Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee7eeaa59 | |||
| 811230fb30 | |||
| 761b7791f9 | |||
| 41e89676c1 | |||
| 484ac9a404 | |||
| c1068bbc51 | |||
| b6fb712c9f | |||
| 3cb8408207 | |||
| 20dd43c093 | |||
| 977839156b | |||
| af4f63f561 | |||
| cdff4ebb8a | |||
| 59f958b085 | |||
| 097d7a0b65 | |||
| 1273777541 | |||
| b6a960879f | |||
| 4bd153104b | |||
| e099fcf600 | |||
| 89fdf401c0 | |||
| 9cc46a1819 | |||
| f7c0e5f51a | |||
| 200581e73c | |||
| 505acf833e | |||
| 1ac5b0447a | |||
| 15a2fc2d5f | |||
| a1cf6a1156 | |||
| 481545c37b | |||
| a67df87013 | |||
| d2dbe2ea2f | |||
| 98ea5e7202 | |||
| 6b5c08e99b | |||
| 11ecad136b | |||
| 3a4dccd9f0 | |||
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 | |||
| e229c60b22 | |||
| 4966bc6a58 | |||
| 617f292516 | |||
| 8e95004022 | |||
| 93df328853 | |||
| 657e74a2e3 | |||
| ea77f7917e | |||
| f6e5a454ae | |||
| cc7ad4b2bc | |||
| 827a599102 | |||
| b9841b762c | |||
| e636cf5d56 | |||
| a896fa66c0 | |||
| 027c1090a4 | |||
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 | |||
| b20f9cc108 | |||
| 515814e1d5 | |||
| fba9838e21 | |||
| a9c2761986 | |||
| db7e5913eb | |||
| aa27d1ea79 | |||
| cf8df2780e | |||
| 419a283c62 | |||
| 1b075a4beb | |||
| 68ff93cf17 | |||
| b22a29e877 | |||
| e601311ca3 | |||
| bb00c3993b | |||
| c9c0de46bf | |||
| 5374299cd5 | |||
| 536e2b2874 | |||
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb | |||
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e |
+79
@@ -72,6 +72,85 @@ steps:
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# -------- NEW: Publish Docker images to GitHub Container Registry --------
|
||||
- name: push-backend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
|
||||
- name: push-frontend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
# -------- NEW: Create GitHub Release --------
|
||||
- name: github-release
|
||||
image: plugins/github-release
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITHUB_TOKEN
|
||||
title: "PicPeak ${DRONE_TAG}"
|
||||
note: |
|
||||
# PicPeak ${DRONE_TAG}
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
This release includes Docker images published to GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:latest
|
||||
|
||||
# Frontend
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:latest
|
||||
```
|
||||
|
||||
## 📦 What's New
|
||||
|
||||
See the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Use the tagged version
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
+31
-18
@@ -1,26 +1,24 @@
|
||||
# Environment Configuration Template
|
||||
# Copy this file to .env and adjust values for your environment
|
||||
# PicPeak Development Environment Configuration
|
||||
# Copy this file to .env for local development
|
||||
|
||||
# Development: Use docker-compose.dev.yml
|
||||
# Production: Use docker-compose.prod.yml with .env.production.example
|
||||
# SECURITY WARNING: This configuration is for development only!
|
||||
# For production, use .env.production.example
|
||||
|
||||
# JWT Secret (CRITICAL for production)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
# JWT Secret (Change in production!)
|
||||
# Generate secure secret with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
|
||||
|
||||
# Application URLs
|
||||
# Application URLs (Docker Compose development setup)
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
BACKEND_URL=http://localhost:3001
|
||||
|
||||
# Database Configuration
|
||||
# SQLite is used for development by default
|
||||
# For production PostgreSQL config, see .env.production.example
|
||||
# Database Configuration (SQLite for development)
|
||||
DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=./data/photo_sharing.db
|
||||
|
||||
# Email Configuration
|
||||
# Development: Uses Mailhog (included in docker-compose.dev.yml)
|
||||
# Production: Configure real SMTP server
|
||||
# Email Configuration (Mailhog for development)
|
||||
# Access Mailhog UI at: http://localhost:8025
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
@@ -28,7 +26,22 @@ SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
|
||||
# Optional: Umami Analytics
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_HASH_SALT=
|
||||
# Backend Port Configuration
|
||||
PORT=3001
|
||||
|
||||
# Optional: Umami Analytics Backend Config
|
||||
# NOTE: Primary configuration through Admin UI > Settings > Analytics
|
||||
# These are fallback values for server-side tracking
|
||||
# UMAMI_URL=https://analytics.example.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
# UMAMI_HASH_SALT=your-hash-salt
|
||||
|
||||
# Development Features
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Admin Setup Notes:
|
||||
# 1. Run 'npm run migrate' in backend folder
|
||||
# 2. Admin credentials will be auto-generated
|
||||
# 3. Check ADMIN_CREDENTIALS.txt for login details
|
||||
# 4. Change password on first login (required)
|
||||
+83
-27
@@ -1,44 +1,100 @@
|
||||
# PicPeak Production Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
# Copy this file to .env and update with your production values
|
||||
|
||||
# Required: Security
|
||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
|
||||
# ============================================
|
||||
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
|
||||
# ============================================
|
||||
|
||||
# Required: URLs (update with your domain)
|
||||
# JWT Secret - REQUIRED (minimum 32 characters)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
|
||||
|
||||
# Application URLs - REQUIRED (your actual domain)
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
ADMIN_URL=https://your-domain.com
|
||||
|
||||
# Required: Email Settings
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
# ============================================
|
||||
# DATABASE CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# Required: Initial Admin Account
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
|
||||
# Database (PostgreSQL recommended for production)
|
||||
# PostgreSQL Configuration (Recommended for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_HOST=postgres # or your database host
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-database-password
|
||||
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
|
||||
|
||||
# Optional: Customization
|
||||
SITE_NAME=PicPeak
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
SESSION_TIMEOUT_MINUTES=60
|
||||
# ============================================
|
||||
# EMAIL CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# Optional: Analytics (Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
# Example: Gmail with App Password
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-16-char-app-password
|
||||
# EMAIL_FROM=Your Name <your-email@gmail.com>
|
||||
|
||||
# Example: SendGrid
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=YOUR-SENDGRID-API-KEY
|
||||
EMAIL_FROM=PicPeak <noreply@your-domain.com>
|
||||
|
||||
# ============================================
|
||||
# ADMIN SETUP - AUTO-GENERATED
|
||||
# ============================================
|
||||
# NOTE: Admin credentials are automatically generated during setup
|
||||
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
|
||||
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
# Umami Analytics (Optional - Fallback values)
|
||||
# Primary config via Admin UI > Settings > Analytics
|
||||
# UMAMI_URL=https://analytics.your-domain.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
# UMAMI_HASH_SALT=your-hash-salt
|
||||
|
||||
# Frontend Analytics (Optional - Fallback values)
|
||||
# VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
|
||||
|
||||
# ============================================
|
||||
# PERFORMANCE & SECURITY TUNING
|
||||
# ============================================
|
||||
|
||||
# Advanced: Performance Tuning
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Security Settings (Defaults are secure)
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
SESSION_TIMEOUT_MINUTES=60
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS=100 # per window
|
||||
|
||||
# Connection Pool (Adjust based on load)
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=25
|
||||
|
||||
# ============================================
|
||||
# DOCKER COMPOSE SPECIFIC
|
||||
# ============================================
|
||||
|
||||
# Traefik Configuration (if using Traefik)
|
||||
DOMAIN=your-domain.com
|
||||
LETSENCRYPT_EMAIL=admin@your-domain.com
|
||||
|
||||
# Volume Paths (Docker)
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
ARCHIVE_PATH=/app/storage/events/archived
|
||||
@@ -4,56 +4,217 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout repository with full history
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for mirroring
|
||||
fetch-depth: 0 # Full history needed for finding the commit
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Create filtered branch
|
||||
- name: Debug - Show current branch and status
|
||||
run: |
|
||||
|
||||
# Create a new branch for GitHub
|
||||
git checkout --orphan -b github-mirror
|
||||
echo "Current branch:"
|
||||
git branch -a
|
||||
echo "Git status:"
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
echo "Checking target commit exists:"
|
||||
git show --oneline 7aca927937 || echo "Target commit not found!"
|
||||
|
||||
- name: Create completely new history from specific commit
|
||||
run: |
|
||||
TARGET_COMMIT="7aca927937"
|
||||
|
||||
# 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
|
||||
# Verify the target commit exists
|
||||
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
|
||||
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a completely new orphan branch (no history)
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Clear the staging area completely
|
||||
git rm -rf . || true
|
||||
|
||||
# Get the file tree from the target commit and create initial commit
|
||||
echo "Creating new history starting from $TARGET_COMMIT..."
|
||||
git read-tree $TARGET_COMMIT
|
||||
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
echo "✅ Created new initial commit: $(git log --oneline -1)"
|
||||
|
||||
# Now get all commits after the target commit and apply their changes
|
||||
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
|
||||
|
||||
if [ -n "$COMMITS_AFTER_TARGET" ]; then
|
||||
echo "📋 Applying changes from commits after $TARGET_COMMIT (excluding Claude commits):"
|
||||
|
||||
for commit in $COMMITS_AFTER_TARGET; do
|
||||
# Get the commit author name
|
||||
COMMIT_AUTHOR_NAME=$(git log --format="%an" -n 1 $commit)
|
||||
|
||||
# Skip commits by Claude
|
||||
if [ "$COMMIT_AUTHOR_NAME" = "Claude" ]; then
|
||||
echo "⚠️ Skipping commit by Claude: $(git log --oneline -1 $commit)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing: $(git log --oneline -1 $commit)"
|
||||
|
||||
# Get the commit message and author info
|
||||
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
|
||||
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
|
||||
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
|
||||
|
||||
# Apply the changes from this commit
|
||||
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
|
||||
# Apply file changes
|
||||
git checkout $commit -- . || true
|
||||
|
||||
# Stage all changes
|
||||
git add -A
|
||||
|
||||
# Only commit if there are changes
|
||||
if ! git diff --cached --quiet; then
|
||||
# Create new commit with original metadata but new SHA
|
||||
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||
GIT_AUTHOR_DATE="$COMMIT_DATE"
|
||||
|
||||
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
|
||||
else
|
||||
echo "⚠️ No changes to commit for $commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Skipping problematic commit $commit"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Finished creating new history"
|
||||
else
|
||||
echo "✅ No commits after target commit - history starts fresh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== New History Summary ==="
|
||||
echo "Total commits in new history: $(git rev-list --count github-mirror)"
|
||||
echo "History starts with: $(git log --oneline --reverse | head -1)"
|
||||
echo "Latest commit: $(git log --oneline -1)"
|
||||
|
||||
|
||||
# Commit the changes
|
||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||
- name: Remove sensitive files and directories
|
||||
run: |
|
||||
# Switch to the github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .env || true
|
||||
rm -rf backend/.env* || true
|
||||
rm -rf frontend/.env* || true
|
||||
rm -rf docker-compose.prod.yml || true
|
||||
rm -rf .claudedocs/ || true
|
||||
rm -rf backend/data/ || true
|
||||
rm -rf backend/storage/ || true
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/install-gitea-runner.sh || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf .github-mirror-exclude || true
|
||||
rm -rf .gitattributes-github || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
rm -rf logs/ || true
|
||||
rm -rf frontend/.claudedocs/ || true
|
||||
rm -rf test-maintenance.sh || true
|
||||
rm -rf storage/ || true
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
|
||||
# Add and commit the cleanup if there are changes
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||
echo "✅ Committed cleanup of sensitive files"
|
||||
else
|
||||
echo "✅ No sensitive files to remove"
|
||||
fi
|
||||
|
||||
echo "Final file structure (top level):"
|
||||
ls -la | head -10 || true
|
||||
|
||||
- name: Push to GitHub
|
||||
- name: Verify completely new history
|
||||
run: |
|
||||
git checkout github-mirror
|
||||
echo "=== Final History Verification ==="
|
||||
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
|
||||
echo ""
|
||||
echo "Complete commit history (should start from target commit content):"
|
||||
git log --oneline --reverse
|
||||
echo ""
|
||||
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
|
||||
echo "🔍 Original target commit content preserved but with new commit ID"
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Force push completely new history to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Switch to github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Force push the filtered branch to GitHub main
|
||||
git push github github-mirror:main --force
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the completely new history to GitHub main
|
||||
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
||||
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
||||
git push github github-mirror:main --force
|
||||
echo "✅ Force push completed - GitHub now has completely new history!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
||||
echo "📊 History starts from commit content: 7aca927937"
|
||||
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
|
||||
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
|
||||
echo "🆕 All commit SHAs are NEW - no connection to previous history"
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -30,15 +31,104 @@ jobs:
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Bump version
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
# Get current version from backend package.json
|
||||
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
set -e # Exit on error
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
@@ -49,14 +139,23 @@ jobs:
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update version in package.json files
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
@@ -65,15 +164,94 @@ jobs:
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
git push
|
||||
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 frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
# 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'
|
||||
run: |
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
@@ -84,5 +262,6 @@ jobs:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -54,3 +54,5 @@ coverage/
|
||||
!storage/thumbnails/.gitkeep
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
PRODUCTION_DEPLOYMENT_GUIDE.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%
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide addresses all known production deployment issues and provides solutions.
|
||||
This comprehensive guide addresses all production deployment scenarios and common issues.
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
@@ -8,43 +8,80 @@ This guide addresses all known production deployment issues and provides solutio
|
||||
Create a `.env` file with ALL required variables:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
# CRITICAL - Must change these!
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
DB_PASSWORD=<strong-password>
|
||||
|
||||
# Application URLs (your actual domain)
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
BACKEND_URL=https://yourdomain.com
|
||||
|
||||
# Database
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres # or external host
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email (Optional but recommended)
|
||||
# Email Configuration (required for notifications)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
SMTP_PASS=your-app-password # Use app-specific password
|
||||
EMAIL_FROM=PicPeak <noreply@yourdomain.com>
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=<generate-random-string>
|
||||
# Port Configuration
|
||||
PORT=3001
|
||||
|
||||
# Performance Tuning
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=25
|
||||
NODE_ENV=production
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional: Umami Analytics (configured via Admin UI)
|
||||
# UMAMI_URL=https://analytics.yourdomain.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
### 2. Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate JWT Secret
|
||||
# Generate JWT Secret (REQUIRED)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Database Password
|
||||
openssl rand -base64 24
|
||||
|
||||
# Generate Umami Hash Salt
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy:
|
||||
|
||||
### Frontend Environment
|
||||
```bash
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api # Uses relative path for reverse proxy
|
||||
|
||||
# Optional: Umami fallback (primary config via Admin UI)
|
||||
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
This ensures all API calls use the same domain/protocol as the frontend.
|
||||
|
||||
### Nginx Proxy Configuration
|
||||
|
||||
The frontend nginx configuration already includes proper proxy settings for:
|
||||
- `/api` → Backend API
|
||||
- `/photos` → Protected photo access
|
||||
- `/thumbnails` → Thumbnail images
|
||||
- `/uploads` → Public uploads (logos, favicons)
|
||||
|
||||
All static assets are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Initial Setup
|
||||
@@ -96,24 +133,31 @@ docker-compose -f docker-compose.prod.yml up -d
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### 4. Create Admin User
|
||||
### 4. Initial Admin Setup
|
||||
|
||||
After deployment, create the first admin user:
|
||||
The admin user is automatically created during database migration:
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
# Run migrations (this creates admin user)
|
||||
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
|
||||
|
||||
# Create admin
|
||||
node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com \
|
||||
--password <your-secure-password>
|
||||
# Admin credentials will be displayed in console and saved to ADMIN_CREDENTIALS.txt
|
||||
# Example output:
|
||||
# ========================================
|
||||
# ✅ Admin user created successfully!
|
||||
# ========================================
|
||||
# Username: admin
|
||||
# Password: SwiftEagle3847!
|
||||
#
|
||||
# ⚠️ IMPORTANT: Change password on first login
|
||||
# ========================================
|
||||
|
||||
# Exit container
|
||||
exit
|
||||
# Retrieve credentials if needed
|
||||
docker-compose -f docker-compose.prod.yml exec backend cat ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Important**: You MUST change the auto-generated password on first login.
|
||||
|
||||
### 5. Configure Email (if using database config)
|
||||
|
||||
1. Login to admin panel: https://yourdomain.com/admin
|
||||
@@ -189,6 +233,23 @@ docker-compose -f docker-compose.prod.yml logs backend | grep email
|
||||
|
||||
## SSL/HTTPS Setup
|
||||
|
||||
### Option 1: Using Traefik (Recommended)
|
||||
|
||||
Add these labels to your docker-compose override:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
```
|
||||
|
||||
### Option 2: Using Certbot
|
||||
|
||||
1. Update `nginx/sites-enabled/default` with your domain
|
||||
2. Run certbot:
|
||||
|
||||
@@ -294,13 +355,18 @@ docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||
- [ ] Strong database password
|
||||
- [ ] Admin password changed from auto-generated one
|
||||
- [ ] SSL/HTTPS enabled
|
||||
- [ ] Firewall configured (only 80/443 open)
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Access logs monitored
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] Rate limiting enabled (built-in)
|
||||
- [ ] File upload restrictions configured
|
||||
- [ ] Password complexity requirements configured (Admin > Settings)
|
||||
- [ ] Session timeout configured (default 60 min)
|
||||
- [ ] Umami analytics configured (if using)
|
||||
- [ ] SMTP credentials secured with app-specific password
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
> 📸 *Gallery preview will be updated soon with latest interface*
|
||||

|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
@@ -160,6 +159,20 @@ Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
</details>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | 🔄 Open |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
+33
-16
@@ -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
|
||||
@@ -1,5 +1,8 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Enhanced Backup System Test Suite
|
||||
|
||||
This directory contains comprehensive tests for the enhanced backup system with S3 support.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests
|
||||
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
|
||||
- Configuration management
|
||||
- S3 backup functionality
|
||||
- Manifest generation
|
||||
- Error handling and recovery
|
||||
- Backward compatibility (local and rsync)
|
||||
- Service lifecycle management
|
||||
|
||||
### Integration Tests
|
||||
- `integration/backup-s3.test.js` - Integration tests for S3 backups
|
||||
- Real S3/MinIO connection tests
|
||||
- Full backup process with actual files
|
||||
- Incremental backup verification
|
||||
- Manifest storage and retrieval
|
||||
- Error recovery scenarios
|
||||
|
||||
### Manual Integration Test Script
|
||||
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
|
||||
- Can test against MinIO, AWS S3, or any S3-compatible service
|
||||
- Tests all backup types (S3, local, rsync)
|
||||
- Performance testing with large files
|
||||
- Detailed progress reporting
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
|
||||
|
||||
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
|
||||
```bash
|
||||
# Start MinIO using Docker
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
3. **Environment Variables** (for integration tests):
|
||||
```bash
|
||||
# Optional - defaults work with local MinIO
|
||||
export TEST_S3_ENDPOINT=http://localhost:9000
|
||||
export TEST_S3_ACCESS_KEY=minioadmin
|
||||
export TEST_S3_SECRET_KEY=minioadmin
|
||||
|
||||
# Skip S3 tests if no S3 service available
|
||||
export SKIP_S3_TESTS=true
|
||||
```
|
||||
|
||||
### Running Unit Tests
|
||||
|
||||
```bash
|
||||
# Run all backup service tests
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Ensure MinIO is running first!
|
||||
|
||||
# Run S3 integration tests
|
||||
npm test -- __tests__/integration/backup-s3.test.js
|
||||
|
||||
# Run with verbose output
|
||||
npm test -- __tests__/integration/backup-s3.test.js --verbose
|
||||
|
||||
# Skip S3 tests if needed
|
||||
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
### Running Manual Integration Tests
|
||||
|
||||
```bash
|
||||
# Test with local MinIO (default)
|
||||
node scripts/test-backup-integration.js
|
||||
|
||||
# Test with AWS S3
|
||||
node scripts/test-backup-integration.js \
|
||||
--endpoint https://s3.amazonaws.com \
|
||||
--access-key YOUR_ACCESS_KEY \
|
||||
--secret-key YOUR_SECRET_KEY \
|
||||
--bucket your-test-bucket
|
||||
|
||||
# Test local backup
|
||||
node scripts/test-backup-integration.js --type local
|
||||
|
||||
# Test with cleanup after completion
|
||||
node scripts/test-backup-integration.js --cleanup
|
||||
|
||||
# Verbose output
|
||||
node scripts/test-backup-integration.js --verbose
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
### Configuration
|
||||
- ✅ Database configuration retrieval
|
||||
- ✅ JSON parsing and error handling
|
||||
- ✅ Configuration validation
|
||||
- ✅ Required field validation
|
||||
|
||||
### S3 Functionality
|
||||
- ✅ S3 client initialization
|
||||
- ✅ Connection testing
|
||||
- ✅ File upload with progress tracking
|
||||
- ✅ Large file handling (multipart upload)
|
||||
- ✅ Metadata and custom headers
|
||||
- ✅ Error handling and retries
|
||||
|
||||
### Backup Process
|
||||
- ✅ Full backup execution
|
||||
- ✅ Incremental backup (changed files only)
|
||||
- ✅ File checksum calculation and comparison
|
||||
- ✅ Database backup inclusion
|
||||
- ✅ Archive inclusion toggle
|
||||
- ✅ File size limits
|
||||
|
||||
### Manifest Generation
|
||||
- ✅ Full manifest generation
|
||||
- ✅ Incremental manifest with parent reference
|
||||
- ✅ JSON and YAML format support
|
||||
- ✅ Manifest validation
|
||||
- ✅ S3 manifest storage and retrieval
|
||||
- ✅ Checksum verification
|
||||
|
||||
### Error Handling
|
||||
- ✅ S3 connection failures
|
||||
- ✅ File read errors
|
||||
- ✅ Individual file failure recovery
|
||||
- ✅ Retry logic with exponential backoff
|
||||
- ✅ Email notifications on failure
|
||||
- ✅ Concurrent backup prevention
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Local directory backup
|
||||
- ✅ Rsync backup
|
||||
- ✅ Existing manifest format support
|
||||
|
||||
### Service Management
|
||||
- ✅ Cron job scheduling
|
||||
- ✅ Service start/stop
|
||||
- ✅ Manual backup triggering
|
||||
- ✅ Backup history and status
|
||||
|
||||
## Mock Setup
|
||||
|
||||
The unit tests use comprehensive mocking:
|
||||
|
||||
```javascript
|
||||
// Database mocking
|
||||
jest.mock('../../src/database/db');
|
||||
|
||||
// S3 client mocking
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
// File system mocking
|
||||
const mockFs = require('mock-fs');
|
||||
|
||||
// Cron job mocking
|
||||
jest.mock('node-cron');
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
To run tests in CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions
|
||||
- name: Run Unit Tests
|
||||
run: npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
```bash
|
||||
# Run tests in debug mode
|
||||
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run single test with console output
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Integration tests create real files and S3 objects
|
||||
- Each test run creates a unique S3 bucket to avoid conflicts
|
||||
- Cleanup is automatic but can be disabled for debugging
|
||||
- Large file tests (10MB+) are included but can be slow
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new backup features:
|
||||
|
||||
1. Add unit tests to `backupService.enhanced.test.js`
|
||||
2. Add integration tests to `backup-s3.test.js` if S3-specific
|
||||
3. Update manual test script for comprehensive testing
|
||||
4. Ensure mocks are properly configured
|
||||
5. Document any new environment requirements
|
||||
@@ -0,0 +1,506 @@
|
||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initialize: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
region: 'us-east-1'
|
||||
};
|
||||
|
||||
describe('S3 Backup Integration Tests', () => {
|
||||
let s3Client;
|
||||
let testStoragePath;
|
||||
let originalEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if no S3 endpoint configured
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Initialize S3 client for test setup
|
||||
s3Client = new S3Client({
|
||||
endpoint: TEST_CONFIG.endpoint,
|
||||
region: TEST_CONFIG.region,
|
||||
credentials: {
|
||||
accessKeyId: TEST_CONFIG.accessKeyId,
|
||||
secretAccessKey: TEST_CONFIG.secretAccessKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
// Create test bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
||||
console.error('Failed to create test bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
await fs.mkdir(testStoragePath, { recursive: true });
|
||||
process.env.STORAGE_PATH = testStoragePath;
|
||||
|
||||
// Set up test data
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
try {
|
||||
// Clean up S3 bucket
|
||||
await cleanupS3Bucket();
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 bucket:', error);
|
||||
}
|
||||
|
||||
// Clean up test storage
|
||||
await fs.rm(testStoragePath, { recursive: true, force: true });
|
||||
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Close database
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean backup tables
|
||||
await db('backup_runs').del();
|
||||
await db('backup_file_states').del();
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
// Configure S3 backup settings
|
||||
await configureS3Backup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Clean up S3 objects created during test
|
||||
await cleanupS3Bucket();
|
||||
});
|
||||
|
||||
describe('S3 Connection and Configuration', () => {
|
||||
it('should successfully connect to S3-compatible storage', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
...TEST_CONFIG,
|
||||
bucket: TEST_CONFIG.bucket,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false
|
||||
});
|
||||
|
||||
const connected = await s3Adapter.testConnection();
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate S3 configuration before backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Remove required configuration
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_secret_key')
|
||||
.del();
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const lastRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(lastRun.status).toBe('failed');
|
||||
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full S3 Backup Process', () => {
|
||||
it('should perform complete S3 backup with all file types', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify backup run completed
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
expect(s3Objects.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for expected file types
|
||||
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
|
||||
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
|
||||
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
|
||||
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
|
||||
|
||||
expect(hasPhotos).toBe(true);
|
||||
expect(hasThumbnails).toBe(true);
|
||||
expect(hasManifest).toBe(true);
|
||||
expect(hasSummary).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large file uploads with multipart', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a large test file (15MB)
|
||||
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
|
||||
const largeFileSize = 15 * 1024 * 1024; // 15MB
|
||||
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
|
||||
await fs.writeFile(largeFilePath, largeFileContent);
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify large file was uploaded
|
||||
const s3Objects = await listS3Objects();
|
||||
const largeFileUploaded = s3Objects.some(obj =>
|
||||
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
|
||||
);
|
||||
|
||||
expect(largeFileUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should include database backup when available', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a mock database backup
|
||||
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
|
||||
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
|
||||
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
|
||||
|
||||
// Record database backup
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'sqlite',
|
||||
file_path: dbBackupPath,
|
||||
file_size_bytes: 100,
|
||||
checksum: 'test123',
|
||||
statistics: JSON.stringify({ tables: {} }),
|
||||
table_checksums: JSON.stringify({})
|
||||
});
|
||||
|
||||
// Configure to include database
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_include_database')
|
||||
.update({ setting_value: 'true' });
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
|
||||
expect(hasDbBackup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Incremental Backup', () => {
|
||||
it('should only upload changed files in incremental backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// First backup - full
|
||||
await backupService.runBackup();
|
||||
|
||||
const firstRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const firstObjectCount = (await listS3Objects()).length;
|
||||
|
||||
// Wait a moment to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Modify one file
|
||||
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
|
||||
await fs.writeFile(modifiedFile, 'modified content');
|
||||
|
||||
// Second backup - incremental
|
||||
await backupService.runBackup();
|
||||
|
||||
const secondRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(secondRun.files_backed_up).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should track file states across backups', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Check file states are recorded
|
||||
const fileStates = await db('backup_file_states').select('*');
|
||||
expect(fileStates.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify checksums are stored
|
||||
const hasChecksums = fileStates.every(state => state.checksum !== null);
|
||||
expect(hasChecksums).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Manifest Storage', () => {
|
||||
it('should upload manifest to S3 and retrieve it', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure YAML manifest format
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_manifest_format')
|
||||
.update({ setting_value: '"yaml"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
|
||||
|
||||
// Retrieve manifest
|
||||
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
|
||||
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest.backup.id).toBeDefined();
|
||||
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
|
||||
});
|
||||
|
||||
it('should validate manifest integrity', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
|
||||
|
||||
expect(validationResult.valid).toBe(true);
|
||||
expect(validationResult.manifest).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle S3 connection failures gracefully', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure with invalid endpoint
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_endpoint')
|
||||
.update({ setting_value: '"http://invalid-endpoint:9999"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('failed');
|
||||
expect(backupRun.error_message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should continue backup despite individual file failures', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a file that will be deleted during backup
|
||||
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
|
||||
await fs.writeFile(tempFile, 'temporary');
|
||||
|
||||
// Mock file deletion during backup
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let callCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
// Delete the temp file to cause an error
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should complete despite one file error
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
|
||||
it('should retry failed uploads with exponential backoff', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Mock S3 upload to fail twice then succeed
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let attemptCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
attemptCount++;
|
||||
if (attemptCount <= 2) {
|
||||
const error = new Error('Network timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should succeed after retries
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(attemptCount).toBeGreaterThan(2);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test directory structure
|
||||
const dirs = [
|
||||
'events/active/event1',
|
||||
'events/active/event2',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'uploads'
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files
|
||||
const files = [
|
||||
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
|
||||
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
|
||||
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
|
||||
{ path: 'events/archived/old-event.zip', content: 'archived content' },
|
||||
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
|
||||
{ path: 'uploads/logo.png', content: 'logo content' }
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
await fs.writeFile(
|
||||
path.join(testStoragePath, file.path),
|
||||
file.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureS3Backup() {
|
||||
const settings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
|
||||
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
|
||||
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
|
||||
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: 'true' },
|
||||
{ setting_key: 'backup_incremental', setting_value: 'true' },
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict(['setting_type', 'setting_key'])
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
async function listS3Objects() {
|
||||
const response = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: TEST_CONFIG.bucket
|
||||
}));
|
||||
return response.Contents || [];
|
||||
}
|
||||
|
||||
async function cleanupS3Bucket() {
|
||||
try {
|
||||
const objects = await listS3Objects();
|
||||
if (objects.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: TEST_CONFIG.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map(obj => ({ Key: obj.Key }))
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
// Mock dependencies before requiring the module
|
||||
jest.mock('../../src/database/db');
|
||||
jest.mock('../../src/utils/logger');
|
||||
jest.mock('../../src/services/emailProcessor');
|
||||
jest.mock('node-cron');
|
||||
jest.mock('../../src/services/backupManifest');
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const { db } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
const { queueEmail } = require('../../src/services/emailProcessor');
|
||||
const cron = require('node-cron');
|
||||
const backupManifest = require('../../src/services/backupManifest');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
|
||||
describe('Enhanced Backup Service Tests', () => {
|
||||
let mockDb;
|
||||
let mockS3Client;
|
||||
let mockCronJob;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
db.mockReturnValue(mockDb);
|
||||
|
||||
// Mock cron job
|
||||
mockCronJob = {
|
||||
stop: jest.fn()
|
||||
};
|
||||
cron.schedule.mockReturnValue(mockCronJob);
|
||||
|
||||
// Mock S3 client
|
||||
mockS3Client = {
|
||||
testConnection: jest.fn().mockResolvedValue(true),
|
||||
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
||||
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
||||
download: jest.fn().mockResolvedValue(),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(),
|
||||
list: jest.fn().mockResolvedValue({ Contents: [] })
|
||||
};
|
||||
S3StorageAdapter.mockImplementation(() => mockS3Client);
|
||||
|
||||
// Mock backup manifest
|
||||
backupManifest.generateManifest = jest.fn().mockResolvedValue({
|
||||
backup: { id: 'test-backup-123' },
|
||||
version: '2.0'
|
||||
});
|
||||
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
|
||||
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
|
||||
backupManifest.validateManifest = jest.fn();
|
||||
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
|
||||
|
||||
// Mock logger
|
||||
logger.info = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('should retrieve and parse backup configuration from database', async () => {
|
||||
const mockSettings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
|
||||
{ setting_key: 'backup_retention_days', setting_value: '30' }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue(mockSettings);
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_retention_days: 30
|
||||
});
|
||||
|
||||
expect(db).toHaveBeenCalledWith('app_settings');
|
||||
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors gracefully', async () => {
|
||||
const mockSettings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue(mockSettings);
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backup_enabled: 'invalid-json'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null on database error', async () => {
|
||||
mockDb.select.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Backup Functionality', () => {
|
||||
beforeEach(() => {
|
||||
// Mock file system
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content'),
|
||||
'photo2.jpg': Buffer.from('photo2 content')
|
||||
},
|
||||
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
|
||||
'/storage/thumbnails': {
|
||||
'thumb1.jpg': Buffer.from('thumb1 content')
|
||||
},
|
||||
'/storage/uploads': {
|
||||
'logo.png': Buffer.from('logo content')
|
||||
}
|
||||
});
|
||||
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
});
|
||||
|
||||
it('should perform S3 backup with correct configuration', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_region: 'us-east-1',
|
||||
backup_s3_endpoint: 'https://s3.amazonaws.com',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_include_archived: true,
|
||||
backup_max_file_size_mb: 100
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.where.mockReturnThis();
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
||||
type: 'sqlite',
|
||||
backupFile: null,
|
||||
hasChanged: true
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledWith({
|
||||
bucket: 'test-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
accessKeyId: 'test-key',
|
||||
secretAccessKey: 'test-secret',
|
||||
forcePathStyle: false,
|
||||
sslEnabled: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000
|
||||
});
|
||||
|
||||
expect(mockS3Client.testConnection).toHaveBeenCalled();
|
||||
expect(mockS3Client.upload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle S3 upload failures gracefully', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
|
||||
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'failed',
|
||||
error_message: expect.stringContaining('Connection failed')
|
||||
}));
|
||||
});
|
||||
|
||||
it('should skip unchanged files in incremental backup', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_incremental: true
|
||||
};
|
||||
|
||||
// Mock existing file state
|
||||
mockDb.first.mockImplementation((query) => {
|
||||
if (query === undefined) {
|
||||
return Promise.resolve({
|
||||
file_path: 'events/active/event1/photo1.jpg',
|
||||
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Should skip unchanged file
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const photo1Uploaded = uploadCalls.some(call =>
|
||||
call[1].includes('photo1.jpg')
|
||||
);
|
||||
expect(photo1Uploaded).toBe(false);
|
||||
});
|
||||
|
||||
it('should include database backup when configured', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_include_database: true
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
||||
type: 'sqlite',
|
||||
backupFile: '/backup/db-backup.sql',
|
||||
size: 1024000,
|
||||
checksum: 'abc123',
|
||||
hasChanged: false
|
||||
});
|
||||
|
||||
// Mock database backup file
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup/db-backup.sql': Buffer.from('database backup content')
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup was uploaded
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const dbBackupUploaded = uploadCalls.some(call =>
|
||||
call[1].includes('database/db-backup.sql')
|
||||
);
|
||||
expect(dbBackupUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate required S3 configuration', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket'
|
||||
// Missing access key and secret key
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'S3 backup failed:',
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('S3 backup configuration incomplete')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manifest Generation', () => {
|
||||
it('should generate and save manifest for successful backup', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup',
|
||||
backup_manifest_format: 'json'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content')
|
||||
},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup',
|
||||
format: 'json'
|
||||
})
|
||||
);
|
||||
|
||||
expect(backupManifest.saveManifest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate incremental manifest when parent exists', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup'
|
||||
};
|
||||
|
||||
const lastBackup = {
|
||||
id: 1,
|
||||
manifest_path: '/backup/manifests/previous.json',
|
||||
manifest_id: 'previous-backup-123'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([2]);
|
||||
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
|
||||
mockDb.orderBy.mockReturnThis();
|
||||
mockDb.where.mockReturnThis();
|
||||
mockDb.whereNot = jest.fn().mockReturnThis();
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
|
||||
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upload manifest to S3 for S3 backups', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_manifest_format: 'yaml'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
const manifest = {
|
||||
backup: { id: 'backup-123' },
|
||||
version: '2.0'
|
||||
};
|
||||
backupManifest.generateManifest.mockResolvedValue(manifest);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/storage/temp': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify manifest was uploaded to S3
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const manifestUploaded = uploadCalls.some(call =>
|
||||
call[1].includes('manifests/backup-manifest-backup-123.yaml')
|
||||
);
|
||||
expect(manifestUploaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should support local backup destination', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup/local'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content')
|
||||
},
|
||||
'/backup/local': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify files were copied to local destination
|
||||
const fs = require('fs');
|
||||
const destPath = '/backup/local/events/active/event1/photo1.jpg';
|
||||
expect(fs.existsSync(destPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support rsync backup destination', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'rsync',
|
||||
backup_rsync_host: 'backup.example.com',
|
||||
backup_rsync_user: 'backup',
|
||||
backup_rsync_path: '/remote/backup'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
// Mock exec for rsync
|
||||
const { exec } = require('child_process');
|
||||
const mockExec = jest.fn((cmd, callback) => {
|
||||
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
|
||||
});
|
||||
exec.mockImplementation(mockExec);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('rsync'),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
// Mock file that throws error on read
|
||||
const fs = require('fs');
|
||||
const originalCreateReadStream = fs.createReadStream;
|
||||
fs.createReadStream = jest.fn((path) => {
|
||||
if (path.includes('error.jpg')) {
|
||||
const stream = new EventEmitter();
|
||||
process.nextTick(() => stream.emit('error', new Error('File read error')));
|
||||
return stream;
|
||||
}
|
||||
return originalCreateReadStream(path);
|
||||
});
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {
|
||||
'error.jpg': Buffer.from('content'),
|
||||
'good.jpg': Buffer.from('content')
|
||||
}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Should continue with other files despite error
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to backup file'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
fs.createReadStream = originalCreateReadStream;
|
||||
});
|
||||
|
||||
it('should send failure email on backup error', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_email_on_failure: true
|
||||
};
|
||||
|
||||
const admins = [
|
||||
{ email: 'admin1@example.com', is_active: true },
|
||||
{ email: 'admin2@example.com', is_active: true }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.where.mockReturnThis();
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig')
|
||||
.mockResolvedValueOnce(config)
|
||||
.mockResolvedValueOnce(config);
|
||||
|
||||
// Force an error
|
||||
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
|
||||
|
||||
// Mock admin users query
|
||||
db.mockImplementation((table) => {
|
||||
if (table === 'admin_users') {
|
||||
return {
|
||||
where: jest.fn().mockResolvedValue(admins)
|
||||
};
|
||||
}
|
||||
return mockDb;
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(queueEmail).toHaveBeenCalledTimes(2);
|
||||
expect(queueEmail).toHaveBeenCalledWith(
|
||||
null,
|
||||
'admin1@example.com',
|
||||
'backup_failed',
|
||||
expect.objectContaining({
|
||||
error_message: 'Storage error'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle concurrent backup attempts', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup'
|
||||
};
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
// Start two backups concurrently
|
||||
const backup1 = backupService.runBackup();
|
||||
const backup2 = backupService.runBackup();
|
||||
|
||||
await Promise.all([backup1, backup2]);
|
||||
|
||||
// Second backup should be skipped
|
||||
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Lifecycle', () => {
|
||||
it('should start backup service with cron schedule', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_schedule: '0 3 * * *' // 3 AM daily
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue(
|
||||
Object.entries(config).map(([key, value]) => ({
|
||||
setting_key: key,
|
||||
setting_value: value.toString()
|
||||
}))
|
||||
);
|
||||
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
|
||||
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
|
||||
});
|
||||
|
||||
it('should stop existing job when restarting service', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_schedule: '0 2 * * *'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue(
|
||||
Object.entries(config).map(([key, value]) => ({
|
||||
setting_key: key,
|
||||
setting_value: value.toString()
|
||||
}))
|
||||
);
|
||||
|
||||
// Start service twice
|
||||
await backupService.startBackupService();
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(mockCronJob.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start service when backup is disabled', async () => {
|
||||
const config = {
|
||||
backup_enabled: false
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([
|
||||
{ setting_key: 'backup_enabled', setting_value: 'false' }
|
||||
]);
|
||||
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(cron.schedule).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup Status and History', () => {
|
||||
it('should return backup status with recent runs', async () => {
|
||||
const recentRuns = [
|
||||
{
|
||||
id: 1,
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
files_backed_up: 100,
|
||||
total_size_bytes: 1024000,
|
||||
manifest_path: '/backup/manifest.json'
|
||||
}
|
||||
];
|
||||
|
||||
mockDb.limit.mockResolvedValue(recentRuns);
|
||||
|
||||
backupManifest.validateManifest.mockImplementation(() => true);
|
||||
|
||||
const status = await backupService.getBackupStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
isRunning: false,
|
||||
isHealthy: true,
|
||||
lastRun: expect.objectContaining({
|
||||
...recentRuns[0],
|
||||
manifestValid: true
|
||||
}),
|
||||
recentRuns: recentRuns,
|
||||
nextScheduledRun: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up old backup runs', async () => {
|
||||
mockDb.delete.mockResolvedValue(5);
|
||||
|
||||
await backupService.cleanupOldBackupRuns(30);
|
||||
|
||||
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackupManifest', () => {
|
||||
it('should retrieve manifest from local filesystem', async () => {
|
||||
const backupRun = {
|
||||
id: 1,
|
||||
manifest_path: '/backup/manifests/backup-123.json'
|
||||
};
|
||||
|
||||
mockDb.first.mockResolvedValue(backupRun);
|
||||
|
||||
const manifest = { backup: { id: 'backup-123' } };
|
||||
backupManifest.loadManifest.mockResolvedValue(manifest);
|
||||
backupManifest.generateSummaryReport.mockReturnValue('Summary');
|
||||
|
||||
const result = await backupService.getBackupManifest(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
manifest: manifest,
|
||||
summary: 'Summary'
|
||||
});
|
||||
});
|
||||
|
||||
it('should retrieve manifest from S3', async () => {
|
||||
const backupRun = {
|
||||
id: 1,
|
||||
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
|
||||
};
|
||||
|
||||
mockDb.first.mockResolvedValue(backupRun);
|
||||
mockDb.select.mockResolvedValue([
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
|
||||
]);
|
||||
|
||||
const manifest = { backup: { id: 'backup-123' } };
|
||||
backupManifest.loadManifest.mockResolvedValue(manifest);
|
||||
|
||||
await backupService.getBackupManifest(1);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalled();
|
||||
expect(mockS3Client.download).toHaveBeenCalledWith(
|
||||
'backups/manifests/backup-123.json',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
# Security Logging Documentation
|
||||
|
||||
## Overview
|
||||
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
|
||||
|
||||
## Log Files
|
||||
|
||||
### 1. **security.log**
|
||||
- Location: `logs/security.log`
|
||||
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
|
||||
- Max Size: 20MB with rotation (keeps 10 files)
|
||||
- Format: JSON with timestamp
|
||||
|
||||
### 2. **error.log**
|
||||
- Location: `logs/error.log`
|
||||
- Contains: All error-level logs including auth failures
|
||||
- Max Size: 10MB with rotation (keeps 5 files)
|
||||
|
||||
### 3. **combined.log**
|
||||
- Location: `logs/combined.log`
|
||||
- Contains: All logs (info, warn, error)
|
||||
- Max Size: 50MB with rotation (keeps 10 files)
|
||||
|
||||
## Security Events Logged
|
||||
|
||||
### Rate Limiting
|
||||
When rate limits are exceeded, the following is logged:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "Rate limit exceeded",
|
||||
"security": true,
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/login",
|
||||
"method": "POST",
|
||||
"authenticated": false,
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"referer": "https://app.example.com",
|
||||
"origin": "https://app.example.com",
|
||||
"headers": {
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.1"
|
||||
},
|
||||
"requestUrl": "/api/admin/login",
|
||||
"rateLimitInfo": {
|
||||
"limit": 5,
|
||||
"current": 6,
|
||||
"remaining": 0,
|
||||
"resetTime": "2024-01-18T14:38:45.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
#### Admin Login Failures
|
||||
- Tracked in `login_attempts` table
|
||||
- Logged with: IP address, username, user agent, timestamp
|
||||
- Account lockout after 5 failures in 15 minutes
|
||||
|
||||
#### Gallery Password Failures
|
||||
- Tracked in `access_logs` table with action='login_fail'
|
||||
- Logged with: event_id, IP address, user agent
|
||||
- Gallery lockout after 5 failures in 15 minutes
|
||||
|
||||
### JWT Validation Failures
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "JWT validation failed",
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/events",
|
||||
"method": "GET",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"error": "TokenExpiredError",
|
||||
"message": "jwt expired"
|
||||
}
|
||||
```
|
||||
|
||||
### Suspicious Activity
|
||||
- Multiple IPs attempting login for same account
|
||||
- Token usage from different IP than issued
|
||||
- Token usage after password change
|
||||
- Revoked token usage attempts
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
All rate limiting settings are configurable via the admin panel:
|
||||
|
||||
| Setting | Default | Range | Description |
|
||||
|---------|---------|-------|-------------|
|
||||
| rate_limit_enabled | true | - | Enable/disable rate limiting |
|
||||
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
|
||||
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
|
||||
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
|
||||
|
||||
## Database Tables
|
||||
|
||||
### login_attempts
|
||||
```sql
|
||||
- id
|
||||
- username
|
||||
- ip_address
|
||||
- user_agent
|
||||
- success (boolean)
|
||||
- created_at
|
||||
```
|
||||
|
||||
### access_logs
|
||||
```sql
|
||||
- id
|
||||
- event_id
|
||||
- ip_address
|
||||
- user_agent
|
||||
- action ('view', 'download', 'login_success', 'login_fail')
|
||||
- photo_id (nullable)
|
||||
- created_at
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `LOG_LEVEL`: Set logging level (default: 'info')
|
||||
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
|
||||
|
||||
## Monitoring Recommendations
|
||||
|
||||
1. **Set up alerts for:**
|
||||
- Rate limit exceeded events (possible DDoS)
|
||||
- Multiple failed login attempts from same IP
|
||||
- Account lockout events
|
||||
- JWT validation failures spike
|
||||
|
||||
2. **Regular review:**
|
||||
- Check security.log for patterns
|
||||
- Review login_attempts table for brute force attempts
|
||||
- Monitor access_logs for suspicious gallery access patterns
|
||||
|
||||
3. **Log analysis tools:**
|
||||
- Use log aggregation tools (ELK stack, Splunk)
|
||||
- Set up dashboards for security metrics
|
||||
- Configure alerts for threshold breaches
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
1. Ensure logs directory has proper permissions
|
||||
2. Set up log rotation outside of application if needed
|
||||
3. Consider shipping logs to centralized logging service
|
||||
4. Monitor disk space for log files
|
||||
5. Set `LOG_TO_CONSOLE=true` for container deployments
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log sensitive data (passwords, tokens)
|
||||
2. Use generic error messages to prevent user enumeration
|
||||
3. Clean up old login attempts regularly (7 days retention)
|
||||
4. Monitor for unusual patterns in real-time
|
||||
5. Keep rate limit settings appropriate for your usage
|
||||
+4
-4
@@ -40,10 +40,10 @@ const config = {
|
||||
keepAliveInitialDelayMillis: 0
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
acquireTimeoutMillis: 30000,
|
||||
createTimeoutMillis: 30000,
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Fix email_queue table by ensuring it doesn't have updated_at column
|
||||
* This migration addresses the PostgreSQL error where queries are trying to update
|
||||
* a non-existent updated_at column
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// First, check if the column exists
|
||||
const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at');
|
||||
|
||||
if (hasUpdatedAt) {
|
||||
console.log('Found updated_at column in email_queue table, removing it...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Also ensure the table has all required columns
|
||||
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
|
||||
if (!hasCreatedAt) {
|
||||
console.log('Adding missing created_at column to email_queue table...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
console.log('email_queue table schema fixed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// In the down migration, we don't add back updated_at since it shouldn't exist
|
||||
// This is intentionally left minimal
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
exports.up = async function(knex) {
|
||||
// Update gallery_created template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie ist bereit!',
|
||||
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||
{{#if welcome_message}}
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Persönliche Nachricht:</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p><strong>Galerie-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Ablaufdatum: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>
|
||||
<p style="background-color: #FEF3C7; padding: 15px; border-radius: 5px; border-left: 4px solid #F59E0B;">
|
||||
<strong>Wichtig:</strong> Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie anzeigen</a>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie erfolgreich erstellt
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!
|
||||
|
||||
{{#if welcome_message}}
|
||||
Persönliche Nachricht:
|
||||
{{welcome_message}}
|
||||
|
||||
{{/if}}
|
||||
Galerie-Details:
|
||||
- Veranstaltungsdatum: {{event_date}}
|
||||
- Galerie-Link: {{gallery_link}}
|
||||
- Passwort: {{gallery_password}}
|
||||
- Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.
|
||||
|
||||
WICHTIG: Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update expiration_warning template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie läuft bald ab',
|
||||
body_html_de: `<h2>Galerie läuft bald ab</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" läuft in <strong>{{days_remaining}} Tagen</strong> ab.</p>
|
||||
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.</p>
|
||||
<p><strong>Ablaufdatum:</strong> {{expiry_date}}</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie jetzt besuchen</a>
|
||||
<p style="background-color: #FEE2E2; padding: 15px; border-radius: 5px; border-left: 4px solid #EF4444;">
|
||||
<strong>Erinnerung:</strong> Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie läuft bald ab
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.
|
||||
|
||||
Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.
|
||||
|
||||
Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Galerie-Link: {{gallery_link}}
|
||||
|
||||
ERINNERUNG: Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update gallery_expired template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen',
|
||||
body_html_de: `<h2>Galerie abgelaufen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.</p>
|
||||
<p>Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.</p>
|
||||
<p>Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:</p>
|
||||
<p style="background-color: #F3F4F6; padding: 15px; border-radius: 5px;">
|
||||
<strong>Kontakt:</strong><br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a><br>
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
</p>
|
||||
<p>Vielen Dank für die Nutzung unseres Foto-Sharing-Services!</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie abgelaufen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.
|
||||
|
||||
Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.
|
||||
|
||||
Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:
|
||||
|
||||
E-Mail: {{admin_email}}
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
|
||||
Vielen Dank für die Nutzung unseres Foto-Sharing-Services!
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update archive_complete template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_de: 'Archivierung abgeschlossen: {{event_name}}',
|
||||
body_html_de: `<h2>Archivierung abgeschlossen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.</p>
|
||||
<p><strong>Archiv-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Archivgröße: {{archive_size}}</li>
|
||||
<li>Archivierungsdatum: {{archive_date}}</li>
|
||||
<li>Anzahl der Fotos: {{photo_count}}</li>
|
||||
</ul>
|
||||
<p>Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.</p>
|
||||
<p style="background-color: #F0FDF4; padding: 15px; border-radius: 5px; border-left: 4px solid #22C55E;">
|
||||
<strong>✓ Erfolgreich archiviert:</strong> Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
</p>
|
||||
<p>Kontakt für Archivzugriff:<br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a></p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Archivierung abgeschlossen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.
|
||||
|
||||
Archiv-Details:
|
||||
- Archivgröße: {{archive_size}}
|
||||
- Archivierungsdatum: {{archive_date}}
|
||||
- Anzahl der Fotos: {{photo_count}}
|
||||
|
||||
Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.
|
||||
|
||||
✓ ERFOLGREICH ARCHIVIERT: Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
|
||||
Kontakt für Archivzugriff:
|
||||
E-Mail: {{admin_email}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Also update the non-language-specific fields to match German for consistency
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to previous German translations
|
||||
// This is a simplified rollback - in production you might want to store the old values
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language column to events table if it doesn't exist
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (!hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.string('language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Add default_language to email_configs if it doesn't exist
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (!hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.string('default_language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Set default language to German for the existing email config
|
||||
await knex('email_configs')
|
||||
.update({
|
||||
default_language: 'de'
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove language column from events table
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('language');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove default_language from email_configs
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.dropColumn('default_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add rate limit settings to app_settings
|
||||
const rateLimitSettings = [
|
||||
{
|
||||
setting_key: 'rate_limit_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_window_minutes',
|
||||
setting_value: JSON.stringify(15),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_max_requests',
|
||||
setting_value: JSON.stringify(1000),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_auth_max_requests',
|
||||
setting_value: JSON.stringify(5),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_skip_authenticated',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_public_endpoints_only',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'security'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert settings if they don't exist
|
||||
for (const setting of rateLimitSettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove rate limit settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'rate_limit_enabled',
|
||||
'rate_limit_window_minutes',
|
||||
'rate_limit_max_requests',
|
||||
'rate_limit_auth_max_requests',
|
||||
'rate_limit_skip_authenticated',
|
||||
'rate_limit_public_endpoints_only'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
exports.up = async function(knex) {
|
||||
// Update English templates to match the quality and content of German templates
|
||||
|
||||
// 1. Gallery Created - Match German version with proper styling and conditionals
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery is ready',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> for {{event_date}} has been successfully created and is now online!</p>
|
||||
|
||||
{{#if welcome_message}}
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Personal message from your photographer:</strong></p>
|
||||
<p style="margin: 10px 0 0 0;">{{welcome_message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Your access data:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 10px;"><strong>Gallery link:</strong> <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li style="margin-bottom: 10px;"><strong>Password:</strong> {{gallery_password}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500;">View Gallery</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Important:</strong> Your gallery will be available until <strong>{{expiry_date}}</strong>. After this date, the photos will be archived and will only be available upon request.</p>
|
||||
</div>
|
||||
|
||||
<p>We hope you enjoy your photos!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" for {{event_date}} has been successfully created and is now online!
|
||||
|
||||
{{#if welcome_message}}
|
||||
Personal message from your photographer:
|
||||
{{welcome_message}}
|
||||
{{/if}}
|
||||
|
||||
Your access data:
|
||||
- Gallery link: {{gallery_link}}
|
||||
- Password: {{gallery_password}}
|
||||
|
||||
Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request.
|
||||
|
||||
We hope you enjoy your photos!
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 2. Expiration Warning - Match German version with urgency and styling
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery expires soon',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> will expire in <strong style="color: #e74c3c; font-size: 18px;">{{days_remaining}} days</strong>!</p>
|
||||
|
||||
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold; font-size: 16px;">⚠️ Important Notice</p>
|
||||
<p style="margin: 10px 0 0 0;">After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Don't miss out – download your photos now!</strong></p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 14px 35px; background-color: #e74c3c; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Visit Gallery Now</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Quick reminder of your access data:</strong></p>
|
||||
<ul style="list-style: none; padding: 0; margin: 10px 0 0 0;">
|
||||
<li>Gallery link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days!
|
||||
|
||||
⚠️ Important Notice
|
||||
After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.
|
||||
|
||||
Don't miss out – download your photos now!
|
||||
|
||||
Quick reminder of your access data:
|
||||
- Gallery link: {{gallery_link}}
|
||||
- Password: {{gallery_password}}
|
||||
|
||||
If you have any questions, please don't hesitate to contact us.
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 3. Gallery Expired - Match German version with contact information
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery has expired',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> expired on {{expiry_date}} and is no longer accessible online.</p>
|
||||
|
||||
<div style="background-color: #f9f9f9; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<h3 style="margin-top: 0;">Your photos are safely archived</h3>
|
||||
<p>Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:</p>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 8px;">📧 Email: <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a></li>
|
||||
{{#if support_phone}}
|
||||
<li>📞 Phone: {{support_phone}}</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Please have the following information ready when contacting us:</p>
|
||||
<ul>
|
||||
<li>Event name: {{event_name}}</li>
|
||||
<li>Event date: {{event_date}}</li>
|
||||
<li>Expiry date: {{expiry_date}}</li>
|
||||
</ul>
|
||||
|
||||
<p>We'll be happy to help you access your archived photos.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.
|
||||
|
||||
Your photos are safely archived
|
||||
Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:
|
||||
|
||||
📧 Email: {{support_email}}
|
||||
{{#if support_phone}}📞 Phone: {{support_phone}}{{/if}}
|
||||
|
||||
Please have the following information ready when contacting us:
|
||||
- Event name: {{event_name}}
|
||||
- Event date: {{event_date}}
|
||||
- Expiry date: {{expiry_date}}
|
||||
|
||||
We'll be happy to help you access your archived photos.
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 4. Archive Complete - Match German version with success message and details
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery has been successfully archived',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> has been successfully archived.</p>
|
||||
|
||||
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold;">✅ Archive successfully created</p>
|
||||
<p style="margin: 10px 0 0 0;">Your photos are now safely stored in our archive.</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Archive details:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 8px;"><strong>Event:</strong> {{event_name}}</li>
|
||||
<li style="margin-bottom: 8px;"><strong>Archive date:</strong> {{archive_date}}</li>
|
||||
<li style="margin-bottom: 8px;"><strong>Number of photos:</strong> {{photo_count}}</li>
|
||||
<li><strong>Archive size:</strong> {{archive_size}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you need access to your archived photos in the future, please contact us at:</p>
|
||||
<p style="margin-left: 20px;">
|
||||
📧 <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a><br>
|
||||
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||
</p>
|
||||
|
||||
<p>Thank you for using our photo sharing service!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" has been successfully archived.
|
||||
|
||||
✅ Archive successfully created
|
||||
Your photos are now safely stored in our archive.
|
||||
|
||||
Archive details:
|
||||
- Event: {{event_name}}
|
||||
- Archive date: {{archive_date}}
|
||||
- Number of photos: {{photo_count}}
|
||||
- Archive size: {{archive_size}}
|
||||
|
||||
If you need access to your archived photos in the future, please contact us at:
|
||||
📧 {{support_email}}
|
||||
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||
|
||||
Thank you for using our photo sharing service!
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 5. Test Email - Update to match German style
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'test_email')
|
||||
.update({
|
||||
subject_en: 'Test Email - Photo Sharing Platform',
|
||||
body_html_en: `
|
||||
<h2>Test Email</h2>
|
||||
|
||||
<p>This is a test email from your photo sharing platform.</p>
|
||||
|
||||
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>✅ Email configuration successful!</strong></p>
|
||||
<p style="margin: 10px 0 0 0;">Your email settings have been configured correctly and emails can be sent.</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Configuration details:</strong></p>
|
||||
<ul style="margin: 10px 0 0 0;">
|
||||
<li>Timestamp: {{timestamp}}</li>
|
||||
<li>Sender: {{from_email}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Test Email
|
||||
|
||||
This is a test email from your photo sharing platform.
|
||||
|
||||
✅ Email configuration successful!
|
||||
Your email settings have been configured correctly and emails can be sent.
|
||||
|
||||
Configuration details:
|
||||
- Timestamp: {{timestamp}}
|
||||
- Sender: {{from_email}}
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to previous simpler English templates
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_en: 'Your Photo Gallery is Ready',
|
||||
body_html_en: '<h2>Hello,</h2><p>Your photo gallery "{{event_name}}" has been created.</p><p><strong>Access Link:</strong> <a href="{{gallery_link}}">{{gallery_link}}</a></p><p><strong>Password:</strong> {{gallery_password}}</p><p>The gallery will be available until {{expiry_date}}.</p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" has been created. Access Link: {{gallery_link}} Password: {{gallery_password}} The gallery will be available until {{expiry_date}}.'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_en: 'Gallery Expires in {{days_remaining}} Days',
|
||||
body_html_en: '<h2>Reminder</h2><p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p><p>Please download your photos before {{expiry_date}}.</p><p><a href="{{gallery_link}}">Access Gallery</a></p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days. Please download your photos before {{expiry_date}}. Access Gallery: {{gallery_link}}'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_en: 'Gallery Expired',
|
||||
body_html_en: '<h2>Gallery Expired</h2><p>Your photo gallery "{{event_name}}" has expired and is no longer accessible.</p><p>If you need access to your photos, please contact support.</p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" has expired and is no longer accessible. If you need access to your photos, please contact support.'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_en: 'Gallery Archived',
|
||||
body_html_en: '<h2>Archive Complete</h2><p>Your gallery "{{event_name}}" has been archived.</p><p>Archive size: {{archive_size}}</p>',
|
||||
body_text_en: 'Your gallery "{{event_name}}" has been archived. Archive size: {{archive_size}}'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'test_email')
|
||||
.update({
|
||||
subject_en: 'Test Email',
|
||||
body_html_en: '<p>This is a test email sent at {{timestamp}}.</p>',
|
||||
body_text_en: 'This is a test email sent at {{timestamp}}.'
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding backup service tables and settings...');
|
||||
|
||||
// Create backup_runs table to track backup history
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (!hasBackupRunsTable) {
|
||||
await db.schema.createTable('backup_runs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed
|
||||
table.string('backup_type'); // full, incremental
|
||||
table.integer('files_backed_up').defaultTo(0);
|
||||
table.bigInteger('total_size_bytes').defaultTo(0);
|
||||
table.integer('duration_seconds');
|
||||
table.text('error_message');
|
||||
table.json('statistics'); // Detailed stats about the backup
|
||||
table.json('file_checksums'); // Store checksums for change detection
|
||||
});
|
||||
}
|
||||
|
||||
// Create backup_file_states table to track individual file states
|
||||
const hasBackupFileStatesTable = await db.schema.hasTable('backup_file_states');
|
||||
if (!hasBackupFileStatesTable) {
|
||||
await db.schema.createTable('backup_file_states', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('file_path').notNullable();
|
||||
table.string('checksum').notNullable();
|
||||
table.bigInteger('size_bytes');
|
||||
table.datetime('last_modified');
|
||||
table.datetime('last_backed_up');
|
||||
table.boolean('is_archived').defaultTo(false);
|
||||
table.index(['file_path'], 'idx_backup_file_path');
|
||||
table.index(['checksum'], 'idx_backup_checksum');
|
||||
});
|
||||
}
|
||||
|
||||
// Add backup-related settings to app_settings
|
||||
const backupSettings = [
|
||||
{
|
||||
setting_key: 'backup_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_schedule',
|
||||
setting_value: JSON.stringify('0 2 * * *'), // Default: 2 AM daily
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_destination_type',
|
||||
setting_value: JSON.stringify('local'), // local, rsync, s3
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_destination_path',
|
||||
setting_value: JSON.stringify('/backup/picpeak'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_host',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_user',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_path',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_ssh_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_endpoint',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_bucket',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_access_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_secret_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_region',
|
||||
setting_value: JSON.stringify('us-east-1'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_retention_days',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_compression',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_email_on_failure',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_email_on_success',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_max_file_size_mb',
|
||||
setting_value: JSON.stringify(5000), // Skip files larger than 5GB
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_exclude_patterns',
|
||||
setting_value: JSON.stringify(['*.tmp', '.DS_Store', 'Thumbs.db']),
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert backup settings if they don't exist
|
||||
for (const setting of backupSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add backup-related email templates
|
||||
const backupEmailTemplates = [
|
||||
{
|
||||
template_key: 'backup_failed',
|
||||
subject_en: 'Backup Failed - Immediate Attention Required',
|
||||
subject_de: 'Backup fehlgeschlagen - Sofortige Aufmerksamkeit erforderlich',
|
||||
body_html_en: `<h2>Backup Failed</h2>
|
||||
<p>The scheduled backup has failed and requires immediate attention.</p>
|
||||
<p><strong>Error Details:</strong></p>
|
||||
<ul>
|
||||
<li>Start Time: {{start_time}}</li>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Error: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Please check the system logs for more details and resolve the issue as soon as possible.</p>`,
|
||||
body_html_de: `<h2>Backup fehlgeschlagen</h2>
|
||||
<p>Das geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
|
||||
<p><strong>Fehlerdetails:</strong></p>
|
||||
<ul>
|
||||
<li>Startzeit: {{start_time}}</li>
|
||||
<li>Backup-Typ: {{backup_type}}</li>
|
||||
<li>Fehler: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und beheben Sie das Problem so schnell wie möglich.</p>`,
|
||||
body_text_en: 'Backup Failed\n\nThe scheduled backup has failed and requires immediate attention.\n\nStart Time: {{start_time}}\nBackup Type: {{backup_type}}\nError: {{error_message}}\n\nPlease check the system logs for more details.',
|
||||
body_text_de: 'Backup fehlgeschlagen\n\nDas geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.\n\nStartzeit: {{start_time}}\nBackup-Typ: {{backup_type}}\nFehler: {{error_message}}\n\nBitte überprüfen Sie die Systemprotokolle für weitere Details.',
|
||||
variables: JSON.stringify(['start_time', 'backup_type', 'error_message'])
|
||||
},
|
||||
{
|
||||
template_key: 'backup_completed',
|
||||
subject_en: 'Backup Completed Successfully',
|
||||
subject_de: 'Backup erfolgreich abgeschlossen',
|
||||
body_html_en: `<h2>Backup Completed</h2>
|
||||
<p>The scheduled backup has been completed successfully.</p>
|
||||
<p><strong>Backup Summary:</strong></p>
|
||||
<ul>
|
||||
<li>Start Time: {{start_time}}</li>
|
||||
<li>Duration: {{duration}}</li>
|
||||
<li>Files Backed Up: {{files_count}}</li>
|
||||
<li>Total Size: {{total_size}}</li>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
</ul>`,
|
||||
body_html_de: `<h2>Backup abgeschlossen</h2>
|
||||
<p>Das geplante Backup wurde erfolgreich abgeschlossen.</p>
|
||||
<p><strong>Backup-Zusammenfassung:</strong></p>
|
||||
<ul>
|
||||
<li>Startzeit: {{start_time}}</li>
|
||||
<li>Dauer: {{duration}}</li>
|
||||
<li>Gesicherte Dateien: {{files_count}}</li>
|
||||
<li>Gesamtgröße: {{total_size}}</li>
|
||||
<li>Backup-Typ: {{backup_type}}</li>
|
||||
</ul>`,
|
||||
body_text_en: 'Backup Completed\n\nThe scheduled backup has been completed successfully.\n\nStart Time: {{start_time}}\nDuration: {{duration}}\nFiles Backed Up: {{files_count}}\nTotal Size: {{total_size}}\nBackup Type: {{backup_type}}',
|
||||
body_text_de: 'Backup abgeschlossen\n\nDas geplante Backup wurde erfolgreich abgeschlossen.\n\nStartzeit: {{start_time}}\nDauer: {{duration}}\nGesicherte Dateien: {{files_count}}\nGesamtgröße: {{total_size}}\nBackup-Typ: {{backup_type}}',
|
||||
variables: JSON.stringify(['start_time', 'duration', 'files_count', 'total_size', 'backup_type'])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert backup email templates if they don't exist
|
||||
for (const template of backupEmailTemplates) {
|
||||
const exists = await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('email_templates').insert(template);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Backup service tables and settings added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove backup tables
|
||||
await db.schema.dropTableIfExists('backup_file_states');
|
||||
await db.schema.dropTableIfExists('backup_runs');
|
||||
|
||||
// Remove backup settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.delete();
|
||||
|
||||
// Remove backup email templates
|
||||
await db('email_templates')
|
||||
.whereIn('template_key', ['backup_failed', 'backup_completed'])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,182 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding database backup tables and settings...');
|
||||
|
||||
// Create database_backup_runs table to track database backup history
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (!hasDatabaseBackupRunsTable) {
|
||||
await db.schema.createTable('database_backup_runs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed
|
||||
table.string('backup_type'); // sqlite, postgresql
|
||||
table.string('destination_path');
|
||||
table.string('file_path');
|
||||
table.bigInteger('file_size_bytes').defaultTo(0);
|
||||
table.bigInteger('original_size_bytes').defaultTo(0);
|
||||
table.integer('duration_seconds');
|
||||
table.string('checksum'); // SHA256 checksum of backup file
|
||||
table.float('compression_ratio'); // Compression percentage
|
||||
table.json('table_checksums'); // Individual table checksums
|
||||
table.text('error_message');
|
||||
table.json('statistics'); // Detailed stats about the backup
|
||||
table.index(['started_at'], 'idx_db_backup_started');
|
||||
table.index(['status'], 'idx_db_backup_status');
|
||||
});
|
||||
}
|
||||
|
||||
// Add database backup-related settings to app_settings
|
||||
const databaseBackupSettings = [
|
||||
{
|
||||
setting_key: 'database_backup_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_schedule',
|
||||
setting_value: JSON.stringify('0 3 * * *'), // Default: 3 AM daily
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_destination_path',
|
||||
setting_value: JSON.stringify('/backup/database'),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_compress',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_validate_integrity',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_include_checksums',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_retention_days',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_email_on_failure',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_email_on_success',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_max_retries',
|
||||
setting_value: JSON.stringify(3),
|
||||
setting_type: 'database_backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert database backup settings if they don't exist
|
||||
for (const setting of databaseBackupSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add database backup-related email templates
|
||||
const databaseBackupEmailTemplates = [
|
||||
{
|
||||
template_key: 'database_backup_failed',
|
||||
subject_en: 'Database Backup Failed - Critical Alert',
|
||||
subject_de: 'Datenbank-Backup fehlgeschlagen - Kritische Warnung',
|
||||
body_html_en: `<h2>Database Backup Failed</h2>
|
||||
<p>The scheduled database backup has failed and requires immediate attention.</p>
|
||||
<p><strong>Error Details:</strong></p>
|
||||
<ul>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Timestamp: {{timestamp}}</li>
|
||||
<li>Error: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>This is a critical issue that could affect disaster recovery. Please investigate immediately.</p>`,
|
||||
body_html_de: `<h2>Datenbank-Backup fehlgeschlagen</h2>
|
||||
<p>Das geplante Datenbank-Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
|
||||
<p><strong>Fehlerdetails:</strong></p>
|
||||
<ul>
|
||||
<li>Backup-Typ: {{backup_type}}</li>
|
||||
<li>Zeitstempel: {{timestamp}}</li>
|
||||
<li>Fehler: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Dies ist ein kritisches Problem, das die Disaster-Recovery beeinträchtigen könnte. Bitte untersuchen Sie es sofort.</p>`,
|
||||
body_text_en: 'Database Backup Failed\n\nThe scheduled database backup has failed.\n\nBackup Type: {{backup_type}}\nTimestamp: {{timestamp}}\nError: {{error_message}}\n\nThis is critical - please investigate immediately.',
|
||||
body_text_de: 'Datenbank-Backup fehlgeschlagen\n\nDas geplante Datenbank-Backup ist fehlgeschlagen.\n\nBackup-Typ: {{backup_type}}\nZeitstempel: {{timestamp}}\nFehler: {{error_message}}\n\nDies ist kritisch - bitte sofort untersuchen.',
|
||||
variables: JSON.stringify(['backup_type', 'timestamp', 'error_message'])
|
||||
},
|
||||
{
|
||||
template_key: 'database_backup_completed',
|
||||
subject_en: 'Database Backup Completed Successfully',
|
||||
subject_de: 'Datenbank-Backup erfolgreich abgeschlossen',
|
||||
body_html_en: `<h2>Database Backup Completed</h2>
|
||||
<p>The scheduled database backup has been completed successfully.</p>
|
||||
<p><strong>Backup Summary:</strong></p>
|
||||
<ul>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Duration: {{duration}}</li>
|
||||
<li>File Size: {{file_size}}</li>
|
||||
<li>Compression Ratio: {{compression_ratio}}</li>
|
||||
<li>File Path: {{file_path}}</li>
|
||||
</ul>`,
|
||||
body_html_de: `<h2>Datenbank-Backup abgeschlossen</h2>
|
||||
<p>Das geplante Datenbank-Backup wurde erfolgreich abgeschlossen.</p>
|
||||
<p><strong>Backup-Zusammenfassung:</strong></p>
|
||||
<ul>
|
||||
<li>Backup-Typ: {{backup_type}}</li>
|
||||
<li>Dauer: {{duration}}</li>
|
||||
<li>Dateigröße: {{file_size}}</li>
|
||||
<li>Komprimierungsverhältnis: {{compression_ratio}}</li>
|
||||
<li>Dateipfad: {{file_path}}</li>
|
||||
</ul>`,
|
||||
body_text_en: 'Database Backup Completed\n\nThe scheduled database backup has been completed successfully.\n\nBackup Type: {{backup_type}}\nDuration: {{duration}}\nFile Size: {{file_size}}\nCompression Ratio: {{compression_ratio}}\nFile Path: {{file_path}}',
|
||||
body_text_de: 'Datenbank-Backup abgeschlossen\n\nDas geplante Datenbank-Backup wurde erfolgreich abgeschlossen.\n\nBackup-Typ: {{backup_type}}\nDauer: {{duration}}\nDateigröße: {{file_size}}\nKomprimierungsverhältnis: {{compression_ratio}}\nDateipfad: {{file_path}}',
|
||||
variables: JSON.stringify(['backup_type', 'duration', 'file_size', 'compression_ratio', 'file_path'])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert database backup email templates if they don't exist
|
||||
for (const template of databaseBackupEmailTemplates) {
|
||||
const exists = await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('email_templates').insert(template);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Database backup tables and settings added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove database backup tables
|
||||
await db.schema.dropTableIfExists('database_backup_runs');
|
||||
|
||||
// Remove database backup settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'database_backup')
|
||||
.delete();
|
||||
|
||||
// Remove database backup email templates
|
||||
await db('email_templates')
|
||||
.whereIn('template_key', ['database_backup_failed', 'database_backup_completed'])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,86 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding backup manifest columns...');
|
||||
|
||||
// Add manifest columns to backup_runs table
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
if (!hasManifestPath) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.string('manifest_path'); // Path to the manifest file
|
||||
table.string('manifest_id'); // Unique manifest ID
|
||||
table.string('manifest_format').defaultTo('json'); // json or yaml
|
||||
});
|
||||
}
|
||||
|
||||
// Add backup manifest-related settings to app_settings
|
||||
const manifestSettings = [
|
||||
{
|
||||
setting_key: 'backup_manifest_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_format',
|
||||
setting_value: JSON.stringify('json'), // json or yaml
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_path',
|
||||
setting_value: JSON.stringify('/backup/manifests'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_validate',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_include_checksums',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert manifest settings if they don't exist
|
||||
for (const setting of manifestSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✓ Backup manifest columns and settings added');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove manifest columns from backup_runs table
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
if (hasManifestPath) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.dropColumn('manifest_path');
|
||||
table.dropColumn('manifest_id');
|
||||
table.dropColumn('manifest_format');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove manifest settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.whereIn('setting_key', [
|
||||
'backup_manifest_enabled',
|
||||
'backup_manifest_format',
|
||||
'backup_manifest_path',
|
||||
'backup_manifest_validate',
|
||||
'backup_manifest_include_checksums'
|
||||
])
|
||||
.delete();
|
||||
|
||||
console.log('✓ Backup manifest columns and settings removed');
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,131 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
// Add new settings to app_settings table
|
||||
.table('app_settings', table => {
|
||||
// S3 configuration enhancements
|
||||
table.boolean('backup_s3_force_path_style').defaultTo(false).comment('Force path-style S3 URLs (for MinIO/self-hosted)');
|
||||
table.boolean('backup_s3_ssl_enabled').defaultTo(true).comment('Enable SSL/TLS for S3 connections');
|
||||
table.string('backup_s3_prefix', 255).comment('S3 key prefix for organizing backups');
|
||||
|
||||
// Backup features
|
||||
table.boolean('backup_incremental').defaultTo(false).comment('Enable incremental backups');
|
||||
table.boolean('backup_include_database').defaultTo(true).comment('Include database dumps in backups');
|
||||
table.boolean('backup_manifest_enabled').defaultTo(true).comment('Generate backup manifests');
|
||||
table.enum('backup_manifest_format', ['json', 'yaml']).defaultTo('json').comment('Manifest file format');
|
||||
table.boolean('backup_encryption_enabled').defaultTo(false).comment('Enable backup encryption');
|
||||
table.string('backup_database_schedule', 100).comment('Separate cron schedule for database-only backups');
|
||||
})
|
||||
|
||||
// Enhance backup_runs table
|
||||
.table('backup_runs', table => {
|
||||
// Manifest tracking
|
||||
table.string('manifest_path', 500).comment('Path to backup manifest file');
|
||||
table.uuid('manifest_id').comment('Unique identifier for the manifest');
|
||||
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
|
||||
|
||||
// Incremental backup support
|
||||
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
|
||||
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
|
||||
|
||||
// Add indexes for better query performance
|
||||
table.index(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
|
||||
table.index(['parent_backup_id'], 'idx_backup_runs_parent');
|
||||
table.index(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
|
||||
})
|
||||
|
||||
// Create backup_manifest table for storing detailed manifest metadata
|
||||
.createTable('backup_manifest', table => {
|
||||
table.increments('id').primary();
|
||||
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
|
||||
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
|
||||
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
|
||||
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
|
||||
|
||||
// Backup metadata
|
||||
table.timestamp('backup_start').notNullable();
|
||||
table.timestamp('backup_end').notNullable();
|
||||
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
|
||||
table.integer('file_count').unsigned().comment('Number of files in backup');
|
||||
table.integer('photo_count').unsigned().comment('Number of photos backed up');
|
||||
table.integer('event_count').unsigned().comment('Number of events backed up');
|
||||
|
||||
// Incremental backup metadata
|
||||
table.boolean('is_incremental').defaultTo(false);
|
||||
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
|
||||
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
|
||||
|
||||
// Content checksums
|
||||
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
|
||||
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
|
||||
|
||||
// Storage information
|
||||
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
|
||||
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
|
||||
|
||||
// Encryption metadata
|
||||
table.boolean('is_encrypted').defaultTo(false);
|
||||
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
|
||||
table.string('encryption_key_id', 255).comment('ID of encryption key used');
|
||||
|
||||
// Additional metadata as JSON
|
||||
table.json('metadata').comment('Additional metadata as JSON');
|
||||
|
||||
// Timestamps
|
||||
table.timestamps(true, true);
|
||||
|
||||
// Indexes
|
||||
table.index(['backup_run_id'], 'idx_manifest_backup_run');
|
||||
table.index(['manifest_id'], 'idx_manifest_uuid');
|
||||
table.index(['parent_manifest_id'], 'idx_manifest_parent');
|
||||
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
|
||||
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
|
||||
})
|
||||
|
||||
// Add composite indexes for common query patterns
|
||||
.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
||||
ON backup_runs(created_at DESC)
|
||||
WHERE status = 'completed' AND backup_mode = 'full';
|
||||
`)
|
||||
.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
||||
ON backup_runs(parent_backup_id, created_at)
|
||||
WHERE backup_mode = 'incremental';
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
// Drop indexes first
|
||||
.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;')
|
||||
.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;')
|
||||
|
||||
// Drop backup_manifest table
|
||||
.dropTableIfExists('backup_manifest')
|
||||
|
||||
// Remove columns from backup_runs table
|
||||
.table('backup_runs', table => {
|
||||
table.dropIndex(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
|
||||
table.dropIndex(['parent_backup_id'], 'idx_backup_runs_parent');
|
||||
table.dropIndex(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
|
||||
|
||||
table.dropColumn('manifest_path');
|
||||
table.dropColumn('manifest_id');
|
||||
table.dropColumn('manifest_format');
|
||||
table.dropColumn('parent_backup_id');
|
||||
table.dropColumn('backup_mode');
|
||||
})
|
||||
|
||||
// Remove columns from app_settings table
|
||||
.table('app_settings', table => {
|
||||
table.dropColumn('backup_s3_force_path_style');
|
||||
table.dropColumn('backup_s3_ssl_enabled');
|
||||
table.dropColumn('backup_s3_prefix');
|
||||
table.dropColumn('backup_incremental');
|
||||
table.dropColumn('backup_include_database');
|
||||
table.dropColumn('backup_manifest_enabled');
|
||||
table.dropColumn('backup_manifest_format');
|
||||
table.dropColumn('backup_encryption_enabled');
|
||||
table.dropColumn('backup_database_schedule');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
const { formatBoolean, parseBoolean } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Add restore_runs table for tracking restore operations
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// Create restore_runs table
|
||||
await knex.schema.createTable('restore_runs', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
// Timing
|
||||
table.timestamp('started_at').notNullable().defaultTo(knex.fn.now());
|
||||
table.timestamp('completed_at');
|
||||
table.integer('duration_seconds');
|
||||
|
||||
// Status and type
|
||||
table.string('status', 50).notNullable().defaultTo('running');
|
||||
table.string('restore_type', 50).notNullable(); // full, database, files, selective
|
||||
|
||||
// Source information
|
||||
table.string('source', 500).notNullable(); // Backup source path or S3 URL
|
||||
table.string('manifest_path', 500); // Path to manifest file
|
||||
|
||||
// Results
|
||||
table.text('error_message');
|
||||
table.text('statistics'); // JSON object with detailed statistics
|
||||
table.text('restore_log'); // JSON array of log entries
|
||||
|
||||
// Safety backup
|
||||
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
|
||||
|
||||
// Flags
|
||||
table.boolean('is_dry_run').defaultTo(formatBoolean(false));
|
||||
table.boolean('was_rollback_attempted').defaultTo(formatBoolean(false));
|
||||
table.boolean('was_successful').defaultTo(formatBoolean(false));
|
||||
|
||||
// Operator information
|
||||
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
|
||||
table.integer('operator_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.string('operator_ip', 50);
|
||||
|
||||
// Metadata
|
||||
table.text('metadata'); // JSON object for additional data
|
||||
|
||||
table.index(['status', 'started_at']);
|
||||
table.index(['restore_type', 'started_at']);
|
||||
});
|
||||
|
||||
// Create restore_file_operations table for tracking individual file operations
|
||||
await knex.schema.createTable('restore_file_operations', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
table.integer('restore_run_id').notNullable()
|
||||
.references('id').inTable('restore_runs').onDelete('CASCADE');
|
||||
|
||||
table.string('file_path', 500).notNullable();
|
||||
table.string('operation', 50).notNullable(); // restore, skip, error
|
||||
table.string('status', 50).notNullable(); // pending, in_progress, completed, failed
|
||||
|
||||
table.bigInteger('file_size');
|
||||
table.string('checksum', 64);
|
||||
table.boolean('checksum_verified').defaultTo(formatBoolean(false));
|
||||
|
||||
table.text('error_message');
|
||||
table.timestamp('started_at');
|
||||
table.timestamp('completed_at');
|
||||
|
||||
table.index(['restore_run_id', 'status']);
|
||||
table.index(['file_path']);
|
||||
});
|
||||
|
||||
// Create restore_validation_results table
|
||||
await knex.schema.createTable('restore_validation_results', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
table.integer('restore_run_id').notNullable()
|
||||
.references('id').inTable('restore_runs').onDelete('CASCADE');
|
||||
|
||||
table.string('validation_type', 50).notNullable(); // pre-restore, post-restore
|
||||
table.boolean('is_valid').notNullable();
|
||||
|
||||
table.text('errors'); // JSON array of errors
|
||||
table.text('warnings'); // JSON array of warnings
|
||||
table.text('checksums'); // JSON object with checksum comparisons
|
||||
|
||||
table.timestamp('validated_at').notNullable().defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['restore_run_id', 'validation_type']);
|
||||
});
|
||||
|
||||
// Add restore-related settings to app_settings
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'restore_allow_force',
|
||||
setting_value: formatBoolean(false),
|
||||
setting_type: 'restore',
|
||||
description: 'Allow force restore with warnings',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_require_pre_backup',
|
||||
setting_value: formatBoolean(true),
|
||||
setting_type: 'restore',
|
||||
description: 'Require pre-restore backup',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_max_file_size_mb',
|
||||
setting_value: '5000',
|
||||
setting_type: 'restore',
|
||||
description: 'Maximum file size for restore (MB)',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_verify_checksums',
|
||||
setting_value: formatBoolean(true),
|
||||
setting_type: 'restore',
|
||||
description: 'Verify file checksums during restore',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_email_on_completion',
|
||||
setting_value: formatBoolean(true),
|
||||
setting_type: 'restore',
|
||||
description: 'Send email on restore completion',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_retention_days',
|
||||
setting_value: '30',
|
||||
setting_type: 'restore',
|
||||
description: 'Days to retain restore history',
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
}
|
||||
]);
|
||||
|
||||
// Add new email templates for restore notifications
|
||||
const emailTemplates = [
|
||||
{
|
||||
name: 'restore_completed',
|
||||
subject: '✅ Restore Completed Successfully',
|
||||
body: `<h2>Restore Operation Completed</h2>
|
||||
<p>A restore operation has completed successfully.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
||||
<li><strong>Duration:</strong> {{duration}}</li>
|
||||
<li><strong>Files Restored:</strong> {{files_restored}}</li>
|
||||
<li><strong>Backup ID:</strong> {{backup_id}}</li>
|
||||
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
|
||||
language: 'en',
|
||||
is_active: formatBoolean(true),
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
name: 'restore_failed',
|
||||
subject: '❌ Restore Operation Failed',
|
||||
body: `<h2>Restore Operation Failed</h2>
|
||||
<p>A restore operation has failed and requires attention.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
||||
<li><strong>Error:</strong> {{error_message}}</li>
|
||||
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Please check the system logs for more details and take appropriate action.</p>
|
||||
|
||||
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
|
||||
language: 'en',
|
||||
is_active: formatBoolean(true),
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
name: 'restore_completed',
|
||||
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
|
||||
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
|
||||
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
|
||||
<li><strong>Dauer:</strong> {{duration}}</li>
|
||||
<li><strong>Wiederhergestellte Dateien:</strong> {{files_restored}}</li>
|
||||
<li><strong>Backup-ID:</strong> {{backup_id}}</li>
|
||||
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
|
||||
language: 'de',
|
||||
is_active: formatBoolean(true),
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
},
|
||||
{
|
||||
name: 'restore_failed',
|
||||
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
|
||||
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
|
||||
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
|
||||
<li><strong>Fehler:</strong> {{error_message}}</li>
|
||||
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
|
||||
|
||||
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
|
||||
language: 'de',
|
||||
is_active: formatBoolean(true),
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now()
|
||||
}
|
||||
];
|
||||
|
||||
await knex('email_templates').insert(emailTemplates);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove email templates
|
||||
await knex('email_templates')
|
||||
.whereIn('name', ['restore_completed', 'restore_failed'])
|
||||
.delete();
|
||||
|
||||
// Remove settings
|
||||
await knex('app_settings')
|
||||
.where('setting_type', 'restore')
|
||||
.delete();
|
||||
|
||||
// Drop tables
|
||||
await knex.schema.dropTableIfExists('restore_validation_results');
|
||||
await knex.schema.dropTableIfExists('restore_file_operations');
|
||||
await knex.schema.dropTableIfExists('restore_runs');
|
||||
};
|
||||
Generated
+2448
-308
File diff suppressed because it is too large
Load Diff
+18
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.77",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,34 +8,43 @@
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrations/run-migrations.js",
|
||||
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||
"fix-temp-photos": "node scripts/fix-temp-photos.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint src/",
|
||||
"test-backup": "node scripts/test-backup-service.js",
|
||||
"test-restore": "node scripts/test-restore-service.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"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",
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Storage Management Scripts
|
||||
|
||||
## check-storage.js
|
||||
|
||||
Checks the storage directory structure and verifies that photo files exist.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Check overall storage structure
|
||||
node scripts/check-storage.js
|
||||
|
||||
# Check specific event
|
||||
node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
||||
```
|
||||
|
||||
### What it checks:
|
||||
- Storage directory structure and permissions
|
||||
- Event directories
|
||||
- Photo files existence
|
||||
- Thumbnail files existence
|
||||
- Database vs filesystem consistency
|
||||
|
||||
## regenerate-thumbnails.js
|
||||
|
||||
Regenerates missing thumbnails for photos in the database.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Regenerate all missing thumbnails
|
||||
node scripts/regenerate-thumbnails.js
|
||||
|
||||
# Regenerate thumbnails for specific event (by ID)
|
||||
node scripts/regenerate-thumbnails.js 2
|
||||
```
|
||||
|
||||
### What it does:
|
||||
- Scans photos in database
|
||||
- Checks if thumbnails exist
|
||||
- Generates missing thumbnails using Sharp
|
||||
- Updates database with thumbnail paths
|
||||
- Reports success/error statistics
|
||||
|
||||
### Prerequisites:
|
||||
- Node.js environment
|
||||
- Database access
|
||||
- Write permissions to storage directory
|
||||
- Sharp library installed
|
||||
|
||||
## Production Usage
|
||||
|
||||
On your production server:
|
||||
|
||||
1. First, check the storage structure:
|
||||
```bash
|
||||
cd /path/to/picpeak/backend
|
||||
NODE_ENV=production node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
||||
```
|
||||
|
||||
2. If thumbnails are missing, regenerate them:
|
||||
```bash
|
||||
NODE_ENV=production node scripts/regenerate-thumbnails.js 2
|
||||
```
|
||||
|
||||
Note: Replace `2` with the actual event ID from your database.
|
||||
|
||||
## cleanup-thumbnails.js
|
||||
|
||||
Cleans up temporary and orphaned thumbnail files.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Dry run - see what would be deleted
|
||||
node scripts/cleanup-thumbnails.js --dry-run
|
||||
|
||||
# Actually delete orphaned thumbnails
|
||||
node scripts/cleanup-thumbnails.js
|
||||
```
|
||||
|
||||
### What it does:
|
||||
- Identifies temporary thumbnails (thumb_temp_*)
|
||||
- Finds orphaned thumbnails not linked to any photo
|
||||
- Removes unnecessary files to free up space
|
||||
- Reports statistics on cleanup
|
||||
|
||||
## diagnose-thumbnails.js
|
||||
|
||||
Diagnoses why thumbnails might not be showing for a specific event.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
node scripts/diagnose-thumbnails.js 2
|
||||
```
|
||||
|
||||
### What it checks:
|
||||
- Thumbnail paths in database vs filesystem
|
||||
- Path format inconsistencies
|
||||
- Missing thumbnail files
|
||||
- Provides SQL to fix path issues
|
||||
|
||||
### Common Issues:
|
||||
1. **Path mismatch**: Database has wrong thumbnail path format
|
||||
2. **Missing files**: Thumbnails were never generated
|
||||
3. **Permission issues**: Web server can't read thumbnail files
|
||||
@@ -0,0 +1,131 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkEmailEnvironment() {
|
||||
console.log('=== Email Environment Check ===\n');
|
||||
|
||||
// 1. Check environment variables
|
||||
console.log('1. Environment Variables:');
|
||||
const envVars = [
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASS',
|
||||
'SMTP_FROM',
|
||||
'SMTP_SECURE',
|
||||
'EMAIL_PROCESSOR_ENABLED',
|
||||
'NODE_ENV'
|
||||
];
|
||||
|
||||
envVars.forEach(varName => {
|
||||
const value = process.env[varName];
|
||||
if (varName.includes('PASS')) {
|
||||
console.log(` ${varName}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
console.log(` ${varName}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check database configuration
|
||||
console.log('\n2. Database Email Configuration:');
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig) {
|
||||
console.log(' Email configuration found in database:');
|
||||
console.log(` - SMTP Host: ${emailConfig.smtp_host}`);
|
||||
console.log(` - SMTP Port: ${emailConfig.smtp_port}`);
|
||||
console.log(` - SMTP User: ${emailConfig.smtp_user || 'NOT SET'}`);
|
||||
console.log(` - SMTP Secure: ${emailConfig.smtp_secure}`);
|
||||
console.log(` - From Address: ${emailConfig.smtp_from}`);
|
||||
} else {
|
||||
console.log(' ⚠️ No email configuration found in database!');
|
||||
console.log(' This will prevent the email processor from initializing.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error reading email configuration: ${error.message}`);
|
||||
}
|
||||
|
||||
// 3. Check if the email processor should be disabled
|
||||
console.log('\n3. Email Processor Status:');
|
||||
const isDisabled = process.env.EMAIL_PROCESSOR_ENABLED === 'false';
|
||||
if (isDisabled) {
|
||||
console.log(' ⚠️ Email processor is DISABLED via EMAIL_PROCESSOR_ENABLED=false');
|
||||
} else {
|
||||
console.log(' ✅ Email processor is enabled (default)');
|
||||
}
|
||||
|
||||
// 4. Check pending emails
|
||||
console.log('\n4. Email Queue Status:');
|
||||
try {
|
||||
const pending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const failed = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const sent = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
console.log(` - Pending emails: ${pending.count}`);
|
||||
console.log(` - Failed emails (max retries): ${failed.count}`);
|
||||
console.log(` - Sent emails: ${sent.count}`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying email queue: ${error.message}`);
|
||||
}
|
||||
|
||||
// 5. Test database connection
|
||||
console.log('\n5. Database Connection:');
|
||||
try {
|
||||
await db.raw('SELECT 1');
|
||||
console.log(' ✅ Database connection successful');
|
||||
} catch (error) {
|
||||
console.log(` ❌ Database connection failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// 6. Check for any recent errors
|
||||
console.log('\n6. Recent Email Errors:');
|
||||
try {
|
||||
const recentErrors = await db('email_queue')
|
||||
.whereNotNull('error_message')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(3)
|
||||
.select('id', 'email_type', 'error_message', 'retry_count');
|
||||
|
||||
if (recentErrors.length > 0) {
|
||||
recentErrors.forEach((email, index) => {
|
||||
console.log(` ${index + 1}. Email ID ${email.id} (${email.email_type}):`);
|
||||
console.log(` Retries: ${email.retry_count}`);
|
||||
console.log(` Error: ${email.error_message}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' No recent errors found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying recent errors: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Environment check complete ===');
|
||||
console.log('\nRecommendations:');
|
||||
|
||||
const emailConfig = await db('email_configs').first().catch(() => null);
|
||||
if (!emailConfig) {
|
||||
console.log('❗ Configure email settings in the admin panel or add email_configs record');
|
||||
}
|
||||
|
||||
if (!process.env.SMTP_HOST && !emailConfig) {
|
||||
console.log('❗ Set SMTP environment variables or configure in database');
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
checkEmailEnvironment().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function checkEmailProcessor() {
|
||||
try {
|
||||
logger.info('=== Email Processor Diagnostic Check ===\n');
|
||||
|
||||
// 1. Check pending emails
|
||||
logger.info('1. Checking pending emails in queue...');
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
logger.info(`Found ${pendingEmails.length} pending emails\n`);
|
||||
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('Pending email details:');
|
||||
pendingEmails.forEach((email, index) => {
|
||||
logger.info(`\nEmail ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Scheduled At: ${email.scheduled_at}`);
|
||||
logger.info(` Created At: ${email.created_at}`);
|
||||
logger.info(` Error: ${email.error_message || 'None'}`);
|
||||
|
||||
// Check if email_data needs parsing
|
||||
logger.info(` Email Data Type: ${typeof email.email_data}`);
|
||||
if (email.email_data) {
|
||||
try {
|
||||
const data = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data)
|
||||
: email.email_data;
|
||||
logger.info(` Email Data Keys: ${Object.keys(data).join(', ')}`);
|
||||
} catch (e) {
|
||||
logger.error(` Failed to parse email_data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check failed emails
|
||||
logger.info('\n\n2. Checking failed emails...');
|
||||
const failedEmails = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(5);
|
||||
|
||||
logger.info(`Found ${failedEmails.length} failed emails (showing last 5)\n`);
|
||||
|
||||
if (failedEmails.length > 0) {
|
||||
failedEmails.forEach((email, index) => {
|
||||
logger.info(`\nFailed Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Error: ${email.error_message || 'No error message'}`);
|
||||
logger.info(` Last Attempt: ${email.sent_at || 'Never'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check if email processor should be running
|
||||
logger.info('\n\n3. Checking email processor configuration...');
|
||||
|
||||
// Check environment variables
|
||||
const emailConfig = {
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
EMAIL_PROCESSOR_ENABLED: process.env.EMAIL_PROCESSOR_ENABLED || 'true'
|
||||
};
|
||||
|
||||
logger.info('Email configuration:');
|
||||
Object.entries(emailConfig).forEach(([key, value]) => {
|
||||
if (key === 'SMTP_USER') {
|
||||
logger.info(` ${key}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
logger.info(` ${key}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Test email processor functionality
|
||||
logger.info('\n\n4. Testing email processor functionality...');
|
||||
|
||||
// Import the email processor
|
||||
const { processEmailQueue, testEmailConnection } = require('../src/services/emailProcessor');
|
||||
|
||||
// Test email connection
|
||||
logger.info('Testing email connection...');
|
||||
try {
|
||||
const connectionTest = await testEmailConnection();
|
||||
logger.info(`Email connection test: ${connectionTest ? 'SUCCESS' : 'FAILED'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Email connection test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Try to process queue once manually
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('\n\n5. Attempting to process email queue manually...');
|
||||
try {
|
||||
await processEmailQueue();
|
||||
logger.info('Manual queue processing completed');
|
||||
|
||||
// Check status after processing
|
||||
const stillPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`Emails still pending after processing: ${stillPending.count}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error processing queue: ${error.message}`);
|
||||
logger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check for any recent successful emails
|
||||
logger.info('\n\n6. Checking recent successful emails...');
|
||||
const recentSuccess = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.orderBy('sent_at', 'desc')
|
||||
.limit(3);
|
||||
|
||||
if (recentSuccess.length > 0) {
|
||||
logger.info(`Last ${recentSuccess.length} successful emails:`);
|
||||
recentSuccess.forEach((email, index) => {
|
||||
logger.info(` ${index + 1}. Type: ${email.email_type}, Sent: ${email.sent_at}`);
|
||||
});
|
||||
} else {
|
||||
logger.info('No successfully sent emails found');
|
||||
}
|
||||
|
||||
logger.info('\n\n=== Diagnostic check complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running diagnostic check:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkEmailProcessor();
|
||||
@@ -0,0 +1,66 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkEmailTemplates() {
|
||||
try {
|
||||
console.log('=== Email Templates Check ===\n');
|
||||
|
||||
// 1. Check table columns
|
||||
console.log('1. Checking email_templates table structure...');
|
||||
|
||||
// Check which columns exist
|
||||
const columnChecks = [
|
||||
'subject', 'subject_en', 'subject_de',
|
||||
'body_html', 'body_html_en', 'body_html_de',
|
||||
'body_text', 'body_text_en', 'body_text_de'
|
||||
];
|
||||
|
||||
const existingColumns = [];
|
||||
for (const col of columnChecks) {
|
||||
const exists = await db.schema.hasColumn('email_templates', col);
|
||||
if (exists) existingColumns.push(col);
|
||||
}
|
||||
|
||||
console.log(' Existing columns:', existingColumns.join(', '));
|
||||
|
||||
// 2. Get all templates
|
||||
console.log('\n2. Current email templates:');
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n Template: ${template.template_key}`);
|
||||
console.log(' -------------------');
|
||||
|
||||
// Check which fields have content
|
||||
const fields = ['subject', 'subject_en', 'subject_de',
|
||||
'body_html', 'body_html_en', 'body_html_de',
|
||||
'body_text', 'body_text_en', 'body_text_de'];
|
||||
|
||||
for (const field of fields) {
|
||||
if (template[field]) {
|
||||
const preview = template[field].substring(0, 50) + '...';
|
||||
console.log(` ${field}: ${preview}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for German translations
|
||||
const hasGermanSubject = template.subject_de || template.body_html_de;
|
||||
console.log(` Has German translation: ${hasGermanSubject ? 'YES' : 'NO'}`);
|
||||
}
|
||||
|
||||
// 3. Summary
|
||||
console.log('\n3. Summary:');
|
||||
const totalTemplates = templates.length;
|
||||
const templatesWithGerman = templates.filter(t => t.subject_de || t.body_html_de).length;
|
||||
console.log(` Total templates: ${totalTemplates}`);
|
||||
console.log(` Templates with German: ${templatesWithGerman}`);
|
||||
console.log(` Missing German: ${totalTemplates - templatesWithGerman}`);
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkEmailTemplates();
|
||||
@@ -0,0 +1,53 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkGermanTemplates() {
|
||||
try {
|
||||
console.log('=== German Email Template Content Check ===\n');
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\nTemplate: ${template.template_key}`);
|
||||
console.log('=====================================');
|
||||
|
||||
// Check German subject
|
||||
console.log('\nGERMAN SUBJECT:');
|
||||
console.log(template.subject_de || 'MISSING');
|
||||
|
||||
// Check if German HTML body has English content
|
||||
console.log('\nGERMAN HTML BODY:');
|
||||
const germanHtml = template.body_html_de || '';
|
||||
|
||||
// Check for English phrases in German template
|
||||
const englishPhrases = [
|
||||
'Dear', 'Gallery', 'has been', 'Your photo', 'successfully',
|
||||
'Details:', 'Link:', 'Password:', 'Expires:', 'Event Date:',
|
||||
'Thank you', 'Best regards', 'View Gallery', 'days'
|
||||
];
|
||||
|
||||
const foundEnglish = englishPhrases.filter(phrase =>
|
||||
germanHtml.toLowerCase().includes(phrase.toLowerCase())
|
||||
);
|
||||
|
||||
if (foundEnglish.length > 0) {
|
||||
console.log('⚠️ Found English phrases in German template:', foundEnglish.join(', '));
|
||||
}
|
||||
|
||||
// Show first 500 chars of German HTML
|
||||
console.log(germanHtml.substring(0, 500) + '...\n');
|
||||
|
||||
// Check German text body
|
||||
console.log('GERMAN TEXT BODY:');
|
||||
const germanText = template.body_text_de || '';
|
||||
console.log(germanText.substring(0, 300) + '...\n');
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkGermanTemplates();
|
||||
@@ -0,0 +1,146 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function debugEmailQueue() {
|
||||
try {
|
||||
logger.info('=== Email Queue Debug Report ===\n');
|
||||
|
||||
// 1. Count exactly like the admin dashboard does
|
||||
logger.info('1. Admin Dashboard Query (ALL pending, no retry filter):');
|
||||
const [adminCount] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
logger.info(` Pending emails (admin dashboard view): ${adminCount.count}\n`);
|
||||
|
||||
// 2. Count like the email processor does
|
||||
logger.info('2. Email Processor Query (pending with retry_count < 3):');
|
||||
const [processorCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
logger.info(` Pending emails (processor view): ${processorCount.count}\n`);
|
||||
|
||||
// 3. Show the discrepancy
|
||||
logger.info('3. Discrepancy Analysis:');
|
||||
if (adminCount.count !== processorCount.count) {
|
||||
logger.info(` ⚠️ DISCREPANCY FOUND!`);
|
||||
logger.info(` Admin shows: ${adminCount.count}`);
|
||||
logger.info(` Processor will process: ${processorCount.count}`);
|
||||
logger.info(` Difference: ${adminCount.count - processorCount.count} email(s)\n`);
|
||||
|
||||
// Find the problematic emails
|
||||
logger.info('4. Emails with retry_count >= 3 (still pending):');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length > 0) {
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s):\n`);
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(` Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count} ⚠️`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}\n`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info(` ✅ No discrepancy - counts match\n`);
|
||||
}
|
||||
|
||||
// 5. Show ALL pending emails with details
|
||||
logger.info('5. ALL Pending Emails (regardless of retry count):');
|
||||
const allPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.orderBy('retry_count', 'desc')
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
if (allPending.length > 0) {
|
||||
allPending.forEach((email, index) => {
|
||||
const willProcess = email.retry_count < 3;
|
||||
logger.info(`\n Email ${index + 1}: ${willProcess ? '✅ WILL PROCESS' : '❌ STUCK (max retries)'}`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}/3`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Scheduled: ${email.scheduled_at}`);
|
||||
if (email.error_message) {
|
||||
logger.info(` Last Error: ${email.error_message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(' No pending emails found');
|
||||
}
|
||||
|
||||
// 6. Show counts by status
|
||||
logger.info('\n\n6. Email Queue Summary by Status:');
|
||||
const statusCounts = await db('email_queue')
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status')
|
||||
.orderBy('status');
|
||||
|
||||
statusCounts.forEach(row => {
|
||||
logger.info(` ${row.status}: ${row.count}`);
|
||||
});
|
||||
|
||||
// 7. Failed emails summary
|
||||
logger.info('\n7. Failed Emails Summary:');
|
||||
const failedSummary = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.select('retry_count')
|
||||
.count('* as count')
|
||||
.groupBy('retry_count')
|
||||
.orderBy('retry_count');
|
||||
|
||||
if (failedSummary.length > 0) {
|
||||
failedSummary.forEach(row => {
|
||||
logger.info(` Retry count ${row.retry_count}: ${row.count} email(s)`);
|
||||
});
|
||||
} else {
|
||||
logger.info(' No failed emails');
|
||||
}
|
||||
|
||||
// 8. Recommendations
|
||||
logger.info('\n\n=== RECOMMENDATIONS ===');
|
||||
|
||||
if (adminCount.count > processorCount.count) {
|
||||
logger.info('\n❗ You have emails stuck with retry_count >= 3');
|
||||
logger.info(' These emails will NOT be processed automatically.');
|
||||
logger.info('\n To fix this, you can:');
|
||||
logger.info(' 1. Reset retry count: UPDATE email_queue SET retry_count = 0 WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 2. Mark as failed: UPDATE email_queue SET status = \'failed\' WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 3. Delete them: DELETE FROM email_queue WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
}
|
||||
|
||||
const anyPending = adminCount.count > 0;
|
||||
if (anyPending && processorCount.count === 0) {
|
||||
logger.info('\n❗ All pending emails have exceeded retry limit');
|
||||
logger.info(' The email processor will not attempt to send them.');
|
||||
} else if (anyPending && processorCount.count > 0) {
|
||||
logger.info('\n✅ Email processor should process the pending emails on next run');
|
||||
logger.info(' Make sure the email processor service is running.');
|
||||
}
|
||||
|
||||
logger.info('\n=== Debug report complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running debug report:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the debug
|
||||
debugEmailQueue();
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to diagnose and fix email_queue schema issues
|
||||
* This helps resolve the "column updated_at does not exist" error
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkAndFixEmailQueueSchema() {
|
||||
console.log('Checking email_queue table schema...');
|
||||
|
||||
try {
|
||||
// Get column information
|
||||
const columns = await db('email_queue').columnInfo();
|
||||
console.log('\nCurrent email_queue columns:', Object.keys(columns));
|
||||
|
||||
// Check for updated_at column
|
||||
if (columns.updated_at) {
|
||||
console.log('\n⚠️ Found unexpected updated_at column in email_queue table!');
|
||||
console.log('This column should not exist and is causing errors.');
|
||||
|
||||
// Ask for confirmation before removing
|
||||
console.log('\nRemoving updated_at column...');
|
||||
await db.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
console.log('✅ Removed updated_at column from email_queue table');
|
||||
} else {
|
||||
console.log('✅ No updated_at column found (this is correct)');
|
||||
}
|
||||
|
||||
// Verify required columns exist
|
||||
const requiredColumns = [
|
||||
'id', 'event_id', 'recipient_email', 'email_type',
|
||||
'email_data', 'status', 'scheduled_at', 'sent_at',
|
||||
'error_message', 'retry_count', 'created_at'
|
||||
];
|
||||
|
||||
const missingColumns = requiredColumns.filter(col => !columns[col]);
|
||||
if (missingColumns.length > 0) {
|
||||
console.log('\n⚠️ Missing required columns:', missingColumns);
|
||||
} else {
|
||||
console.log('✅ All required columns are present');
|
||||
}
|
||||
|
||||
// Check for any database triggers
|
||||
if (process.env.DATABASE_CLIENT === 'pg') {
|
||||
console.log('\nChecking for PostgreSQL triggers on email_queue...');
|
||||
const triggers = await db.raw(`
|
||||
SELECT trigger_name, event_manipulation, action_statement
|
||||
FROM information_schema.triggers
|
||||
WHERE event_object_table = 'email_queue'
|
||||
AND trigger_schema = current_schema()
|
||||
`);
|
||||
|
||||
if (triggers.rows && triggers.rows.length > 0) {
|
||||
console.log('⚠️ Found triggers on email_queue table:');
|
||||
triggers.rows.forEach(trigger => {
|
||||
console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`);
|
||||
});
|
||||
} else {
|
||||
console.log('✅ No triggers found on email_queue table');
|
||||
}
|
||||
}
|
||||
|
||||
// Test update query
|
||||
console.log('\nTesting update query...');
|
||||
const testEmail = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.first();
|
||||
|
||||
if (testEmail) {
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', testEmail.id)
|
||||
.update({
|
||||
retry_count: testEmail.retry_count
|
||||
});
|
||||
console.log('✅ Update query works correctly');
|
||||
} catch (error) {
|
||||
console.log('❌ Update query failed:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No pending emails to test with');
|
||||
}
|
||||
|
||||
console.log('\nSchema check complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking schema:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkAndFixEmailQueueSchema();
|
||||
@@ -0,0 +1,88 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function fixFinalGermanTemplates() {
|
||||
try {
|
||||
console.log('Fixing remaining English words in German templates...\n');
|
||||
|
||||
// Get all templates
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
let updated = false;
|
||||
let updates = {};
|
||||
|
||||
// Fix subject_de
|
||||
if (template.subject_de) {
|
||||
updates.subject_de = template.subject_de;
|
||||
}
|
||||
|
||||
// Fix body_html_de
|
||||
if (template.body_html_de) {
|
||||
let html = template.body_html_de;
|
||||
|
||||
// Replace English words with German
|
||||
html = html.replace(/Gallery-Details:/g, 'Galerie-Details:');
|
||||
html = html.replace(/Galerie-Details:/g, 'Galerie-Details:');
|
||||
html = html.replace(/Details:/g, 'Details:');
|
||||
html = html.replace(/Link:/g, 'Link:');
|
||||
html = html.replace(/Gallery-Link:/g, 'Galerie-Link:');
|
||||
html = html.replace(/Galerie-Link:/g, 'Galerie-Link:');
|
||||
html = html.replace(/Archive-Details:/g, 'Archiv-Details:');
|
||||
html = html.replace(/Archiv-Details:/g, 'Archiv-Details:');
|
||||
|
||||
if (html !== template.body_html_de) {
|
||||
updates.body_html_de = html;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix body_text_de
|
||||
if (template.body_text_de) {
|
||||
let text = template.body_text_de;
|
||||
|
||||
text = text.replace(/Gallery-Details:/g, 'Galerie-Details:');
|
||||
text = text.replace(/Galerie-Details:/g, 'Galerie-Details:');
|
||||
text = text.replace(/Details:/g, 'Details:');
|
||||
text = text.replace(/Link:/g, 'Link:');
|
||||
text = text.replace(/Gallery-Link:/g, 'Galerie-Link:');
|
||||
text = text.replace(/Galerie-Link:/g, 'Galerie-Link:');
|
||||
text = text.replace(/Archive-Details:/g, 'Archiv-Details:');
|
||||
text = text.replace(/Archiv-Details:/g, 'Archiv-Details:');
|
||||
|
||||
if (text !== template.body_text_de) {
|
||||
updates.body_text_de = text;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the non-language-specific fields to match German
|
||||
if (template.body_html_de) {
|
||||
updates.body_html = template.body_html_de;
|
||||
}
|
||||
if (template.body_text_de) {
|
||||
updates.body_text = template.body_text_de;
|
||||
}
|
||||
if (template.subject_de) {
|
||||
updates.subject = template.subject_de;
|
||||
}
|
||||
|
||||
if (updated || Object.keys(updates).length > 0) {
|
||||
await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.update(updates);
|
||||
console.log(`✅ Updated ${template.template_key}`);
|
||||
} else {
|
||||
console.log(`⏭️ No changes needed for ${template.template_key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone!');
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixFinalGermanTemplates();
|
||||
@@ -0,0 +1,126 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function fixStuckEmails() {
|
||||
try {
|
||||
logger.info('=== Fix Stuck Emails Script ===\n');
|
||||
|
||||
// 1. Find stuck emails
|
||||
logger.info('1. Finding stuck emails (pending with retry_count >= 3)...');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length === 0) {
|
||||
logger.info(' ✅ No stuck emails found!');
|
||||
logger.info('\n=== Script complete ===');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s)\n`);
|
||||
|
||||
// 2. Show details
|
||||
logger.info('2. Stuck email details:');
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(`\n Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}`);
|
||||
});
|
||||
|
||||
// 3. Ask for action
|
||||
logger.info('\n\n3. Choose an action:');
|
||||
logger.info(' 1. Reset retry count to 0 (emails will be retried)');
|
||||
logger.info(' 2. Mark as failed (emails will not be retried)');
|
||||
logger.info(' 3. Delete these emails');
|
||||
logger.info(' 4. Cancel (do nothing)');
|
||||
|
||||
// Get command line argument
|
||||
const action = process.argv[2];
|
||||
|
||||
if (!action || !['reset', 'fail', 'delete'].includes(action)) {
|
||||
logger.info('\n❗ No valid action specified');
|
||||
logger.info('\nUsage:');
|
||||
logger.info(' node fix-stuck-emails.js reset - Reset retry count to 0');
|
||||
logger.info(' node fix-stuck-emails.js fail - Mark as failed');
|
||||
logger.info(' node fix-stuck-emails.js delete - Delete stuck emails');
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Execute action
|
||||
logger.info(`\n4. Executing action: ${action.toUpperCase()}`);
|
||||
|
||||
const emailIds = stuckEmails.map(e => e.id);
|
||||
|
||||
switch (action) {
|
||||
case 'reset':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
retry_count: 0,
|
||||
error_message: null
|
||||
});
|
||||
logger.info(` ✅ Reset retry count for ${emailIds.length} email(s)`);
|
||||
logger.info(' These emails will be processed on the next run');
|
||||
break;
|
||||
|
||||
case 'fail':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
status: 'failed'
|
||||
});
|
||||
logger.info(` ✅ Marked ${emailIds.length} email(s) as failed`);
|
||||
logger.info(' These emails will not be retried');
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.delete();
|
||||
logger.info(` ✅ Deleted ${emailIds.length} email(s)`);
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. Show updated counts
|
||||
logger.info('\n5. Updated email queue status:');
|
||||
const [pendingCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count');
|
||||
const [processableCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
|
||||
logger.info(` Total pending: ${pendingCount.count}`);
|
||||
logger.info(` Processable (retry < 3): ${processableCount.count}`);
|
||||
|
||||
if (pendingCount.count !== processableCount.count) {
|
||||
logger.info(` ⚠️ Still have ${pendingCount.count - processableCount.count} stuck email(s)`);
|
||||
} else {
|
||||
logger.info(' ✅ No stuck emails remaining');
|
||||
}
|
||||
|
||||
logger.info('\n=== Script complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the fix
|
||||
fixStuckEmails();
|
||||
@@ -0,0 +1,171 @@
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
const { generatePhotoFilename } = require('../src/utils/filenameSanitizer');
|
||||
|
||||
async function fixTempPhotos() {
|
||||
console.log('Starting to fix temporary photo files...\n');
|
||||
|
||||
try {
|
||||
// Find all photos with temp_ filenames
|
||||
const tempPhotos = await db('photos')
|
||||
.where('filename', 'like', 'temp_%')
|
||||
.orderBy('event_id', 'asc')
|
||||
.orderBy('category_id', 'asc')
|
||||
.orderBy('id', 'asc');
|
||||
|
||||
console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`);
|
||||
|
||||
if (tempPhotos.length === 0) {
|
||||
console.log('No temporary photos found. Exiting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group photos by event and category
|
||||
const grouped = {};
|
||||
for (const photo of tempPhotos) {
|
||||
const key = `${photo.event_id}_${photo.category_id || 'null'}`;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(photo);
|
||||
}
|
||||
|
||||
console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`);
|
||||
|
||||
// Process each group
|
||||
for (const [key, photos] of Object.entries(grouped)) {
|
||||
const [eventId, categoryIdStr] = key.split('_');
|
||||
const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr);
|
||||
|
||||
console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`);
|
||||
console.log(`Photos in group: ${photos.length}`);
|
||||
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error(`Event ${eventId} not found! Skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get category details if applicable
|
||||
let category = null;
|
||||
let startCounter = 1;
|
||||
|
||||
if (categoryId) {
|
||||
category = await db('photo_categories').where({ id: categoryId }).first();
|
||||
if (!category) {
|
||||
console.error(`Category ${categoryId} not found! Treating as uncategorized...`);
|
||||
} else {
|
||||
// Get the highest counter for this category
|
||||
const maxPhoto = await db('photos')
|
||||
.where({ event_id: eventId, category_id: categoryId })
|
||||
.whereNot('filename', 'like', 'temp_%')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (maxPhoto && maxPhoto.filename) {
|
||||
// Extract counter from filename
|
||||
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
|
||||
if (match) {
|
||||
startCounter = parseInt(match[1]) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For uncategorized, get the highest counter
|
||||
const maxPhoto = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('category_id')
|
||||
.whereNot('filename', 'like', 'temp_%')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (maxPhoto && maxPhoto.filename) {
|
||||
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
|
||||
if (match) {
|
||||
startCounter = parseInt(match[1]) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Starting counter: ${startCounter}`);
|
||||
|
||||
// Process each photo in the group
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const counter = startCounter + i;
|
||||
|
||||
try {
|
||||
// Generate new filename
|
||||
const extension = path.extname(photo.filename);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
|
||||
// Build full paths
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const oldPath = path.join(storagePath, 'events/active', photo.path);
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
|
||||
// Check if old file exists
|
||||
try {
|
||||
await fs.access(oldPath);
|
||||
} catch (e) {
|
||||
console.error(`File not found: ${oldPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
await fs.rename(oldPath, newPath);
|
||||
|
||||
// Update database
|
||||
const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
filename: newFilename,
|
||||
path: newRelativePath
|
||||
});
|
||||
|
||||
console.log(`✓ Renamed: ${photo.filename} → ${newFilename}`);
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to process photo ${photo.id}: ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update category counter if needed
|
||||
if (category && successCount > 0) {
|
||||
const newCounter = startCounter + photos.length - 1;
|
||||
await db('photo_categories')
|
||||
.where({ id: categoryId })
|
||||
.update({ photo_counter: newCounter });
|
||||
console.log(`Updated category counter to ${newCounter}`);
|
||||
}
|
||||
|
||||
console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
console.log('\n=== COMPLETE ===');
|
||||
console.log('All temporary photos have been processed.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
fixTempPhotos().catch(console.error);
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { db } = require('../src/database/db');
|
||||
const {
|
||||
initializeTransporter,
|
||||
processEmailQueue,
|
||||
testEmailConnection
|
||||
} = require('../src/services/emailProcessor');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function runEmailProcessor(runOnce = false) {
|
||||
try {
|
||||
logger.info('=== Starting Email Processor ===\n');
|
||||
|
||||
// Initialize transporter
|
||||
logger.info('Initializing email transporter...');
|
||||
await initializeTransporter();
|
||||
|
||||
// Test connection
|
||||
logger.info('Testing email connection...');
|
||||
const connectionOk = await testEmailConnection();
|
||||
|
||||
if (!connectionOk) {
|
||||
logger.error('Email connection test failed! Check your SMTP configuration.');
|
||||
logger.info('\nRequired environment variables:');
|
||||
logger.info('- SMTP_HOST');
|
||||
logger.info('- SMTP_PORT');
|
||||
logger.info('- SMTP_USER');
|
||||
logger.info('- SMTP_PASS');
|
||||
logger.info('- SMTP_FROM');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('Email connection test successful!\n');
|
||||
|
||||
if (runOnce) {
|
||||
// Process queue once
|
||||
logger.info('Processing email queue once...');
|
||||
await processEmailQueue();
|
||||
logger.info('Email processing complete');
|
||||
|
||||
// Show final status
|
||||
const pendingCount = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`\nEmails still pending: ${pendingCount.count}`);
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Run continuously
|
||||
logger.info('Starting continuous email processor...');
|
||||
logger.info('Processing emails every 60 seconds. Press Ctrl+C to stop.\n');
|
||||
|
||||
// Process immediately
|
||||
await processEmailQueue();
|
||||
|
||||
// Then every minute
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await processEmailQueue();
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Fatal error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n\nShutting down email processor...');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Check command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const runOnce = args.includes('--once') || args.includes('-o');
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
Email Processor Runner
|
||||
|
||||
Usage: node run-email-processor.js [options]
|
||||
|
||||
Options:
|
||||
--once, -o Process the email queue once and exit
|
||||
--help, -h Show this help message
|
||||
|
||||
By default, the processor runs continuously, checking for emails every 60 seconds.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the processor
|
||||
runEmailProcessor(runOnce);
|
||||
Executable
+577
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Manual Integration Test Script for Enhanced Backup System
|
||||
*
|
||||
* This script provides a comprehensive test of the backup system with real services.
|
||||
* It can be used to test against MinIO, AWS S3, or other S3-compatible services.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/test-backup-integration.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --endpoint <url> S3 endpoint URL (default: http://localhost:9000)
|
||||
* --access-key <key> S3 access key (default: minioadmin)
|
||||
* --secret-key <key> S3 secret key (default: minioadmin)
|
||||
* --bucket <name> S3 bucket name (default: test-backup-<timestamp>)
|
||||
* --type <type> Backup type: s3, local, rsync (default: s3)
|
||||
* --cleanup Clean up test data after completion
|
||||
* --verbose Enable verbose logging
|
||||
* --help Show this help message
|
||||
*
|
||||
* Examples:
|
||||
* # Test with local MinIO
|
||||
* node scripts/test-backup-integration.js
|
||||
*
|
||||
* # Test with AWS S3
|
||||
* node scripts/test-backup-integration.js \
|
||||
* --endpoint https://s3.amazonaws.com \
|
||||
* --access-key AKIAIOSFODNN7EXAMPLE \
|
||||
* --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
|
||||
* --bucket my-test-bucket
|
||||
*
|
||||
* # Test local backup
|
||||
* node scripts/test-backup-integration.js --type local
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const { S3Client, CreateBucketCommand, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
endpoint: 'http://localhost:9000',
|
||||
accessKey: 'minioadmin',
|
||||
secretKey: 'minioadmin',
|
||||
bucket: `test-backup-${Date.now()}`,
|
||||
type: 's3',
|
||||
cleanup: false,
|
||||
verbose: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--endpoint':
|
||||
options.endpoint = args[++i];
|
||||
break;
|
||||
case '--access-key':
|
||||
options.accessKey = args[++i];
|
||||
break;
|
||||
case '--secret-key':
|
||||
options.secretKey = args[++i];
|
||||
break;
|
||||
case '--bucket':
|
||||
options.bucket = args[++i];
|
||||
break;
|
||||
case '--type':
|
||||
options.type = args[++i];
|
||||
break;
|
||||
case '--cleanup':
|
||||
options.cleanup = true;
|
||||
break;
|
||||
case '--verbose':
|
||||
options.verbose = true;
|
||||
break;
|
||||
case '--help':
|
||||
console.log(module.exports.description || 'Manual Integration Test Script');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Load environment and services
|
||||
require('dotenv').config();
|
||||
const { db, initialize: initDb } = require('../src/database/db');
|
||||
const backupService = require('../src/services/backupService');
|
||||
const S3StorageAdapter = require('../src/services/storage/s3Storage');
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
// Configure logger based on verbose flag
|
||||
if (!options.verbose) {
|
||||
logger.info = () => {};
|
||||
logger.debug = () => {};
|
||||
}
|
||||
|
||||
// Test results
|
||||
const results = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
tests: []
|
||||
};
|
||||
|
||||
// Test utilities
|
||||
async function runTest(name, testFn) {
|
||||
console.log(`\n📋 Running: ${name}`);
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
await testFn();
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`✅ PASSED: ${name} (${duration}ms)`);
|
||||
results.passed++;
|
||||
results.tests.push({ name, status: 'passed', duration });
|
||||
} catch (error) {
|
||||
console.error(`❌ FAILED: ${name}`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
if (options.verbose) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
results.failed++;
|
||||
results.tests.push({ name, status: 'failed', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function skipTest(name, reason) {
|
||||
console.log(`\n⏭️ Skipping: ${name}`);
|
||||
console.log(` Reason: ${reason}`);
|
||||
results.skipped++;
|
||||
results.tests.push({ name, status: 'skipped', reason });
|
||||
}
|
||||
|
||||
// Test functions
|
||||
async function testS3Connection() {
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
bucket: options.bucket,
|
||||
endpoint: options.endpoint,
|
||||
accessKeyId: options.accessKey,
|
||||
secretAccessKey: options.secretKey,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
sslEnabled: options.endpoint.startsWith('https')
|
||||
});
|
||||
|
||||
await s3Adapter.testConnection();
|
||||
console.log(` ✓ Connected to S3 endpoint: ${options.endpoint}`);
|
||||
console.log(` ✓ Bucket accessible: ${options.bucket}`);
|
||||
}
|
||||
|
||||
async function setupTestData() {
|
||||
const storagePath = path.join(__dirname, '../test-storage');
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
// Create directory structure
|
||||
const dirs = [
|
||||
'events/active/wedding-2024',
|
||||
'events/active/birthday-2024',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'uploads',
|
||||
'backups'
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await fs.mkdir(path.join(storagePath, dir), { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files with various sizes
|
||||
const files = [
|
||||
{ path: 'events/active/wedding-2024/photo1.jpg', size: 1024 * 1024 }, // 1MB
|
||||
{ path: 'events/active/wedding-2024/photo2.jpg', size: 512 * 1024 }, // 512KB
|
||||
{ path: 'events/active/birthday-2024/photo1.jpg', size: 2 * 1024 * 1024 }, // 2MB
|
||||
{ path: 'events/archived/old-event.zip', size: 5 * 1024 * 1024 }, // 5MB
|
||||
{ path: 'thumbnails/thumb1.jpg', size: 50 * 1024 }, // 50KB
|
||||
{ path: 'uploads/logo.png', size: 100 * 1024 } // 100KB
|
||||
];
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const content = crypto.randomBytes(file.size);
|
||||
await fs.writeFile(path.join(storagePath, file.path), content);
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
console.log(` ✓ Created ${files.length} test files`);
|
||||
console.log(` ✓ Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
return { storagePath, fileCount: files.length, totalSize };
|
||||
}
|
||||
|
||||
async function configureBackup(type) {
|
||||
const baseSettings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: `"${type}"` },
|
||||
{ setting_key: 'backup_include_archived', setting_value: 'true' },
|
||||
{ setting_key: 'backup_include_database', setting_value: 'true' },
|
||||
{ setting_key: 'backup_incremental', setting_value: 'true' },
|
||||
{ setting_key: 'backup_manifest_format', setting_value: '"json"' },
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
const typeSpecificSettings = {
|
||||
s3: [
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: `"${options.bucket}"` },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: `"${options.endpoint}"` },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: `"${options.accessKey}"` },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: `"${options.secretKey}"` },
|
||||
{ setting_key: 'backup_s3_region', setting_value: '"us-east-1"' },
|
||||
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
|
||||
{ setting_key: 'backup_s3_ssl_enabled', setting_value: options.endpoint.startsWith('https') ? 'true' : 'false' }
|
||||
],
|
||||
local: [
|
||||
{ setting_key: 'backup_destination_path', setting_value: `"${path.join(__dirname, '../test-backup')}"` }
|
||||
],
|
||||
rsync: [
|
||||
{ setting_key: 'backup_rsync_host', setting_value: '"localhost"' },
|
||||
{ setting_key: 'backup_rsync_path', setting_value: `"${path.join(__dirname, '../test-backup-rsync')}"` }
|
||||
]
|
||||
};
|
||||
|
||||
const settings = [...baseSettings, ...(typeSpecificSettings[type] || [])];
|
||||
|
||||
// Clear existing settings
|
||||
await db('app_settings').where('setting_type', 'backup').del();
|
||||
|
||||
// Insert new settings
|
||||
for (const setting of settings) {
|
||||
await db('app_settings').insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ Configured ${type} backup with ${settings.length} settings`);
|
||||
}
|
||||
|
||||
async function performBackup() {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Run the backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Get backup results
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (!backupRun) {
|
||||
throw new Error('No backup run found');
|
||||
}
|
||||
|
||||
if (backupRun.status !== 'completed') {
|
||||
throw new Error(`Backup failed with status: ${backupRun.status}, error: ${backupRun.error_message}`);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log(` ✓ Backup completed in ${duration}ms`);
|
||||
console.log(` ✓ Files backed up: ${backupRun.files_backed_up}`);
|
||||
console.log(` ✓ Total size: ${(backupRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` ✓ Manifest: ${backupRun.manifest_path ? 'Generated' : 'Not generated'}`);
|
||||
|
||||
return backupRun;
|
||||
}
|
||||
|
||||
async function verifyS3Backup(backupRun) {
|
||||
const s3Client = new S3Client({
|
||||
endpoint: options.endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: options.accessKey,
|
||||
secretAccessKey: options.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
// List objects in bucket
|
||||
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: options.bucket
|
||||
}));
|
||||
|
||||
const objects = listResponse.Contents || [];
|
||||
console.log(` ✓ Objects in S3: ${objects.length}`);
|
||||
|
||||
// Verify key components
|
||||
const hasBackupFolder = objects.some(obj => obj.Key.includes('backup-'));
|
||||
const hasManifest = objects.some(obj => obj.Key.includes('backup-manifest'));
|
||||
const hasSummary = objects.some(obj => obj.Key.includes('backup-summary.json'));
|
||||
const hasPhotos = objects.some(obj => obj.Key.includes('events/active'));
|
||||
|
||||
if (!hasBackupFolder) throw new Error('No backup folder found in S3');
|
||||
if (!hasManifest) throw new Error('No manifest found in S3');
|
||||
if (!hasSummary) throw new Error('No summary found in S3');
|
||||
if (!hasPhotos) throw new Error('No photos found in S3');
|
||||
|
||||
console.log(` ✓ Backup structure verified`);
|
||||
|
||||
// Download and verify a file
|
||||
const photoObject = objects.find(obj => obj.Key.includes('photo1.jpg'));
|
||||
if (photoObject) {
|
||||
const getResponse = await s3Client.send(new GetObjectCommand({
|
||||
Bucket: options.bucket,
|
||||
Key: photoObject.Key
|
||||
}));
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of getResponse.Body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const content = Buffer.concat(chunks);
|
||||
|
||||
console.log(` ✓ Downloaded test file: ${photoObject.Key} (${content.length} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testIncrementalBackup(testData) {
|
||||
// Modify a file
|
||||
const modifiedFile = path.join(testData.storagePath, 'events/active/wedding-2024/photo1.jpg');
|
||||
const newContent = crypto.randomBytes(1024 * 1024 + 100); // Slightly larger
|
||||
await fs.writeFile(modifiedFile, newContent);
|
||||
|
||||
console.log(` ✓ Modified test file`);
|
||||
|
||||
// Perform incremental backup
|
||||
const backupRun = await performBackup();
|
||||
|
||||
if (backupRun.files_backed_up !== 1) {
|
||||
throw new Error(`Expected 1 file in incremental backup, got ${backupRun.files_backed_up}`);
|
||||
}
|
||||
|
||||
console.log(` ✓ Incremental backup correctly identified changed file`);
|
||||
|
||||
// Verify manifest indicates incremental
|
||||
if (backupRun.manifest_path) {
|
||||
const { manifest } = await backupService.getBackupManifest(backupRun.id);
|
||||
if (!manifest.incremental) {
|
||||
throw new Error('Manifest does not indicate incremental backup');
|
||||
}
|
||||
console.log(` ✓ Manifest correctly marked as incremental`);
|
||||
}
|
||||
|
||||
return backupRun;
|
||||
}
|
||||
|
||||
async function testManifestValidation(backupRun) {
|
||||
if (!backupRun.manifest_path) {
|
||||
throw new Error('No manifest path in backup run');
|
||||
}
|
||||
|
||||
const result = await backupService.validateBackupManifest(backupRun.manifest_path);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new Error(`Manifest validation failed: ${result.error}`);
|
||||
}
|
||||
|
||||
console.log(` ✓ Manifest validation passed`);
|
||||
console.log(` ✓ Manifest version: ${result.manifest.manifest.version}`);
|
||||
console.log(` ✓ Files in manifest: ${result.manifest.files.count}`);
|
||||
}
|
||||
|
||||
async function testBackupStatus() {
|
||||
const status = await backupService.getBackupStatus(5);
|
||||
|
||||
console.log(` ✓ Backup service running: ${status.isRunning}`);
|
||||
console.log(` ✓ Backup service healthy: ${status.isHealthy}`);
|
||||
console.log(` ✓ Recent runs: ${status.recentRuns.length}`);
|
||||
|
||||
if (status.lastRun) {
|
||||
console.log(` ✓ Last run status: ${status.lastRun.status}`);
|
||||
console.log(` ✓ Manifest valid: ${status.lastRun.manifestValid}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupTestData() {
|
||||
if (!options.cleanup) {
|
||||
console.log('\n📌 Test data retained for inspection');
|
||||
console.log(` Storage: ${process.env.STORAGE_PATH}`);
|
||||
if (options.type === 's3') {
|
||||
console.log(` S3 Bucket: ${options.bucket}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🧹 Cleaning up test data...');
|
||||
|
||||
// Clean storage directory
|
||||
if (process.env.STORAGE_PATH) {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true });
|
||||
console.log(' ✓ Removed test storage directory');
|
||||
}
|
||||
|
||||
// Clean S3 bucket if used
|
||||
if (options.type === 's3') {
|
||||
const s3Client = new S3Client({
|
||||
endpoint: options.endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: options.accessKey,
|
||||
secretAccessKey: options.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
try {
|
||||
// List and delete all objects
|
||||
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: options.bucket
|
||||
}));
|
||||
|
||||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: options.bucket,
|
||||
Delete: {
|
||||
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key }))
|
||||
}
|
||||
}));
|
||||
console.log(` ✓ Deleted ${listResponse.Contents.length} objects from S3`);
|
||||
}
|
||||
|
||||
// Delete bucket
|
||||
await s3Client.send(new DeleteBucketCommand({
|
||||
Bucket: options.bucket
|
||||
}));
|
||||
console.log(` ✓ Deleted S3 bucket: ${options.bucket}`);
|
||||
} catch (error) {
|
||||
console.error(` ⚠️ Failed to cleanup S3: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean backup directories
|
||||
const backupDirs = [
|
||||
path.join(__dirname, '../test-backup'),
|
||||
path.join(__dirname, '../test-backup-rsync')
|
||||
];
|
||||
|
||||
for (const dir of backupDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
console.log(' ✓ Removed backup directories');
|
||||
}
|
||||
|
||||
// Main test runner
|
||||
async function main() {
|
||||
console.log('🚀 Enhanced Backup System Integration Test');
|
||||
console.log('==========================================');
|
||||
console.log(`Type: ${options.type}`);
|
||||
console.log(`Endpoint: ${options.endpoint}`);
|
||||
console.log(`Bucket: ${options.bucket}`);
|
||||
console.log('');
|
||||
|
||||
let s3Client;
|
||||
let testData;
|
||||
|
||||
try {
|
||||
// Initialize database
|
||||
console.log('📦 Initializing database...');
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
console.log(' ✓ Database initialized');
|
||||
|
||||
// S3-specific setup
|
||||
if (options.type === 's3') {
|
||||
// Test S3 connection
|
||||
await runTest('S3 Connection Test', testS3Connection);
|
||||
|
||||
// Create S3 bucket if needed
|
||||
s3Client = new S3Client({
|
||||
endpoint: options.endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: options.accessKey,
|
||||
secretAccessKey: options.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
try {
|
||||
await s3Client.send(new HeadBucketCommand({ Bucket: options.bucket }));
|
||||
console.log(`\n📦 Using existing bucket: ${options.bucket}`);
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFound') {
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: options.bucket }));
|
||||
console.log(`\n📦 Created new bucket: ${options.bucket}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup test data
|
||||
console.log('\n📁 Setting up test data...');
|
||||
testData = await setupTestData();
|
||||
|
||||
// Configure backup
|
||||
console.log(`\n⚙️ Configuring ${options.type} backup...`);
|
||||
await configureBackup(options.type);
|
||||
|
||||
// Run tests based on backup type
|
||||
await runTest('Initial Full Backup', performBackup);
|
||||
|
||||
if (options.type === 's3') {
|
||||
await runTest('Verify S3 Backup Contents', async () => {
|
||||
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
|
||||
await verifyS3Backup(lastRun);
|
||||
});
|
||||
}
|
||||
|
||||
await runTest('Incremental Backup', () => testIncrementalBackup(testData));
|
||||
|
||||
await runTest('Manifest Validation', async () => {
|
||||
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
|
||||
await testManifestValidation(lastRun);
|
||||
});
|
||||
|
||||
await runTest('Backup Status Check', testBackupStatus);
|
||||
|
||||
// Performance test with larger files
|
||||
if (options.type === 's3') {
|
||||
await runTest('Large File Backup (10MB)', async () => {
|
||||
const largeFile = path.join(testData.storagePath, 'events/active/large.jpg');
|
||||
await fs.writeFile(largeFile, crypto.randomBytes(10 * 1024 * 1024));
|
||||
await performBackup();
|
||||
});
|
||||
}
|
||||
|
||||
// Test backup service lifecycle
|
||||
await runTest('Backup Service Start/Stop', async () => {
|
||||
await backupService.startBackupService();
|
||||
console.log(' ✓ Service started');
|
||||
|
||||
backupService.stopBackupService();
|
||||
console.log(' ✓ Service stopped');
|
||||
});
|
||||
|
||||
// Print results summary
|
||||
console.log('\n📊 Test Results Summary');
|
||||
console.log('======================');
|
||||
console.log(`✅ Passed: ${results.passed}`);
|
||||
console.log(`❌ Failed: ${results.failed}`);
|
||||
console.log(`⏭️ Skipped: ${results.skipped}`);
|
||||
console.log(`📋 Total: ${results.tests.length}`);
|
||||
|
||||
if (results.failed > 0) {
|
||||
console.log('\nFailed Tests:');
|
||||
results.tests
|
||||
.filter(t => t.status === 'failed')
|
||||
.forEach(t => console.log(` - ${t.name}: ${t.error}`));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n💥 Fatal error:', error.message);
|
||||
if (options.verbose) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
results.failed++;
|
||||
} finally {
|
||||
// Cleanup
|
||||
await cleanupTestData();
|
||||
|
||||
// Close database
|
||||
await db.destroy();
|
||||
|
||||
// Exit with appropriate code
|
||||
process.exit(results.failed > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runTest, skipTest };
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Test script to verify CMS and email formatting improvements
|
||||
*/
|
||||
|
||||
const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters');
|
||||
|
||||
console.log('Testing CMS and Email Formatting Improvements\n');
|
||||
|
||||
// Test 1: Basic line break conversion
|
||||
console.log('Test 1: Basic line break conversion');
|
||||
const basicText = `Hello,
|
||||
This is line 1.
|
||||
This is line 2.
|
||||
|
||||
This is line 4 with an extra break.`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(basicText);
|
||||
console.log('\nOutput (nl2br):');
|
||||
console.log(nl2br(basicText));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 2: Welcome message formatting
|
||||
console.log('Test 2: Welcome message formatting');
|
||||
const welcomeMessage = `Dear guests,
|
||||
|
||||
We're so excited to share these special moments with you!
|
||||
|
||||
Please note:
|
||||
- Download your photos before the expiration date
|
||||
- The password is case-sensitive
|
||||
- Contact us if you have any issues
|
||||
|
||||
Thank you for being part of our special day!
|
||||
|
||||
Best regards,
|
||||
Sarah & John`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(welcomeMessage);
|
||||
console.log('\nOutput (formatWelcomeMessage):');
|
||||
console.log(formatWelcomeMessage(welcomeMessage));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 3: Empty and edge cases
|
||||
console.log('Test 3: Edge cases');
|
||||
console.log('Empty string:', formatWelcomeMessage(''));
|
||||
console.log('Null:', formatWelcomeMessage(null));
|
||||
console.log('Only spaces:', formatWelcomeMessage(' \n \n '));
|
||||
console.log('Single line:', formatWelcomeMessage('This is a single line message'));
|
||||
|
||||
console.log('\nAll tests completed!');
|
||||
@@ -0,0 +1,85 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const { processTemplate } = require('../src/services/emailProcessor');
|
||||
|
||||
async function testGermanEmails() {
|
||||
try {
|
||||
console.log('=== Testing German Email Templates ===\n');
|
||||
|
||||
// Test variables
|
||||
const testVars = {
|
||||
host_name: 'Max Mustermann',
|
||||
event_name: 'Hochzeit Schmidt',
|
||||
event_date: '15.07.2024',
|
||||
gallery_link: 'https://example.com/gallery/test',
|
||||
gallery_password: 'test1234',
|
||||
expiry_date: '15.08.2024',
|
||||
days_remaining: '7',
|
||||
welcome_message: 'Herzlich willkommen zu unserer Hochzeitsgalerie!',
|
||||
archive_size: '250 MB',
|
||||
archive_date: '16.08.2024',
|
||||
photo_count: '347',
|
||||
admin_email: 'support@example.com',
|
||||
eventId: 1
|
||||
};
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n========== ${template.template_key.toUpperCase()} ==========`);
|
||||
|
||||
// Process German version
|
||||
const germanResult = await processGermanTemplate(template, testVars);
|
||||
|
||||
console.log('\n--- GERMAN VERSION ---');
|
||||
console.log('Subject:', germanResult.subject);
|
||||
console.log('\nHTML Preview (first 500 chars):');
|
||||
console.log(germanResult.htmlBody.substring(0, 500) + '...\n');
|
||||
|
||||
// Check for any remaining English text
|
||||
const englishWords = ['Dear', 'Gallery', 'Details:', 'Link:', 'Password:', 'days', 'Thank you'];
|
||||
const foundEnglish = englishWords.filter(word =>
|
||||
germanResult.htmlBody.includes(word) || germanResult.subject.includes(word)
|
||||
);
|
||||
|
||||
if (foundEnglish.length > 0) {
|
||||
console.log('⚠️ WARNING: Found English words:', foundEnglish.join(', '));
|
||||
} else {
|
||||
console.log('✅ No English words found in German template');
|
||||
}
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function processGermanTemplate(template, variables) {
|
||||
// Process template as German
|
||||
const subjectField = 'subject_de';
|
||||
const htmlField = 'body_html_de';
|
||||
const textField = 'body_text_de';
|
||||
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Replace variables
|
||||
Object.keys(variables).forEach(key => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
subject = subject.replace(regex, variables[key]);
|
||||
htmlBody = htmlBody.replace(regex, variables[key]);
|
||||
textBody = textBody.replace(regex, variables[key]);
|
||||
});
|
||||
|
||||
// Handle conditionals (simplified)
|
||||
htmlBody = htmlBody.replace(/{{#if welcome_message}}[\s\S]*?{{\/if}}/g, (match) => {
|
||||
return variables.welcome_message ? match.replace(/{{#if welcome_message}}|{{\/if}}/g, '') : '';
|
||||
});
|
||||
|
||||
return { subject, htmlBody, textBody };
|
||||
}
|
||||
|
||||
testGermanEmails();
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to test photo authentication
|
||||
* Usage: node scripts/test-photo-auth.js <jwt-token>
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
async function testPhotoAuth(token) {
|
||||
if (!token) {
|
||||
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
|
||||
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
|
||||
console.log(`Base URL: ${baseUrl}\n`);
|
||||
|
||||
// Test URLs
|
||||
const tests = [
|
||||
{
|
||||
name: 'Thumbnail via static route',
|
||||
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Photo via static route',
|
||||
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Gallery photos API',
|
||||
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(`Testing: ${test.name}`);
|
||||
console.log(`URL: ${test.url}`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(test.url, {
|
||||
headers: test.headers,
|
||||
validateStatus: () => true // Don't throw on any status
|
||||
});
|
||||
|
||||
console.log(`Status: ${response.status}`);
|
||||
console.log(`Headers:`, response.headers['content-type']);
|
||||
|
||||
if (response.status === 200) {
|
||||
if (test.name.includes('API')) {
|
||||
console.log(`Photos count: ${response.data.photos?.length || 0}`);
|
||||
} else {
|
||||
console.log(`Content length: ${response.headers['content-length']} bytes`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Error:`, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Network error:`, error.message);
|
||||
}
|
||||
|
||||
console.log('---\n');
|
||||
}
|
||||
|
||||
// Decode token to show info
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
console.log('Token payload:', payload);
|
||||
} catch (error) {
|
||||
console.log('Failed to decode token');
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from command line
|
||||
const token = process.argv[2];
|
||||
|
||||
testPhotoAuth(token).catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script to verify security logging is working correctly
|
||||
* Run with: node scripts/test-security-logging.js
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
console.log('Testing Security Logging...\n');
|
||||
|
||||
// Test 1: Basic logging
|
||||
console.log('1. Testing basic logging levels:');
|
||||
logger.info('Test info message', { test: true });
|
||||
logger.warn('Test warning message', { test: true });
|
||||
logger.error('Test error message', { test: true });
|
||||
|
||||
// Test 2: Security event logging
|
||||
console.log('\n2. Testing security event logging:');
|
||||
|
||||
// Rate limit exceeded
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: '192.168.1.100',
|
||||
path: '/api/admin/login',
|
||||
method: 'POST',
|
||||
authenticated: false,
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
timestamp: new Date().toISOString(),
|
||||
rateLimitInfo: {
|
||||
limit: 5,
|
||||
current: 6,
|
||||
remaining: 0,
|
||||
resetTime: new Date(Date.now() + 900000).toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Auth rate limit
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: '192.168.1.101',
|
||||
path: '/api/auth/admin/login',
|
||||
method: 'POST',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
authType: 'admin',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Failed login
|
||||
logger.warn('Failed login attempt', {
|
||||
username: 'testuser',
|
||||
ip: '192.168.1.102',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
reason: 'invalid_credentials',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// JWT validation failure
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: '192.168.1.103',
|
||||
path: '/api/admin/events',
|
||||
method: 'GET',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
error: 'TokenExpiredError',
|
||||
message: 'jwt expired',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Account lockout
|
||||
logger.warn('Login attempt on locked account', {
|
||||
username: 'lockeduser',
|
||||
ip: '192.168.1.104',
|
||||
remainingLockTime: 1200,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Suspicious activity
|
||||
logger.warn('Suspicious login activity detected', {
|
||||
username: 'suspicioususer',
|
||||
ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'],
|
||||
timeWindow: '15 minutes',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('\n3. Check log files:');
|
||||
console.log('- logs/security.log - Should contain all security warnings');
|
||||
console.log('- logs/error.log - Should contain error messages');
|
||||
console.log('- logs/combined.log - Should contain all messages');
|
||||
|
||||
console.log('\n✅ Security logging test complete!');
|
||||
console.log('Review the log files to ensure all events are properly captured.');
|
||||
|
||||
// Give logger time to flush
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
@@ -0,0 +1,110 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function verifyTemplateEquality() {
|
||||
try {
|
||||
console.log('Verifying template equality between German and English versions...\n');
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n=== ${template.template_key.toUpperCase()} ===`);
|
||||
|
||||
// Check subject length similarity
|
||||
const subjectEnLength = template.subject_en?.length || 0;
|
||||
const subjectDeLength = template.subject_de?.length || 0;
|
||||
console.log(`Subject length - EN: ${subjectEnLength}, DE: ${subjectDeLength}`);
|
||||
|
||||
// Check HTML content features
|
||||
const htmlEn = template.body_html_en || '';
|
||||
const htmlDe = template.body_html_de || '';
|
||||
|
||||
// Check for key features in both versions
|
||||
const features = [
|
||||
{ name: 'Handlebars conditionals', pattern: /{{#if/g },
|
||||
{ name: 'Styled divs', pattern: /style="/g },
|
||||
{ name: 'Background colors', pattern: /background-color:/g },
|
||||
{ name: 'Buttons/CTAs', pattern: /<a.*style.*background-color.*>/g },
|
||||
{ name: 'Icons/Emojis', pattern: /[📧📞✅⚠️]/g },
|
||||
{ name: 'Lists', pattern: /<ul/g },
|
||||
{ name: 'Strong emphasis', pattern: /<strong>/g }
|
||||
];
|
||||
|
||||
console.log('\nFeature comparison:');
|
||||
for (const feature of features) {
|
||||
const enCount = (htmlEn.match(feature.pattern) || []).length;
|
||||
const deCount = (htmlDe.match(feature.pattern) || []).length;
|
||||
const status = enCount === deCount ? '✅' : '❌';
|
||||
console.log(`${status} ${feature.name}: EN=${enCount}, DE=${deCount}`);
|
||||
}
|
||||
|
||||
// Check text content length
|
||||
const textEn = template.body_text_en || '';
|
||||
const textDe = template.body_text_de || '';
|
||||
console.log(`\nText content length - EN: ${textEn.length}, DE: ${textDe.length}`);
|
||||
|
||||
// Check for specific variables usage
|
||||
const variables = [
|
||||
'host_name', 'event_name', 'event_date', 'gallery_link',
|
||||
'gallery_password', 'expiry_date', 'welcome_message',
|
||||
'days_remaining', 'support_email', 'support_phone',
|
||||
'archive_date', 'photo_count', 'archive_size'
|
||||
];
|
||||
|
||||
const missingInEn = [];
|
||||
const missingInDe = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
const varPattern = new RegExp(`{{${variable}}}`, 'g');
|
||||
const inEn = varPattern.test(htmlEn) || varPattern.test(textEn);
|
||||
const inDe = varPattern.test(htmlDe) || varPattern.test(textDe);
|
||||
|
||||
if (inDe && !inEn) missingInEn.push(variable);
|
||||
if (inEn && !inDe) missingInDe.push(variable);
|
||||
}
|
||||
|
||||
if (missingInEn.length > 0) {
|
||||
console.log(`\n⚠️ Variables in DE but missing in EN: ${missingInEn.join(', ')}`);
|
||||
}
|
||||
if (missingInDe.length > 0) {
|
||||
console.log(`\n⚠️ Variables in EN but missing in DE: ${missingInDe.join(', ')}`);
|
||||
}
|
||||
|
||||
// Overall quality score
|
||||
const enScore = [
|
||||
htmlEn.includes('style='),
|
||||
htmlEn.includes('{{#if'),
|
||||
htmlEn.includes('background-color'),
|
||||
htmlEn.includes('<strong>'),
|
||||
htmlEn.includes('margin:'),
|
||||
htmlEn.includes('padding:')
|
||||
].filter(Boolean).length;
|
||||
|
||||
const deScore = [
|
||||
htmlDe.includes('style='),
|
||||
htmlDe.includes('{{#if'),
|
||||
htmlDe.includes('background-color'),
|
||||
htmlDe.includes('<strong>'),
|
||||
htmlDe.includes('margin:'),
|
||||
htmlDe.includes('padding:')
|
||||
].filter(Boolean).length;
|
||||
|
||||
console.log(`\nQuality score (out of 6) - EN: ${enScore}, DE: ${deScore}`);
|
||||
console.log(enScore === deScore ? '✅ Templates have equal quality!' : '❌ Quality mismatch');
|
||||
}
|
||||
|
||||
console.log('\n\nSummary:');
|
||||
console.log('The English templates have been updated to match the German templates in:');
|
||||
console.log('- HTML styling and structure');
|
||||
console.log('- Conditional content blocks');
|
||||
console.log('- Visual elements (buttons, alerts, icons)');
|
||||
console.log('- Information completeness');
|
||||
console.log('- Professional formatting');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
verifyTemplateEquality();
|
||||
+71
-35
@@ -4,19 +4,27 @@ require('dotenv').config();
|
||||
const { validateEnvironment } = require('./src/config/validateEnv');
|
||||
validateEnvironment();
|
||||
|
||||
// Initialize logger early to capture startup logs
|
||||
const logger = require('./src/utils/logger');
|
||||
logger.info('Server starting up', {
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const path = require('path');
|
||||
const { initializeDatabase, db } = require('./src/database/db');
|
||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||
const { startBackupService } = require('./src/services/backupService');
|
||||
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const logger = require('./src/utils/logger');
|
||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth-enhanced');
|
||||
@@ -93,37 +101,23 @@ const corsOptions = {
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Rate limiting with admin bypass
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
|
||||
skip: (req) => {
|
||||
// Skip rate limiting for authenticated admin users
|
||||
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
|
||||
const token = req.headers.authorization.replace('Bearer ', '');
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return decoded.type === 'admin';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Also skip rate limiting for public settings endpoint in development
|
||||
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// Initialize rate limiters (they will be created dynamically)
|
||||
let generalRateLimiter;
|
||||
let authRateLimiter;
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5 // limit auth attempts
|
||||
});
|
||||
// Function to initialize rate limiters
|
||||
async function initializeRateLimiters() {
|
||||
generalRateLimiter = await createRateLimiter();
|
||||
authRateLimiter = await createAuthRateLimiter();
|
||||
|
||||
// Apply rate limiting
|
||||
app.use('/api/', generalRateLimiter);
|
||||
app.use('/api/auth', authRateLimiter);
|
||||
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||
app.use('/api/admin/auth/login', authRateLimiter);
|
||||
}
|
||||
|
||||
// Apply rate limiting - admin routes check will skip for valid admin tokens
|
||||
app.use('/api/', limiter);
|
||||
app.use('/api/auth', authLimiter);
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
|
||||
// Body parsing middleware with increased limits for large uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
@@ -158,6 +152,28 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
|
||||
// Static file serving for uploads (public - logos, favicons)
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||
|
||||
// Debug endpoint to check IP detection (only in development)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.get('/api/debug/ip', (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
res.json({
|
||||
detectedIp: clientIp,
|
||||
reqIp: req.ip,
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip'],
|
||||
'x-forwarded-proto': req.headers['x-forwarded-proto'],
|
||||
'x-forwarded-host': req.headers['x-forwarded-host']
|
||||
},
|
||||
trustProxy: app.get('trust proxy')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
@@ -187,6 +203,8 @@ app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||
@@ -203,9 +221,21 @@ async function startServer() {
|
||||
// Initialize database
|
||||
await initializeDatabase();
|
||||
|
||||
// Initialize auth security cleanup job
|
||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||
initializeCleanupJob();
|
||||
// Initialize rate limiters after database is ready
|
||||
await initializeRateLimiters();
|
||||
logger.info('Rate limiters initialized with database configuration');
|
||||
|
||||
// Initialize auth security cleanup job
|
||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||
initializeCleanupJob();
|
||||
|
||||
// Initialize temp upload cleanup job
|
||||
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
|
||||
// Run cleanup on startup
|
||||
cleanupTempUploads();
|
||||
// Schedule periodic cleanup every hour
|
||||
setInterval(cleanupTempUploads, 60 * 60 * 1000);
|
||||
logger.info('Temp upload cleanup scheduled');
|
||||
|
||||
// Start file watcher
|
||||
startFileWatcher();
|
||||
@@ -217,6 +247,12 @@ async function startServer() {
|
||||
await initializeTransporter();
|
||||
startEmailQueueProcessor();
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
// Start database backup service
|
||||
await startScheduledBackups();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
|
||||
@@ -34,7 +34,7 @@ function validateEnvironment() {
|
||||
if (name === 'JWT_SECRET' && value) {
|
||||
// Check for the insecure default value
|
||||
if (value === 'your-secret-key') {
|
||||
errors.push(`CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.`);
|
||||
errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.');
|
||||
}
|
||||
|
||||
// Check minimum length (should be at least 32 characters for security)
|
||||
|
||||
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
|
||||
// Create database connection with built-in retry logic
|
||||
const db = knex(knexConfig);
|
||||
|
||||
// Connection retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
// Wrapper function to handle connection retries
|
||||
async function withRetry(queryFn, retries = MAX_RETRIES) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await queryFn();
|
||||
} catch (error) {
|
||||
const isConnectionError = error.message && (
|
||||
error.message.includes('Connection terminated unexpectedly') ||
|
||||
error.message.includes('Connection ended unexpectedly') ||
|
||||
error.message.includes('ECONNREFUSED') ||
|
||||
error.message.includes('ETIMEDOUT')
|
||||
);
|
||||
|
||||
if (isConnectionError && i < retries - 1) {
|
||||
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabase() {
|
||||
// Events table
|
||||
const hasEventsTable = await db.schema.hasTable('events');
|
||||
@@ -59,9 +86,9 @@ async function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
|
||||
await db.raw(`DROP TABLE events`);
|
||||
await db.raw(`ALTER TABLE events_new RENAME TO events`);
|
||||
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||
await db.raw('DROP TABLE events');
|
||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||
} catch (error) {
|
||||
// If the migration fails, it might already have been applied
|
||||
console.log('Color theme migration may have already been applied');
|
||||
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, logActivity };
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
@@ -1,24 +1,83 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
logger.warn('Admin auth attempt without token', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (jwtError) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
error: jwtError.name,
|
||||
message: jwtError.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (jwtError.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
|
||||
|
||||
if (!admin) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('Admin auth failed - user not found or inactive', {
|
||||
ip: clientIp,
|
||||
userId: decoded.id,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.admin = admin;
|
||||
next();
|
||||
} catch (error) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.error('Admin auth middleware error', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Validate uploaded file is complete and not corrupted
|
||||
*/
|
||||
async function validateUploadedFile(filePath) {
|
||||
try {
|
||||
// Check file exists and has size
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// For image files, verify they can be read by Sharp
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
if (imageExtensions.includes(ext)) {
|
||||
// Try to read metadata - this will fail if image is corrupted
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(filePath, {
|
||||
failOnError: false, // Don't fail on recoverable errors
|
||||
limitInputPixels: 268402689 // ~16k x 16k max
|
||||
}).metadata();
|
||||
} catch (metadataError) {
|
||||
// If metadata reading fails, the file is likely incomplete
|
||||
throw new Error(`Invalid image file: ${metadataError.message}`);
|
||||
}
|
||||
|
||||
if (!metadata || !metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image dimensions - file may be incomplete');
|
||||
}
|
||||
|
||||
// Check for reasonable dimensions
|
||||
if (metadata.width < 10 || metadata.height < 10) {
|
||||
throw new Error('Image dimensions too small');
|
||||
}
|
||||
|
||||
// Additional check: verify we can actually decode a small portion of the image
|
||||
try {
|
||||
await sharp(filePath, {
|
||||
failOnError: false,
|
||||
limitInputPixels: 268402689
|
||||
})
|
||||
.resize(10, 10) // Try to resize to very small size
|
||||
.toBuffer();
|
||||
} catch (decodeError) {
|
||||
throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`File validation failed for ${filePath}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate uploaded files after multer processing
|
||||
*/
|
||||
async function validateUploadedFiles(req, res, next) {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const validFiles = [];
|
||||
const invalidFiles = [];
|
||||
|
||||
// Validate each file
|
||||
for (const file of req.files) {
|
||||
try {
|
||||
await validateUploadedFile(file.path);
|
||||
validFiles.push(file);
|
||||
} catch (error) {
|
||||
logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`);
|
||||
invalidFiles.push({
|
||||
filename: file.originalname,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// Delete the invalid file
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch (unlinkErr) {
|
||||
logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update req.files to only include valid files
|
||||
req.files = validFiles;
|
||||
|
||||
// Store invalid files info for response
|
||||
if (invalidFiles.length > 0) {
|
||||
req.invalidFiles = invalidFiles;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateUploadedFile,
|
||||
validateUploadedFiles
|
||||
};
|
||||
@@ -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;
|
||||
@@ -251,7 +251,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
} catch (statError) {
|
||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||
console.error(`Entry name was: ${entry.entryName}`);
|
||||
console.error(`Error:`, statError.message);
|
||||
console.error('Error:', statError.message);
|
||||
// Skip this file if we can't stat it
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -210,15 +210,15 @@ router.get('/templates', adminAuth, async (req, res) => {
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
@@ -66,7 +67,12 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
const processedEventName = event_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
@@ -83,7 +89,14 @@ router.post('/', adminAuth, [
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
@@ -122,8 +135,7 @@ router.post('/', adminAuth, [
|
||||
);
|
||||
|
||||
// Queue creation email
|
||||
// Determine language based on email domain
|
||||
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
|
||||
// Language detection is handled by email processor
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
@@ -132,10 +144,10 @@ router.post('/', adminAuth, [
|
||||
email_data: JSON.stringify({
|
||||
host_name: host_name,
|
||||
event_name,
|
||||
event_date: await formatDate(event_date, emailLang),
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
expiry_date: await formatDate(expires_at, emailLang),
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
@@ -388,13 +400,56 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete associated photos
|
||||
await db('photos').where('event_id', id).del();
|
||||
// Start a transaction to ensure all deletions succeed or fail together
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', id).del();
|
||||
|
||||
// Delete event
|
||||
await db('events').where('id', id).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', id).del();
|
||||
|
||||
// Log activity
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', id).del();
|
||||
|
||||
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', id).del();
|
||||
|
||||
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
|
||||
await trx('photo_categories').where('event_id', id).del();
|
||||
|
||||
// 6. Finally delete the event
|
||||
await trx('events').where('id', id).del();
|
||||
|
||||
// Delete event folder from storage if it exists
|
||||
if (event.folder_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete event folder:', err);
|
||||
// Don't fail the transaction if folder deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete archive if exists
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete archive file:', err);
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
@@ -404,7 +459,19 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
res.json({ message: 'Event deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||
details: error.message
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -480,16 +547,15 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
// For password reset, we'll need to create a template or use a different approach
|
||||
// For now, let's use the gallery_created template with updated password
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: new Date(event.event_date).toLocaleDateString(),
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: new Date(event.expires_at).toLocaleDateString()
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
@@ -504,6 +570,81 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get event details
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// The email processor will determine the language based on:
|
||||
// 1. Event language setting
|
||||
// 2. App settings general_default_language
|
||||
// 3. Email config default language
|
||||
// 4. Domain-based detection
|
||||
// So we don't need to determine it here
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
|
||||
// Log the activity using the proper schema
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: event.host_email,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
type: 'admin',
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Warning: Failed to log activity:', logError);
|
||||
// Don't fail the request if activity logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resending creation email:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
res.status(500).json({ error: 'Failed to resend creation email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
+233
-107
@@ -4,55 +4,41 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
destination: (req, file, cb) => {
|
||||
console.log('Multer destination called for file:', file.originalname);
|
||||
const { eventId } = req.params;
|
||||
|
||||
try {
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found in multer destination:', eventId);
|
||||
return cb(new Error('Event not found'));
|
||||
}
|
||||
|
||||
// Store event in request for use in filename generation
|
||||
req.eventData = event;
|
||||
|
||||
// Create destination path - now just event folder, no type subfolder
|
||||
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
console.log('Destination path:', destPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
cb(null, destPath);
|
||||
} catch (error) {
|
||||
console.error('Error in multer destination:', error);
|
||||
cb(error);
|
||||
}
|
||||
// We'll validate the event exists in the route handler
|
||||
// For now, just create a temp destination
|
||||
const tempPath = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||
|
||||
// Create directory synchronously
|
||||
require('fs').mkdirSync(tempPath, { recursive: true });
|
||||
console.log('Temp destination path:', tempPath);
|
||||
|
||||
// Store temp path for cleanup
|
||||
req.tempUploadPath = tempPath;
|
||||
|
||||
cb(null, tempPath);
|
||||
},
|
||||
filename: async (req, file, cb) => {
|
||||
filename: (req, file, cb) => {
|
||||
console.log('Multer filename called for file:', file.originalname);
|
||||
try {
|
||||
// Use temporary filename for now, will rename after getting category info
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
} catch (error) {
|
||||
console.error('Error in multer filename:', error);
|
||||
cb(error);
|
||||
}
|
||||
// Use a simple temporary filename
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,6 +51,9 @@ const upload = multer({
|
||||
files: 500, // Maximum 500 files
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
parts: 10000, // Maximum number of parts (fields + files)
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only with proper validation
|
||||
@@ -75,7 +64,9 @@ const upload = multer({
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
|
||||
}
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
@@ -87,9 +78,29 @@ const validateUploadContent = createFileUploadValidator({
|
||||
validateContent: true
|
||||
});
|
||||
|
||||
// Request timeout middleware for uploads
|
||||
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
return (req, res, next) => {
|
||||
// Set timeout for the request
|
||||
req.setTimeout(timeout, () => {
|
||||
console.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set response timeout as well
|
||||
res.setTimeout(timeout, () => {
|
||||
console.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
@@ -106,7 +117,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, validateUploadContent, async (req, res) => {
|
||||
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -121,12 +132,28 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found:', eventId);
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
@@ -138,15 +165,27 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
if (parsedCategoryId) {
|
||||
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
||||
if (!category) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({ error: 'Invalid category' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create final destination directory
|
||||
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
await fs.mkdir(finalDestPath, { recursive: true });
|
||||
|
||||
const uploadedPhotos = [];
|
||||
const errors = [];
|
||||
|
||||
// Process files in batches to optimize database operations
|
||||
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
|
||||
const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads
|
||||
|
||||
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
|
||||
const batch = req.files.slice(i, i + BATCH_SIZE);
|
||||
@@ -169,16 +208,25 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
.whereNull('category_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
batchCounter = (uncategorizedCount.count || 0) + 1;
|
||||
batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1;
|
||||
}
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
|
||||
// First pass: prepare data and move files from temp to final location
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
const tempPath = file.path; // Original temp path
|
||||
|
||||
try {
|
||||
// Verify file is complete before processing
|
||||
const tempStats = await fs.stat(tempPath);
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
@@ -188,77 +236,142 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
extension
|
||||
);
|
||||
|
||||
// Rename the file
|
||||
const oldPath = file.path;
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
await fs.rename(oldPath, newPath);
|
||||
|
||||
// Update file object
|
||||
file.filename = newFilename;
|
||||
file.path = newPath;
|
||||
|
||||
// Generate thumbnail with new filename
|
||||
const thumbnailPath = await generateThumbnail(file.path);
|
||||
|
||||
// Calculate relative paths
|
||||
// Calculate final path
|
||||
const finalPath = path.join(finalDestPath, newFilename);
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
|
||||
const relativeThumbPath = thumbnailPath;
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
batchPhotos.push({
|
||||
event_id: eventId,
|
||||
filename: file.filename,
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: parsedCategoryId || null,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
|
||||
type: 'individual',
|
||||
size_bytes: file.size
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
|
||||
// Store move operation for later
|
||||
fileRenameOperations.push({
|
||||
tempPath: tempPath,
|
||||
finalPath: finalPath,
|
||||
filename: newFilename,
|
||||
photoData: photoData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.originalname}:`, error);
|
||||
console.error(`Error preparing file ${file.originalname}:`, error);
|
||||
errors.push({ filename: file.originalname, error: error.message });
|
||||
// Delete the file if it was partially processed
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert all photos from this batch
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
// Update category counter if needed
|
||||
if (category) {
|
||||
if (category && parsedCategoryId) {
|
||||
const newCounter = batchCounter + batchPhotos.length - 1;
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
|
||||
.update({ photo_counter: newCounter });
|
||||
console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`);
|
||||
}
|
||||
|
||||
// Add to uploaded photos array
|
||||
batchPhotos.forEach((photo, index) => {
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[index]?.id || insertedIds[index],
|
||||
filename: photo.filename,
|
||||
size: photo.size_bytes,
|
||||
category_id: photo.category_id
|
||||
});
|
||||
});
|
||||
// Commit the transaction first
|
||||
await trx.commit();
|
||||
console.log(`Successfully committed batch of ${batchPhotos.length} photos`);
|
||||
|
||||
// Now move files from temp to final location after successful commit
|
||||
for (let idx = 0; idx < fileRenameOperations.length; idx++) {
|
||||
const operation = fileRenameOperations[idx];
|
||||
try {
|
||||
// Move the file from temp to final location
|
||||
await fs.rename(operation.tempPath, operation.finalPath);
|
||||
console.log(`Moved file from ${operation.tempPath} to ${operation.finalPath}`);
|
||||
|
||||
// Verify the file was moved successfully
|
||||
const finalStats = await fs.stat(operation.finalPath);
|
||||
if (finalStats.size !== operation.photoData.size_bytes) {
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
size: operation.photoData.size_bytes,
|
||||
category_id: operation.photoData.category_id
|
||||
});
|
||||
} catch (moveError) {
|
||||
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
|
||||
errors.push({
|
||||
filename: operation.filename,
|
||||
error: `File move failed: ${moveError.message}`
|
||||
});
|
||||
|
||||
// Try to clean up the database entry if file move failed
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
try {
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
console.log(`Cleaned up database entry for failed photo ${photoId}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to clean up database entry:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No photos to insert, just rollback
|
||||
await trx.rollback();
|
||||
}
|
||||
|
||||
// Commit the batch transaction
|
||||
await trx.commit();
|
||||
} catch (error) {
|
||||
console.error(`Error processing batch starting at index ${i}:`, error);
|
||||
await trx.rollback();
|
||||
console.error('Stack trace:', error.stack);
|
||||
|
||||
// Try to clean up files from failed batch
|
||||
for (const file of batch) {
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
// Rollback if not already committed
|
||||
if (!trx.isCompleted()) {
|
||||
await trx.rollback();
|
||||
}
|
||||
|
||||
// Add all files in this batch to errors
|
||||
for (const file of batch) {
|
||||
errors.push({
|
||||
filename: file.originalname,
|
||||
error: `Batch processing failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp upload directory
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,24 +382,39 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Include any files that were invalid from the validation middleware
|
||||
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
|
||||
|
||||
// Prepare response
|
||||
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||
const response = {
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||
photos: uploadedPhotos,
|
||||
totalFiles: req.files.length,
|
||||
totalFiles: totalAttempted,
|
||||
successCount: uploadedPhotos.length,
|
||||
failureCount: errors.length
|
||||
failureCount: totalInvalidFiles.length
|
||||
};
|
||||
|
||||
// Include error details if any files failed
|
||||
if (errors.length > 0) {
|
||||
response.errors = errors;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
||||
if (totalInvalidFiles.length > 0) {
|
||||
response.errors = totalInvalidFiles;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
|
||||
// Clean up temp upload directory on error
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory after error: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
@@ -613,26 +741,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
if (!photo) {
|
||||
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
||||
|
||||
console.log(`Attempting to serve thumbnail: ${filePath}`);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
console.error(`Thumbnail file not found: ${filePath}`, error);
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
const filePath = path.join(storagePath, thumbnailPath);
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
|
||||
res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for logo uploads
|
||||
@@ -495,6 +496,43 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'analytics',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'analytics_settings_updated',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||
});
|
||||
|
||||
res.json({ message: 'Analytics settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Analytics settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update analytics settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -520,11 +558,15 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
|
||||
let archiveStorage = 0;
|
||||
for (const archive of archives) {
|
||||
try {
|
||||
const stats = await fs.stat(archive.archive_path);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,4 +620,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Update rate limit settings
|
||||
router.put('/security/rate-limit', adminAuth, [
|
||||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
|
||||
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
|
||||
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
rate_limit_enabled,
|
||||
rate_limit_window_minutes,
|
||||
rate_limit_max_requests,
|
||||
rate_limit_auth_max_requests,
|
||||
rate_limit_skip_authenticated,
|
||||
rate_limit_public_endpoints_only
|
||||
} = req.body;
|
||||
|
||||
// Update each setting
|
||||
const settings = [
|
||||
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
|
||||
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
|
||||
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
|
||||
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
|
||||
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
|
||||
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
|
||||
];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', key)
|
||||
.update({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the rate limit settings cache to apply changes immediately
|
||||
clearSettingsCache();
|
||||
|
||||
// Log activity
|
||||
await logActivity('settings_updated',
|
||||
{
|
||||
category: 'security',
|
||||
subcategory: 'rate_limit',
|
||||
changes: settings.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Rate limit settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Rate limit settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,9 +1,10 @@
|
||||
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');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -69,12 +70,46 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
|
||||
// Email queue status
|
||||
const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
const [processableEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count');
|
||||
const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count');
|
||||
const [stuckEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count');
|
||||
|
||||
// Activity logs count
|
||||
const [activityCount] = await db('activity_logs').count('* as count');
|
||||
|
||||
// Storage info
|
||||
const [{ totalPhotoStorage }] = await db('photos')
|
||||
.sum('size_bytes as totalPhotoStorage');
|
||||
|
||||
const archives = await db('events')
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.whereNotNull('archive_path')
|
||||
.select('archive_path');
|
||||
|
||||
let archiveStorage = 0;
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
|
||||
|
||||
// System info
|
||||
const systemInfo = {
|
||||
platform: os.platform(),
|
||||
@@ -105,8 +140,15 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
activityLogs: activityCount.count
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
totalUsed: totalStorage,
|
||||
photoStorage: parseInt(totalPhotoStorage) || 0,
|
||||
archiveStorage: archiveStorage
|
||||
},
|
||||
emailQueue: {
|
||||
pending: pendingEmails.count,
|
||||
processable: processableEmails.count,
|
||||
stuck: stuckEmails.count,
|
||||
sent: sentEmails.count,
|
||||
failed: failedEmails.count
|
||||
},
|
||||
|
||||
@@ -87,10 +87,10 @@ router.post('/', adminAuth, [
|
||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
event_name,
|
||||
event_date: new Date(event_date).toLocaleDateString(),
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
expiry_date: expires_at.toLocaleDateString(),
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/photos/${photo.path}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
|
||||
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -320,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get photo stats
|
||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public settings (branding and theme)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Fetch branding, theme, general, and select security settings
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
|
||||
.select('setting_key', 'setting_value');
|
||||
// Fetch branding, theme, general, and security settings
|
||||
// Note: We include analytics in the query but it might not exist yet
|
||||
const settings = await withRetry(async () => {
|
||||
return await db('app_settings')
|
||||
.where(function() {
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%');
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
@@ -41,7 +47,12 @@ router.get('/', async (req, res) => {
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||
// Umami analytics configuration (only if enabled)
|
||||
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
|
||||
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
|
||||
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
|
||||
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
const { DatabaseBackupService } = require('../databaseBackup');
|
||||
const { db } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../database/db');
|
||||
jest.mock('../../utils/logger');
|
||||
jest.mock('../emailProcessor');
|
||||
jest.mock('child_process');
|
||||
|
||||
describe('DatabaseBackupService', () => {
|
||||
let service;
|
||||
let mockExecAsync;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DatabaseBackupService();
|
||||
mockExecAsync = jest.fn();
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock execAsync
|
||||
const childProcess = require('child_process');
|
||||
childProcess.exec = jest.fn((cmd, opts, callback) => {
|
||||
if (callback) {
|
||||
callback(null, { stdout: 'ok', stderr: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup test files
|
||||
try {
|
||||
await fs.rmdir('/tmp/test-backup', { recursive: true });
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe('calculateChecksum', () => {
|
||||
it('should calculate SHA256 checksum of a file', async () => {
|
||||
const testFile = '/tmp/test-checksum.txt';
|
||||
const testContent = 'Hello, World!';
|
||||
await fs.writeFile(testFile, testContent);
|
||||
|
||||
const checksum = await service.calculateChecksum(testFile);
|
||||
|
||||
// Expected checksum for "Hello, World!"
|
||||
const expectedChecksum = crypto
|
||||
.createHash('sha256')
|
||||
.update(testContent)
|
||||
.digest('hex');
|
||||
|
||||
expect(checksum).toBe(expectedChecksum);
|
||||
|
||||
await fs.unlink(testFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableChecksums', () => {
|
||||
it('should get checksums for all tables', async () => {
|
||||
// Mock getTables
|
||||
service.getTables = jest.fn().mockResolvedValue(['events', 'photos']);
|
||||
|
||||
// Mock SQLite response
|
||||
db.raw = jest.fn()
|
||||
.mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }])
|
||||
.mockResolvedValueOnce([{ row_count: 20, data_sum: 2000 }]);
|
||||
|
||||
const checksums = await service.getTableChecksums();
|
||||
|
||||
expect(checksums).toHaveProperty('events');
|
||||
expect(checksums).toHaveProperty('photos');
|
||||
expect(checksums.events.rowCount).toBe(10);
|
||||
expect(checksums.photos.rowCount).toBe(20);
|
||||
expect(checksums.events.checksum).toBeDefined();
|
||||
expect(checksums.photos.checksum).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTables', () => {
|
||||
it('should get list of tables for SQLite', async () => {
|
||||
service.dbType = 'sqlite';
|
||||
|
||||
db.raw = jest.fn().mockResolvedValue([
|
||||
{ name: 'events' },
|
||||
{ name: 'photos' },
|
||||
{ name: 'admin_users' }
|
||||
]);
|
||||
|
||||
const tables = await service.getTables();
|
||||
|
||||
expect(tables).toEqual(['events', 'photos', 'admin_users']);
|
||||
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('sqlite_master'));
|
||||
});
|
||||
|
||||
it('should get list of tables for PostgreSQL', async () => {
|
||||
service.dbType = 'postgresql';
|
||||
|
||||
db.raw = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ table_name: 'events' },
|
||||
{ table_name: 'photos' },
|
||||
{ table_name: 'admin_users' }
|
||||
]
|
||||
});
|
||||
|
||||
const tables = await service.getTables();
|
||||
|
||||
expect(tables).toEqual(['events', 'photos', 'admin_users']);
|
||||
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('information_schema.tables'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatabaseSize', () => {
|
||||
it('should get database size for SQLite', async () => {
|
||||
service.dbType = 'sqlite';
|
||||
const mockSize = 1024 * 1024 * 10; // 10MB
|
||||
|
||||
// Mock fs.stat
|
||||
const originalStat = fs.stat;
|
||||
fs.stat = jest.fn().mockResolvedValue({ size: mockSize });
|
||||
|
||||
const size = await service.getDatabaseSize();
|
||||
|
||||
expect(size).toBe(mockSize);
|
||||
|
||||
fs.stat = originalStat;
|
||||
});
|
||||
|
||||
it('should get database size for PostgreSQL', async () => {
|
||||
service.dbType = 'postgresql';
|
||||
const mockSize = 1024 * 1024 * 100; // 100MB
|
||||
|
||||
db.raw = jest.fn().mockResolvedValue({
|
||||
rows: [{ size: mockSize.toString() }]
|
||||
});
|
||||
|
||||
const size = await service.getDatabaseSize();
|
||||
|
||||
expect(size).toBe(mockSize);
|
||||
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('pg_database_size'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('compressFile', () => {
|
||||
it('should compress file and return stats', async () => {
|
||||
const testFile = '/tmp/test-compress.txt';
|
||||
const compressedFile = '/tmp/test-compress.txt.gz';
|
||||
|
||||
// Create test file with repetitive content (compresses well)
|
||||
const testContent = 'Hello, World! '.repeat(1000);
|
||||
await fs.writeFile(testFile, testContent);
|
||||
|
||||
const stats = await service.compressFile(testFile, compressedFile);
|
||||
|
||||
expect(stats.originalSize).toBeGreaterThan(0);
|
||||
expect(stats.compressedSize).toBeGreaterThan(0);
|
||||
expect(stats.compressedSize).toBeLessThan(stats.originalSize);
|
||||
expect(parseFloat(stats.compressionRatio)).toBeGreaterThan(0);
|
||||
|
||||
// Cleanup
|
||||
await fs.unlink(testFile);
|
||||
await fs.unlink(compressedFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup configuration', () => {
|
||||
it('should get backup configuration from database', async () => {
|
||||
const mockConfig = [
|
||||
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_compress', setting_value: 'true' },
|
||||
{ setting_key: 'database_backup_retention_days', setting_value: '30' }
|
||||
];
|
||||
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue(mockConfig)
|
||||
});
|
||||
|
||||
const config = await service.getBackupConfig();
|
||||
|
||||
expect(config.database_backup_enabled).toBe(true);
|
||||
expect(config.database_backup_compress).toBe(true);
|
||||
expect(config.database_backup_retention_days).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
it('should delete old backup files and records', async () => {
|
||||
const oldBackups = [
|
||||
{ id: 1, file_path: '/backup/old1.sql.gz' },
|
||||
{ id: 2, file_path: '/backup/old2.sql.gz' }
|
||||
];
|
||||
|
||||
db.mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockResolvedValue(oldBackups),
|
||||
delete: jest.fn().mockResolvedValue(1)
|
||||
});
|
||||
|
||||
// Mock fs.unlink
|
||||
fs.unlink = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await service.cleanupOldBackups(30);
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(fs.unlink).toHaveBeenCalledWith('/backup/old1.sql.gz');
|
||||
expect(fs.unlink).toHaveBeenCalledWith('/backup/old2.sql.gz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress tracking', () => {
|
||||
it('should update and retrieve progress', () => {
|
||||
expect(service.getProgress()).toBeNull();
|
||||
|
||||
service.updateProgress('Testing...', { step: 1 });
|
||||
|
||||
const progress = service.getProgress();
|
||||
expect(progress.message).toBe('Testing...');
|
||||
expect(progress.details.step).toBe(1);
|
||||
expect(progress.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup history', () => {
|
||||
it('should retrieve backup history', async () => {
|
||||
const mockHistory = [
|
||||
{
|
||||
id: 1,
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
file_size_bytes: 1024000
|
||||
}
|
||||
];
|
||||
|
||||
db.mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockResolvedValue(mockHistory)
|
||||
});
|
||||
|
||||
const history = await service.getBackupHistory(10);
|
||||
|
||||
expect(history).toEqual(mockHistory);
|
||||
expect(history.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const os = require('os');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Backup Manifest Generator
|
||||
*
|
||||
* Generates comprehensive manifests for backups including:
|
||||
* - Version information (app, node, OS)
|
||||
* - File listings with metadata and checksums
|
||||
* - Database information
|
||||
* - System state at backup time
|
||||
* - Support for both JSON and YAML formats
|
||||
* - Incremental backup support with parent references
|
||||
*/
|
||||
|
||||
class BackupManifestGenerator {
|
||||
constructor() {
|
||||
this.appVersion = require('../../package.json').version;
|
||||
this.nodeVersion = process.version;
|
||||
this.platform = process.platform;
|
||||
this.osRelease = os.release();
|
||||
this.hostname = os.hostname();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive backup manifest
|
||||
* @param {Object} options - Manifest generation options
|
||||
* @param {string} options.backupType - 'full' or 'incremental'
|
||||
* @param {string} options.backupPath - Path to the backup directory
|
||||
* @param {Array} options.files - Array of backed up files with metadata
|
||||
* @param {Object} options.databaseInfo - Database backup information
|
||||
* @param {string} options.parentBackupId - For incremental backups, reference to parent
|
||||
* @param {string} options.format - 'json' or 'yaml' (default: 'json')
|
||||
* @param {Object} options.customMetadata - Additional metadata to include
|
||||
* @returns {Object} Generated manifest object
|
||||
*/
|
||||
async generateManifest(options) {
|
||||
const {
|
||||
backupType = 'full',
|
||||
backupPath,
|
||||
files = [],
|
||||
databaseInfo = {},
|
||||
parentBackupId = null,
|
||||
format = 'json',
|
||||
customMetadata = {}
|
||||
} = options;
|
||||
|
||||
const manifest = {
|
||||
// Manifest metadata
|
||||
manifest: {
|
||||
version: '2.0',
|
||||
created: new Date().toISOString(),
|
||||
generator: 'PicPeak Backup Manifest Generator',
|
||||
format: format
|
||||
},
|
||||
|
||||
// Backup information
|
||||
backup: {
|
||||
id: this.generateBackupId(),
|
||||
type: backupType,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: backupPath,
|
||||
parent_backup_id: parentBackupId,
|
||||
retention_days: customMetadata.retentionDays || 30
|
||||
},
|
||||
|
||||
// System information
|
||||
system: {
|
||||
hostname: this.hostname,
|
||||
platform: this.platform,
|
||||
os_release: this.osRelease,
|
||||
architecture: os.arch(),
|
||||
cpu_count: os.cpus().length,
|
||||
total_memory: os.totalmem(),
|
||||
free_memory: os.freemem(),
|
||||
uptime: os.uptime()
|
||||
},
|
||||
|
||||
// Application information
|
||||
application: {
|
||||
name: 'PicPeak',
|
||||
version: this.appVersion,
|
||||
node_version: this.nodeVersion,
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
storage_path: process.env.STORAGE_PATH || path.join(__dirname, '../../../storage')
|
||||
},
|
||||
|
||||
// Files information
|
||||
files: {
|
||||
count: files.length,
|
||||
total_size: files.reduce((sum, file) => sum + (file.size || 0), 0),
|
||||
checksums: await this.generateFileChecksums(files),
|
||||
manifest: files.map(file => ({
|
||||
path: file.relativePath || file.path,
|
||||
size: file.size,
|
||||
modified: file.modified,
|
||||
checksum: file.checksum,
|
||||
type: this.getFileType(file.path),
|
||||
permissions: file.permissions
|
||||
}))
|
||||
},
|
||||
|
||||
// Database information
|
||||
database: {
|
||||
type: databaseInfo.type || this.getDatabaseType(),
|
||||
backup_file: databaseInfo.backupFile,
|
||||
size: databaseInfo.size,
|
||||
checksum: databaseInfo.checksum,
|
||||
tables: databaseInfo.tables || {},
|
||||
row_counts: databaseInfo.rowCounts || {},
|
||||
schema_version: await this.getSchemaVersion()
|
||||
},
|
||||
|
||||
// Verification information
|
||||
verification: {
|
||||
total_checksum: null, // Will be calculated after manifest is complete
|
||||
file_count_check: files.length,
|
||||
size_check: files.reduce((sum, file) => sum + (file.size || 0), 0),
|
||||
integrity_timestamp: new Date().toISOString()
|
||||
},
|
||||
|
||||
// Custom metadata
|
||||
metadata: {
|
||||
...customMetadata,
|
||||
backup_settings: await this.getBackupSettings(),
|
||||
active_events_count: await this.getActiveEventsCount(),
|
||||
archived_events_count: await this.getArchivedEventsCount(),
|
||||
total_photos_count: await this.getTotalPhotosCount()
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate total checksum of the manifest
|
||||
manifest.verification.total_checksum = this.calculateManifestChecksum(manifest);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save manifest to file
|
||||
* @param {Object} manifest - Manifest object to save
|
||||
* @param {string} filePath - Path to save the manifest
|
||||
* @param {string} format - 'json' or 'yaml'
|
||||
*/
|
||||
async saveManifest(manifest, filePath, format = 'json') {
|
||||
try {
|
||||
let content;
|
||||
|
||||
if (format === 'yaml') {
|
||||
content = yaml.dump(manifest, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: true
|
||||
});
|
||||
} else {
|
||||
content = JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
logger.info(`Manifest saved to ${filePath} (format: ${format})`);
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
logger.error('Failed to save manifest:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate an existing manifest
|
||||
* @param {string} filePath - Path to the manifest file
|
||||
* @returns {Object} Loaded and validated manifest
|
||||
*/
|
||||
async loadManifest(filePath) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
let manifest;
|
||||
|
||||
// Detect format and parse
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
||||
manifest = yaml.load(content);
|
||||
} else {
|
||||
manifest = JSON.parse(content);
|
||||
}
|
||||
|
||||
// Validate manifest structure
|
||||
this.validateManifest(manifest);
|
||||
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load manifest:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate manifest structure and integrity
|
||||
* @param {Object} manifest - Manifest to validate
|
||||
* @throws {Error} If validation fails
|
||||
*/
|
||||
validateManifest(manifest) {
|
||||
// Check required sections
|
||||
const requiredSections = ['manifest', 'backup', 'system', 'application', 'files', 'database', 'verification'];
|
||||
for (const section of requiredSections) {
|
||||
if (!manifest[section]) {
|
||||
throw new Error(`Missing required section: ${section}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate manifest version
|
||||
if (!manifest.manifest.version) {
|
||||
throw new Error('Missing manifest version');
|
||||
}
|
||||
|
||||
// Validate file checksums
|
||||
if (manifest.files.count !== manifest.files.manifest.length) {
|
||||
throw new Error('File count mismatch');
|
||||
}
|
||||
|
||||
// Validate total checksum
|
||||
const calculatedChecksum = this.calculateManifestChecksum(manifest);
|
||||
if (manifest.verification.total_checksum !== calculatedChecksum) {
|
||||
throw new Error('Manifest checksum verification failed');
|
||||
}
|
||||
|
||||
logger.info('Manifest validation passed');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two manifests for incremental backup
|
||||
* @param {Object} currentManifest - Current backup manifest
|
||||
* @param {Object} parentManifest - Parent backup manifest
|
||||
* @returns {Object} Comparison results
|
||||
*/
|
||||
compareManifests(currentManifest, parentManifest) {
|
||||
const comparison = {
|
||||
added_files: [],
|
||||
modified_files: [],
|
||||
deleted_files: [],
|
||||
unchanged_files: [],
|
||||
size_difference: 0,
|
||||
database_changes: {}
|
||||
};
|
||||
|
||||
// Create file maps for easy comparison
|
||||
const currentFiles = new Map(
|
||||
currentManifest.files.manifest.map(f => [f.path, f])
|
||||
);
|
||||
const parentFiles = new Map(
|
||||
parentManifest.files.manifest.map(f => [f.path, f])
|
||||
);
|
||||
|
||||
// Find added and modified files
|
||||
for (const [path, file] of currentFiles) {
|
||||
const parentFile = parentFiles.get(path);
|
||||
if (!parentFile) {
|
||||
comparison.added_files.push(file);
|
||||
comparison.size_difference += file.size;
|
||||
} else if (file.checksum !== parentFile.checksum) {
|
||||
comparison.modified_files.push(file);
|
||||
comparison.size_difference += file.size - parentFile.size;
|
||||
} else {
|
||||
comparison.unchanged_files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted files
|
||||
for (const [path, file] of parentFiles) {
|
||||
if (!currentFiles.has(path)) {
|
||||
comparison.deleted_files.push(file);
|
||||
comparison.size_difference -= file.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare database info
|
||||
comparison.database_changes = {
|
||||
size_difference: currentManifest.database.size - parentManifest.database.size,
|
||||
checksum_changed: currentManifest.database.checksum !== parentManifest.database.checksum,
|
||||
schema_version_changed: currentManifest.database.schema_version !== parentManifest.database.schema_version
|
||||
};
|
||||
|
||||
return comparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate incremental manifest based on parent
|
||||
* @param {Object} options - Manifest generation options
|
||||
* @param {Object} parentManifest - Parent backup manifest
|
||||
* @returns {Object} Incremental manifest
|
||||
*/
|
||||
async generateIncrementalManifest(options, parentManifest) {
|
||||
const fullManifest = await this.generateManifest({
|
||||
...options,
|
||||
backupType: 'incremental'
|
||||
});
|
||||
|
||||
const comparison = this.compareManifests(fullManifest, parentManifest);
|
||||
|
||||
// Add incremental-specific information
|
||||
fullManifest.incremental = {
|
||||
parent_backup_id: parentManifest.backup.id,
|
||||
parent_timestamp: parentManifest.backup.timestamp,
|
||||
changes: {
|
||||
added_files_count: comparison.added_files.length,
|
||||
modified_files_count: comparison.modified_files.length,
|
||||
deleted_files_count: comparison.deleted_files.length,
|
||||
unchanged_files_count: comparison.unchanged_files.length,
|
||||
size_difference: comparison.size_difference
|
||||
},
|
||||
added_files: comparison.added_files.map(f => f.path),
|
||||
modified_files: comparison.modified_files.map(f => f.path),
|
||||
deleted_files: comparison.deleted_files.map(f => f.path)
|
||||
};
|
||||
|
||||
return fullManifest;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
generateBackupId() {
|
||||
const timestamp = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').split('.')[0];
|
||||
const random = crypto.randomBytes(4).toString('hex');
|
||||
return `backup-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
async generateFileChecksums(files) {
|
||||
const checksums = {};
|
||||
for (const file of files) {
|
||||
if (file.checksum) {
|
||||
checksums[file.relativePath || file.path] = file.checksum;
|
||||
}
|
||||
}
|
||||
return checksums;
|
||||
}
|
||||
|
||||
getFileType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const typeMap = {
|
||||
'.jpg': 'image',
|
||||
'.jpeg': 'image',
|
||||
'.png': 'image',
|
||||
'.gif': 'image',
|
||||
'.webp': 'image',
|
||||
'.zip': 'archive',
|
||||
'.sql': 'database',
|
||||
'.db': 'database',
|
||||
'.json': 'config',
|
||||
'.yaml': 'config',
|
||||
'.yml': 'config'
|
||||
};
|
||||
return typeMap[ext] || 'other';
|
||||
}
|
||||
|
||||
getDatabaseType() {
|
||||
return process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite';
|
||||
}
|
||||
|
||||
async getSchemaVersion() {
|
||||
try {
|
||||
const result = await db('migrations')
|
||||
.orderBy('run_at', 'desc')
|
||||
.first();
|
||||
return result ? result.migration_name : 'unknown';
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
async getBackupSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
try {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveEventsCount() {
|
||||
try {
|
||||
const result = await db('events')
|
||||
.where('status', 'active')
|
||||
.count('* as count')
|
||||
.first();
|
||||
return result ? parseInt(result.count) : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getArchivedEventsCount() {
|
||||
try {
|
||||
const result = await db('events')
|
||||
.where('status', 'archived')
|
||||
.count('* as count')
|
||||
.first();
|
||||
return result ? parseInt(result.count) : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalPhotosCount() {
|
||||
try {
|
||||
const result = await db('photos')
|
||||
.count('* as count')
|
||||
.first();
|
||||
return result ? parseInt(result.count) : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
calculateManifestChecksum(manifest) {
|
||||
// Create a copy without the checksum field
|
||||
const manifestCopy = JSON.parse(JSON.stringify(manifest));
|
||||
if (manifestCopy.verification) {
|
||||
delete manifestCopy.verification.total_checksum;
|
||||
}
|
||||
|
||||
// Calculate SHA256 of the sorted JSON
|
||||
const content = JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort());
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary report from a manifest
|
||||
* @param {Object} manifest - Manifest to summarize
|
||||
* @returns {string} Human-readable summary
|
||||
*/
|
||||
generateSummaryReport(manifest) {
|
||||
const report = [];
|
||||
|
||||
report.push('=== BACKUP MANIFEST SUMMARY ===');
|
||||
report.push(`Backup ID: ${manifest.backup.id}`);
|
||||
report.push(`Type: ${manifest.backup.type}`);
|
||||
report.push(`Created: ${manifest.backup.timestamp}`);
|
||||
|
||||
if (manifest.backup.parent_backup_id) {
|
||||
report.push(`Parent Backup: ${manifest.backup.parent_backup_id}`);
|
||||
}
|
||||
|
||||
report.push('\n--- System Information ---');
|
||||
report.push(`Host: ${manifest.system.hostname}`);
|
||||
report.push(`Platform: ${manifest.system.platform} ${manifest.system.os_release}`);
|
||||
report.push(`Architecture: ${manifest.system.architecture}`);
|
||||
|
||||
report.push('\n--- Application Information ---');
|
||||
report.push(`App Version: ${manifest.application.version}`);
|
||||
report.push(`Node Version: ${manifest.application.node_version}`);
|
||||
report.push(`Environment: ${manifest.application.environment}`);
|
||||
|
||||
report.push('\n--- Files Summary ---');
|
||||
report.push(`Total Files: ${manifest.files.count}`);
|
||||
report.push(`Total Size: ${(manifest.files.total_size / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
if (manifest.incremental) {
|
||||
report.push('\n--- Incremental Changes ---');
|
||||
report.push(`Added Files: ${manifest.incremental.changes.added_files_count}`);
|
||||
report.push(`Modified Files: ${manifest.incremental.changes.modified_files_count}`);
|
||||
report.push(`Deleted Files: ${manifest.incremental.changes.deleted_files_count}`);
|
||||
report.push(`Size Difference: ${(manifest.incremental.changes.size_difference / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
report.push('\n--- Database Information ---');
|
||||
report.push(`Type: ${manifest.database.type}`);
|
||||
report.push(`Size: ${manifest.database.size ? (manifest.database.size / 1024 / 1024).toFixed(2) + ' MB' : 'N/A'}`);
|
||||
report.push(`Schema Version: ${manifest.database.schema_version}`);
|
||||
|
||||
report.push('\n--- Content Statistics ---');
|
||||
report.push(`Active Events: ${manifest.metadata.active_events_count}`);
|
||||
report.push(`Archived Events: ${manifest.metadata.archived_events_count}`);
|
||||
report.push(`Total Photos: ${manifest.metadata.total_photos_count}`);
|
||||
|
||||
report.push('\n--- Verification ---');
|
||||
report.push(`Manifest Checksum: ${manifest.verification.total_checksum}`);
|
||||
report.push(`Integrity Timestamp: ${manifest.verification.integrity_timestamp}`);
|
||||
|
||||
return report.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
module.exports = new BackupManifestGenerator();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,720 @@
|
||||
const cron = require('node-cron');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const backupManifest = require('./backupManifest');
|
||||
|
||||
// Backup job reference
|
||||
let backupJob = null;
|
||||
let backupConfig = null;
|
||||
let isRunning = false;
|
||||
|
||||
// Storage paths
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Calculate file checksum using SHA256
|
||||
*/
|
||||
async function calculateChecksum(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = require('fs').createReadStream(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', data => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database backup information
|
||||
*/
|
||||
async function getDatabaseBackupInfo() {
|
||||
try {
|
||||
// Check for recent database backup
|
||||
const recentDbBackup = await db('database_backup_runs')
|
||||
.where('status', 'completed')
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (recentDbBackup && recentDbBackup.file_path) {
|
||||
return {
|
||||
type: recentDbBackup.backup_type,
|
||||
backupFile: recentDbBackup.file_path,
|
||||
size: recentDbBackup.file_size_bytes,
|
||||
checksum: recentDbBackup.checksum,
|
||||
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
|
||||
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
|
||||
backupFile: null,
|
||||
size: 0,
|
||||
checksum: null,
|
||||
tables: {},
|
||||
rowCounts: {}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get database backup info:', error);
|
||||
return {
|
||||
type: 'unknown',
|
||||
backupFile: null,
|
||||
size: 0,
|
||||
checksum: null,
|
||||
tables: {},
|
||||
rowCounts: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup configuration from database
|
||||
*/
|
||||
async function getBackupConfig() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
try {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup configuration:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files to backup
|
||||
*/
|
||||
async function getFilesToBackup(includeArchived = true) {
|
||||
const files = [];
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
try {
|
||||
// Active events
|
||||
const activePath = path.join(storagePath, 'events/active');
|
||||
await scanDirectory(activePath, files, storagePath);
|
||||
|
||||
// Archived events (if enabled)
|
||||
if (includeArchived) {
|
||||
const archivePath = path.join(storagePath, 'events/archived');
|
||||
await scanDirectory(archivePath, files, storagePath);
|
||||
}
|
||||
|
||||
// Thumbnails
|
||||
const thumbsPath = path.join(storagePath, 'thumbnails');
|
||||
await scanDirectory(thumbsPath, files, storagePath);
|
||||
|
||||
// Uploads (logos, favicons, etc.)
|
||||
const uploadsPath = path.join(storagePath, 'uploads');
|
||||
await scanDirectory(uploadsPath, files, storagePath);
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get files to backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for files
|
||||
*/
|
||||
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const relativePath = path.relative(basePath, fullPath);
|
||||
|
||||
// Check exclude patterns
|
||||
if (excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
|
||||
}
|
||||
return entry.name === pattern;
|
||||
})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
|
||||
} else if (entry.isFile()) {
|
||||
const stats = await fs.stat(fullPath);
|
||||
fileList.push({
|
||||
path: fullPath,
|
||||
relativePath: relativePath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
logger.error(`Failed to scan directory ${dirPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file has changed since last backup
|
||||
*/
|
||||
async function hasFileChanged(filePath, checksum) {
|
||||
try {
|
||||
const fileState = await db('backup_file_states')
|
||||
.where('file_path', filePath)
|
||||
.first();
|
||||
|
||||
return !fileState || fileState.checksum !== checksum;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check file state:', error);
|
||||
return true; // Assume changed if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file state in database
|
||||
*/
|
||||
async function updateFileState(filePath, checksum, size, modified) {
|
||||
try {
|
||||
const existing = await db('backup_file_states')
|
||||
.where('file_path', filePath)
|
||||
.first();
|
||||
|
||||
const data = {
|
||||
file_path: filePath,
|
||||
checksum: checksum,
|
||||
size_bytes: size,
|
||||
last_modified: modified,
|
||||
last_backed_up: new Date()
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('backup_file_states')
|
||||
.where('id', existing.id)
|
||||
.update(data);
|
||||
} else {
|
||||
await db('backup_file_states').insert(data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform local directory backup
|
||||
*/
|
||||
async function performLocalBackup(config, files) {
|
||||
const destPath = config.backup_destination_path;
|
||||
const storagePath = getStoragePath();
|
||||
let backedUpCount = 0;
|
||||
let backedUpSize = 0;
|
||||
const backedUpFiles = [];
|
||||
|
||||
// Ensure destination exists
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Skip large files if configured
|
||||
const maxSizeMB = config.backup_max_file_size_mb || 5000;
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
const checksum = await calculateChecksum(file.path);
|
||||
file.checksum = checksum; // Add checksum to file object
|
||||
|
||||
// Check if file has changed
|
||||
const changed = await hasFileChanged(file.relativePath, checksum);
|
||||
if (!changed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Copy file
|
||||
const destFilePath = path.join(destPath, file.relativePath);
|
||||
const destDir = path.dirname(destFilePath);
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await fs.copyFile(file.path, destFilePath);
|
||||
|
||||
// Update state
|
||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
||||
|
||||
backedUpCount++;
|
||||
backedUpSize += file.size;
|
||||
backedUpFiles.push(file.relativePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to backup file ${file.relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { backedUpCount, backedUpSize, backedUpFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform rsync backup
|
||||
*/
|
||||
async function performRsyncBackup(config, files) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const user = config.backup_rsync_user;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
const sshKey = config.backup_rsync_ssh_key;
|
||||
|
||||
if (!host || !remotePath) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
// Build rsync command
|
||||
const rsyncOptions = [
|
||||
'-avz', // archive, verbose, compress
|
||||
'--delete', // remove deleted files
|
||||
'--stats' // show statistics
|
||||
];
|
||||
|
||||
if (sshKey) {
|
||||
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
|
||||
}
|
||||
|
||||
// Add exclude patterns
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => {
|
||||
rsyncOptions.push(`--exclude="${pattern}"`);
|
||||
});
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
|
||||
|
||||
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(rsyncCommand);
|
||||
|
||||
// Parse rsync stats
|
||||
const stats = parseRsyncStats(stdout);
|
||||
|
||||
// Update file states for successfully synced files
|
||||
for (const file of files) {
|
||||
try {
|
||||
const checksum = await calculateChecksum(file.path);
|
||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update state for ${file.relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backedUpCount: stats.filesTransferred || files.length,
|
||||
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
|
||||
backedUpFiles: files.map(f => f.relativePath)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Rsync backup failed:', error);
|
||||
throw new Error(`Rsync backup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rsync statistics from output
|
||||
*/
|
||||
function parseRsyncStats(output) {
|
||||
const stats = {};
|
||||
|
||||
// Extract files transferred
|
||||
const filesMatch = output.match(/Number of files transferred: (\d+)/);
|
||||
if (filesMatch) {
|
||||
stats.filesTransferred = parseInt(filesMatch[1]);
|
||||
}
|
||||
|
||||
// Extract total size
|
||||
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
|
||||
if (sizeMatch) {
|
||||
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform S3-compatible backup
|
||||
*/
|
||||
async function performS3Backup(config, files) {
|
||||
// This would require AWS SDK or similar
|
||||
// For now, return a placeholder
|
||||
throw new Error('S3 backup not implemented yet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run backup process
|
||||
*/
|
||||
async function runBackup() {
|
||||
if (isRunning) {
|
||||
logger.warn('Backup already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
const startTime = new Date();
|
||||
let backupRun = null;
|
||||
|
||||
try {
|
||||
// Get current configuration
|
||||
const config = await getBackupConfig();
|
||||
if (!config.backup_enabled) {
|
||||
logger.info('Backup is disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup run record
|
||||
const [runId] = await db('backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: 'scheduled'
|
||||
});
|
||||
|
||||
backupRun = { id: runId };
|
||||
|
||||
// Get files to backup
|
||||
const files = await getFilesToBackup(config.backup_include_archived);
|
||||
logger.info(`Found ${files.length} files to check for backup`);
|
||||
|
||||
// Perform backup based on destination type
|
||||
let result;
|
||||
switch (config.backup_destination_type) {
|
||||
case 'local':
|
||||
result = await performLocalBackup(config, files);
|
||||
break;
|
||||
case 'rsync':
|
||||
result = await performRsyncBackup(config, files);
|
||||
break;
|
||||
case 's3':
|
||||
result = await performS3Backup(config, files);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
const endTime = new Date();
|
||||
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
||||
|
||||
// Generate backup manifest
|
||||
let manifestPath = null;
|
||||
try {
|
||||
logger.info('Generating backup manifest...');
|
||||
|
||||
// Get database backup info if available
|
||||
const databaseInfo = await getDatabaseBackupInfo();
|
||||
|
||||
// Determine if this is an incremental backup
|
||||
const lastSuccessfulBackup = await db('backup_runs')
|
||||
.where('status', 'completed')
|
||||
.whereNot('id', runId)
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first();
|
||||
|
||||
let manifest;
|
||||
const manifestOptions = {
|
||||
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
|
||||
backupPath: config.backup_destination_path || config.backup_destination_type,
|
||||
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
|
||||
databaseInfo: databaseInfo,
|
||||
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
|
||||
format: config.backup_manifest_format || 'json',
|
||||
customMetadata: {
|
||||
backup_run_id: runId,
|
||||
destination_type: config.backup_destination_type,
|
||||
operator: 'system',
|
||||
reason: 'scheduled',
|
||||
retentionDays: config.backup_retention_days || 30
|
||||
}
|
||||
};
|
||||
|
||||
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
|
||||
try {
|
||||
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
|
||||
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load parent manifest, generating full manifest:', error);
|
||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
||||
}
|
||||
} else {
|
||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
||||
}
|
||||
|
||||
// Save manifest
|
||||
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
|
||||
await fs.mkdir(manifestDir, { recursive: true });
|
||||
|
||||
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
|
||||
manifestPath = path.join(manifestDir, manifestFileName);
|
||||
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
|
||||
|
||||
logger.info(`Backup manifest saved to ${manifestPath}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate backup manifest:', error);
|
||||
// Don't fail the entire backup for manifest generation failure
|
||||
}
|
||||
|
||||
// Update backup run record
|
||||
await db('backup_runs')
|
||||
.where('id', runId)
|
||||
.update({
|
||||
completed_at: endTime,
|
||||
status: 'completed',
|
||||
files_backed_up: result.backedUpCount,
|
||||
total_size_bytes: result.backedUpSize,
|
||||
duration_seconds: durationSeconds,
|
||||
manifest_path: manifestPath,
|
||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||
statistics: JSON.stringify({
|
||||
totalFilesChecked: files.length,
|
||||
filesBackedUp: result.backedUpCount,
|
||||
totalSize: result.backedUpSize,
|
||||
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
manifestGenerated: !!manifestPath
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
|
||||
|
||||
// Send success email if configured
|
||||
if (config.backup_email_on_success) {
|
||||
// Get admin emails
|
||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
||||
for (const admin of admins) {
|
||||
await queueEmail(null, admin.email, 'backup_completed', {
|
||||
start_time: startTime.toISOString(),
|
||||
duration: `${durationSeconds} seconds`,
|
||||
files_count: result.backedUpCount.toString(),
|
||||
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
|
||||
backup_type: config.backup_destination_type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Backup failed:', error);
|
||||
|
||||
// Update backup run record
|
||||
if (backupRun) {
|
||||
await db('backup_runs')
|
||||
.where('id', backupRun.id)
|
||||
.update({
|
||||
completed_at: new Date(),
|
||||
status: 'failed',
|
||||
error_message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Send failure email
|
||||
const config = await getBackupConfig();
|
||||
if (config && config.backup_email_on_failure) {
|
||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
||||
for (const admin of admins) {
|
||||
await queueEmail(null, admin.email, 'backup_failed', {
|
||||
start_time: startTime.toISOString(),
|
||||
backup_type: config.backup_destination_type || 'unknown',
|
||||
error_message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start backup service
|
||||
*/
|
||||
async function startBackupService() {
|
||||
try {
|
||||
// Get configuration
|
||||
backupConfig = await getBackupConfig();
|
||||
|
||||
if (!backupConfig || !backupConfig.backup_enabled) {
|
||||
logger.info('Backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel existing job if any
|
||||
if (backupJob) {
|
||||
backupJob.stop();
|
||||
}
|
||||
|
||||
// Schedule backup job
|
||||
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
|
||||
backupJob = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled backup');
|
||||
await runBackup();
|
||||
});
|
||||
|
||||
logger.info(`Backup service started with schedule: ${schedule}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start backup service:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop backup service
|
||||
*/
|
||||
function stopBackupService() {
|
||||
if (backupJob) {
|
||||
backupJob.stop();
|
||||
backupJob = null;
|
||||
logger.info('Backup service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual backup
|
||||
*/
|
||||
async function triggerManualBackup() {
|
||||
logger.info('Starting manual backup');
|
||||
await runBackup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup status and history
|
||||
*/
|
||||
async function getBackupStatus(limit = 10) {
|
||||
try {
|
||||
const runs = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
const lastRun = runs[0];
|
||||
const isHealthy = lastRun && lastRun.status === 'completed';
|
||||
|
||||
// Validate manifest if exists
|
||||
let manifestValid = false;
|
||||
if (lastRun && lastRun.manifest_path) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
||||
backupManifest.validateManifest(manifest);
|
||||
manifestValid = true;
|
||||
} catch (error) {
|
||||
logger.warn('Manifest validation failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
isHealthy,
|
||||
lastRun: lastRun ? {
|
||||
...lastRun,
|
||||
manifestValid
|
||||
} : null,
|
||||
recentRuns: runs,
|
||||
nextScheduledRun: backupJob ? getNextScheduledRun() : null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup status:', error);
|
||||
return {
|
||||
isRunning,
|
||||
isHealthy: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next scheduled run time
|
||||
*/
|
||||
function getNextScheduledRun() {
|
||||
// This is a simplified version - would need proper cron parsing
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
|
||||
return tomorrow.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backup runs
|
||||
*/
|
||||
async function cleanupOldBackupRuns(retentionDays = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
const deleted = await db('backup_runs')
|
||||
.where('started_at', '<', cutoffDate)
|
||||
.delete();
|
||||
|
||||
if (deleted > 0) {
|
||||
logger.info(`Cleaned up ${deleted} old backup runs`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup old backup runs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup manifest for a specific backup run
|
||||
*/
|
||||
async function getBackupManifest(backupRunId) {
|
||||
try {
|
||||
const run = await db('backup_runs')
|
||||
.where('id', backupRunId)
|
||||
.first();
|
||||
|
||||
if (!run || !run.manifest_path) {
|
||||
throw new Error('Backup manifest not found');
|
||||
}
|
||||
|
||||
const manifest = await backupManifest.loadManifest(run.manifest_path);
|
||||
return {
|
||||
manifest,
|
||||
summary: backupManifest.generateSummaryReport(manifest)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a backup manifest file
|
||||
*/
|
||||
async function validateBackupManifest(manifestPath) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(manifestPath);
|
||||
backupManifest.validateManifest(manifest);
|
||||
return { valid: true, manifest };
|
||||
} catch (error) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
triggerManualBackup,
|
||||
getBackupStatus,
|
||||
runBackup,
|
||||
cleanupOldBackupRuns,
|
||||
getBackupManifest,
|
||||
validateBackupManifest
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Database Backup Service Usage Examples
|
||||
*
|
||||
* This service provides comprehensive database backup functionality
|
||||
* with support for both SQLite and PostgreSQL databases.
|
||||
*/
|
||||
|
||||
const { databaseBackupService } = require('./databaseBackup');
|
||||
|
||||
// Example 1: Manual backup with default settings
|
||||
async function manualBackup() {
|
||||
try {
|
||||
const result = await databaseBackupService.backup();
|
||||
console.log('Backup completed:', result);
|
||||
// Result includes: path, size, duration, checksum, compressionRatio
|
||||
} catch (error) {
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 2: Backup with custom options
|
||||
async function customBackup() {
|
||||
try {
|
||||
const result = await databaseBackupService.backup({
|
||||
destinationPath: '/custom/backup/path',
|
||||
compress: true, // Enable gzip compression
|
||||
validateIntegrity: true, // Validate backup after creation
|
||||
includeChecksums: true, // Calculate table checksums
|
||||
noTransaction: false // Use transaction for consistency (PostgreSQL)
|
||||
});
|
||||
console.log('Custom backup completed:', result);
|
||||
} catch (error) {
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Check backup progress (useful for long-running backups)
|
||||
async function backupWithProgress() {
|
||||
// Start backup asynchronously
|
||||
const backupPromise = databaseBackupService.backup();
|
||||
|
||||
// Poll for progress
|
||||
const progressInterval = setInterval(() => {
|
||||
const progress = databaseBackupService.getProgress();
|
||||
if (progress) {
|
||||
console.log(`Progress: ${progress.message}`, progress.details);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const result = await backupPromise;
|
||||
clearInterval(progressInterval);
|
||||
console.log('Backup completed:', result);
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
console.error('Backup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 4: Get backup history
|
||||
async function getBackupHistory() {
|
||||
const history = await databaseBackupService.getBackupHistory(10);
|
||||
|
||||
history.forEach(backup => {
|
||||
console.log(`Backup ${backup.id}:`);
|
||||
console.log(` Started: ${backup.started_at}`);
|
||||
console.log(` Status: ${backup.status}`);
|
||||
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Duration: ${backup.duration_seconds}s`);
|
||||
});
|
||||
}
|
||||
|
||||
// Example 5: Clean up old backups
|
||||
async function cleanupBackups() {
|
||||
// Delete backups older than 30 days
|
||||
await databaseBackupService.cleanupOldBackups(30);
|
||||
console.log('Old backups cleaned up');
|
||||
}
|
||||
|
||||
// Example 6: Get table checksums (useful for monitoring changes)
|
||||
async function getTableChecksums() {
|
||||
const checksums = await databaseBackupService.getTableChecksums();
|
||||
|
||||
console.log('Table Checksums:');
|
||||
Object.entries(checksums).forEach(([table, info]) => {
|
||||
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Example 7: Using the scheduled backup service
|
||||
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
|
||||
|
||||
async function setupScheduledBackups() {
|
||||
// Start scheduled backups (reads schedule from database config)
|
||||
await startScheduledBackups();
|
||||
console.log('Scheduled backups started');
|
||||
|
||||
// Later, if needed, stop scheduled backups
|
||||
// stopScheduledBackups();
|
||||
}
|
||||
|
||||
// Example 8: Admin API endpoints available
|
||||
/*
|
||||
GET /api/admin/database-backup/status - Get backup status and config
|
||||
PUT /api/admin/database-backup/config - Update backup configuration
|
||||
POST /api/admin/database-backup/backup - Trigger manual backup
|
||||
GET /api/admin/database-backup/progress - Get current backup progress
|
||||
GET /api/admin/database-backup/history - Get backup history with pagination
|
||||
DELETE /api/admin/database-backup/cleanup - Delete old backup files
|
||||
POST /api/admin/database-backup/test - Test backup configuration
|
||||
GET /api/admin/database-backup/checksums - Get current table checksums
|
||||
*/
|
||||
|
||||
// Example 9: Configuration options stored in database
|
||||
/*
|
||||
database_backup_enabled: boolean - Enable/disable scheduled backups
|
||||
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
|
||||
database_backup_destination_path: string - Where to store backups
|
||||
database_backup_compress: boolean - Enable gzip compression
|
||||
database_backup_validate_integrity: boolean - Validate after backup
|
||||
database_backup_include_checksums: boolean - Calculate table checksums
|
||||
database_backup_retention_days: number - Days to keep old backups
|
||||
database_backup_email_on_failure: boolean - Send email on failure
|
||||
database_backup_email_on_success: boolean - Send email on success
|
||||
*/
|
||||
|
||||
// Example 10: Production considerations
|
||||
/*
|
||||
1. Ensure destination path has sufficient space
|
||||
2. For large databases, backups may take significant time
|
||||
3. PostgreSQL backups use single-transaction mode by default
|
||||
4. Compression typically reduces size by 70-90%
|
||||
5. Schedule backups during low-traffic periods
|
||||
6. Monitor backup history for failures
|
||||
7. Test restore procedures regularly
|
||||
8. Consider replication for real-time redundancy
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
manualBackup,
|
||||
customBackup,
|
||||
backupWithProgress,
|
||||
getBackupHistory,
|
||||
cleanupBackups,
|
||||
getTableChecksums,
|
||||
setupScheduledBackups
|
||||
};
|
||||
@@ -0,0 +1,631 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Constants
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
|
||||
|
||||
/**
|
||||
* Database Backup Service
|
||||
* Supports both SQLite and PostgreSQL with proper escaping,
|
||||
* compression, checksums, and validation
|
||||
*/
|
||||
class DatabaseBackupService {
|
||||
constructor() {
|
||||
this.isRunning = false;
|
||||
this.currentProgress = null;
|
||||
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate checksum for a file
|
||||
*/
|
||||
async calculateChecksum(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', data => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a file using gzip
|
||||
*/
|
||||
async compressFile(inputPath, outputPath) {
|
||||
const gzip = zlib.createGzip({ level: 6 }); // Balanced compression
|
||||
const source = createReadStream(inputPath);
|
||||
const destination = createWriteStream(outputPath);
|
||||
|
||||
await pipeline(source, gzip, destination);
|
||||
|
||||
// Get compression ratio
|
||||
const inputStats = await fs.stat(inputPath);
|
||||
const outputStats = await fs.stat(outputPath);
|
||||
const ratio = (1 - outputStats.size / inputStats.size) * 100;
|
||||
|
||||
return {
|
||||
originalSize: inputStats.size,
|
||||
compressedSize: outputStats.size,
|
||||
compressionRatio: ratio.toFixed(2)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database size
|
||||
*/
|
||||
async getDatabaseSize() {
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
const stats = await fs.stat(dbPath);
|
||||
return stats.size;
|
||||
} else {
|
||||
// PostgreSQL
|
||||
const result = await db.raw(`
|
||||
SELECT pg_database_size(current_database()) as size
|
||||
`);
|
||||
return parseInt(result.rows[0].size);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table checksums for change detection
|
||||
*/
|
||||
async getTableChecksums() {
|
||||
const checksums = {};
|
||||
const tables = await this.getTables();
|
||||
|
||||
for (const table of tables) {
|
||||
if (this.dbType === 'sqlite') {
|
||||
// SQLite: Use aggregate of all row data
|
||||
const result = await db.raw(`
|
||||
SELECT
|
||||
COUNT(*) as row_count,
|
||||
COALESCE(SUM(LENGTH(CAST(t.* AS TEXT))), 0) as data_sum
|
||||
FROM "${table}" t
|
||||
`);
|
||||
|
||||
checksums[table] = {
|
||||
rowCount: result[0].row_count,
|
||||
checksum: crypto
|
||||
.createHash('md5')
|
||||
.update(`${result[0].row_count}-${result[0].data_sum}`)
|
||||
.digest('hex')
|
||||
};
|
||||
} else {
|
||||
// PostgreSQL: Use built-in functions
|
||||
const result = await db.raw(`
|
||||
SELECT
|
||||
COUNT(*) as row_count,
|
||||
MD5(COALESCE(STRING_AGG(MD5(t::text), ''), '')) as checksum
|
||||
FROM "${table}" t
|
||||
`);
|
||||
|
||||
checksums[table] = {
|
||||
rowCount: parseInt(result.rows[0].row_count),
|
||||
checksum: result.rows[0].checksum || 'empty'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return checksums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tables
|
||||
*/
|
||||
async getTables() {
|
||||
if (this.dbType === 'sqlite') {
|
||||
const result = await db.raw(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name != 'knex_migrations'
|
||||
AND name != 'knex_migrations_lock'
|
||||
ORDER BY name
|
||||
`);
|
||||
return result.map(row => row.name);
|
||||
} else {
|
||||
// PostgreSQL
|
||||
const result = await db.raw(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT IN ('knex_migrations', 'knex_migrations_lock')
|
||||
ORDER BY table_name
|
||||
`);
|
||||
return result.rows.map(row => row.table_name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SQLite backup
|
||||
*/
|
||||
async createSQLiteBackup(outputPath, options = {}) {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
const tempPath = `${outputPath}.tmp`;
|
||||
|
||||
try {
|
||||
// Use SQLite's backup API for consistency
|
||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
|
||||
|
||||
// Verify the backup
|
||||
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
|
||||
if (!verifyResult.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
|
||||
// Move temp file to final location
|
||||
await fs.rename(tempPath, outputPath);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// Cleanup temp file if exists
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PostgreSQL backup with proper escaping
|
||||
*/
|
||||
async createPostgreSQLBackup(outputPath, options = {}) {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
|
||||
// Build connection string with proper escaping
|
||||
const connectionParts = [
|
||||
`host=${host}`,
|
||||
`port=${port}`,
|
||||
`dbname=${database}`,
|
||||
`user=${user}`
|
||||
];
|
||||
|
||||
// Set PGPASSWORD environment variable for security
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
env.PGPASSWORD = password;
|
||||
}
|
||||
|
||||
// Build pg_dump command with options
|
||||
const pgDumpOptions = [
|
||||
'--verbose',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--format=plain',
|
||||
'--encoding=UTF8'
|
||||
];
|
||||
|
||||
// Add transaction support for consistency
|
||||
if (!options.noTransaction) {
|
||||
pgDumpOptions.push('--single-transaction');
|
||||
}
|
||||
|
||||
// Add compression if not doing it separately
|
||||
if (options.compress && !options.separateCompression) {
|
||||
pgDumpOptions.push('--compress=6');
|
||||
}
|
||||
|
||||
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
|
||||
|
||||
try {
|
||||
const { stderr } = await execAsync(command, {
|
||||
env,
|
||||
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
|
||||
});
|
||||
|
||||
// pg_dump writes progress to stderr, not an error
|
||||
if (stderr && !stderr.includes('dump complete')) {
|
||||
logger.warn('pg_dump warnings:', stderr);
|
||||
}
|
||||
|
||||
// Verify the dump file is not empty
|
||||
const stats = await fs.stat(outputPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Backup file is empty');
|
||||
}
|
||||
|
||||
return { success: true, warnings: stderr };
|
||||
} catch (error) {
|
||||
throw new Error(`PostgreSQL backup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup integrity
|
||||
*/
|
||||
async validateBackup(backupPath, originalChecksums) {
|
||||
const tempDbPath = `${backupPath}.validate`;
|
||||
|
||||
try {
|
||||
if (this.dbType === 'sqlite') {
|
||||
// For SQLite, we can directly check integrity
|
||||
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
|
||||
if (!result.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
} else {
|
||||
// For PostgreSQL, we'd need to restore to a temp database
|
||||
// This is more complex and might not be feasible in production
|
||||
logger.info('PostgreSQL backup validation would require restore test');
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} finally {
|
||||
// Cleanup
|
||||
try {
|
||||
await fs.unlink(tempDbPath);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main backup method
|
||||
*/
|
||||
async backup(options = {}) {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Backup already in progress');
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
const startTime = new Date();
|
||||
let backupRun = null;
|
||||
|
||||
try {
|
||||
// Get configuration
|
||||
const config = await this.getBackupConfig();
|
||||
const {
|
||||
destinationPath = '/backup/database',
|
||||
compress = true,
|
||||
validateIntegrity = true,
|
||||
includeChecksums = true
|
||||
} = { ...config, ...options };
|
||||
|
||||
// Create backup directory
|
||||
await fs.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
// Generate backup filename
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseName = `picpeak-db-${this.dbType}-${timestamp}`;
|
||||
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
|
||||
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
|
||||
|
||||
// Create backup run record
|
||||
const [runId] = await db('database_backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: this.dbType,
|
||||
destination_path: finalFile
|
||||
});
|
||||
|
||||
backupRun = { id: runId };
|
||||
|
||||
// Get initial checksums
|
||||
let tableChecksums = null;
|
||||
if (includeChecksums) {
|
||||
this.updateProgress('Calculating table checksums...');
|
||||
tableChecksums = await this.getTableChecksums();
|
||||
}
|
||||
|
||||
// Get database size
|
||||
const dbSize = await this.getDatabaseSize();
|
||||
|
||||
// Create the backup
|
||||
this.updateProgress('Creating database backup...');
|
||||
if (this.dbType === 'sqlite') {
|
||||
await this.createSQLiteBackup(sqlFile, options);
|
||||
} else {
|
||||
await this.createPostgreSQLBackup(sqlFile, options);
|
||||
}
|
||||
|
||||
// Compress if requested
|
||||
let compressionStats = null;
|
||||
if (compress) {
|
||||
this.updateProgress('Compressing backup...');
|
||||
compressionStats = await this.compressFile(sqlFile, finalFile);
|
||||
await fs.unlink(sqlFile); // Remove uncompressed file
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
this.updateProgress('Calculating backup checksum...');
|
||||
const backupChecksum = await this.calculateChecksum(finalFile);
|
||||
|
||||
// Validate if requested
|
||||
if (validateIntegrity && !compress) {
|
||||
this.updateProgress('Validating backup integrity...');
|
||||
await this.validateBackup(finalFile, tableChecksums);
|
||||
}
|
||||
|
||||
// Get final file size
|
||||
const finalStats = await fs.stat(finalFile);
|
||||
|
||||
// Calculate duration
|
||||
const endTime = new Date();
|
||||
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
||||
|
||||
// Update backup run record
|
||||
await db('database_backup_runs')
|
||||
.where('id', runId)
|
||||
.update({
|
||||
completed_at: endTime,
|
||||
status: 'completed',
|
||||
file_path: finalFile,
|
||||
file_size_bytes: finalStats.size,
|
||||
original_size_bytes: dbSize,
|
||||
duration_seconds: durationSeconds,
|
||||
checksum: backupChecksum,
|
||||
compression_ratio: compressionStats?.compressionRatio || null,
|
||||
table_checksums: tableChecksums ? JSON.stringify(tableChecksums) : null,
|
||||
statistics: JSON.stringify({
|
||||
dbType: this.dbType,
|
||||
compressed: compress,
|
||||
validated: validateIntegrity,
|
||||
compressionStats,
|
||||
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
|
||||
|
||||
// Send success notification if configured
|
||||
if (config.emailOnSuccess) {
|
||||
await this.sendBackupNotification('success', {
|
||||
duration: durationSeconds,
|
||||
size: finalStats.size,
|
||||
compressionRatio: compressionStats?.compressionRatio,
|
||||
path: finalFile
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: finalFile,
|
||||
size: finalStats.size,
|
||||
duration: durationSeconds,
|
||||
checksum: backupChecksum,
|
||||
compressionRatio: compressionStats?.compressionRatio
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Database backup failed:', error);
|
||||
|
||||
// Update backup run record
|
||||
if (backupRun) {
|
||||
await db('database_backup_runs')
|
||||
.where('id', backupRun.id)
|
||||
.update({
|
||||
completed_at: new Date(),
|
||||
status: 'failed',
|
||||
error_message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Send failure notification
|
||||
const config = await this.getBackupConfig();
|
||||
if (config.emailOnFailure) {
|
||||
await this.sendBackupNotification('failure', {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
this.currentProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress
|
||||
*/
|
||||
updateProgress(message, details = {}) {
|
||||
this.currentProgress = {
|
||||
message,
|
||||
details,
|
||||
timestamp: new Date()
|
||||
};
|
||||
logger.info(`Backup progress: ${message}`, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current progress
|
||||
*/
|
||||
getProgress() {
|
||||
return this.currentProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup configuration
|
||||
*/
|
||||
async getBackupConfig() {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'database_backup')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
try {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send backup notification email
|
||||
*/
|
||||
async sendBackupNotification(type, details) {
|
||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
||||
|
||||
for (const admin of admins) {
|
||||
if (type === 'success') {
|
||||
await queueEmail(null, admin.email, 'database_backup_completed', {
|
||||
backup_type: this.dbType,
|
||||
duration: `${details.duration} seconds`,
|
||||
file_size: `${(details.size / 1024 / 1024).toFixed(2)} MB`,
|
||||
compression_ratio: details.compressionRatio ? `${details.compressionRatio}%` : 'N/A',
|
||||
file_path: details.path
|
||||
});
|
||||
} else {
|
||||
await queueEmail(null, admin.email, 'database_backup_failed', {
|
||||
backup_type: this.dbType,
|
||||
error_message: details.error,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backups
|
||||
*/
|
||||
async cleanupOldBackups(retentionDays = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
// Get old backup records
|
||||
const oldBackups = await db('database_backup_runs')
|
||||
.where('completed_at', '<', cutoffDate)
|
||||
.where('status', 'completed')
|
||||
.select('id', 'file_path');
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const backup of oldBackups) {
|
||||
try {
|
||||
// Delete the file
|
||||
if (backup.file_path) {
|
||||
await fs.unlink(backup.file_path);
|
||||
}
|
||||
|
||||
// Delete the record
|
||||
await db('database_backup_runs')
|
||||
.where('id', backup.id)
|
||||
.delete();
|
||||
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete old backup ${backup.file_path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`Cleaned up ${deletedCount} old database backups`);
|
||||
}
|
||||
|
||||
// Also clean up old failed runs
|
||||
await db('database_backup_runs')
|
||||
.where('started_at', '<', cutoffDate)
|
||||
.where('status', 'failed')
|
||||
.delete();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup old database backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup history
|
||||
*/
|
||||
async getBackupHistory(limit = 10) {
|
||||
return await db('database_backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from backup (careful!)
|
||||
*/
|
||||
async restore(backupPath, options = {}) {
|
||||
// This is a dangerous operation and should be used with extreme caution
|
||||
throw new Error('Restore functionality not implemented for safety. Please restore manually.');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const databaseBackupService = new DatabaseBackupService();
|
||||
|
||||
// Scheduled backup runner
|
||||
let backupSchedule = null;
|
||||
|
||||
/**
|
||||
* Start scheduled database backups
|
||||
*/
|
||||
async function startScheduledBackups() {
|
||||
const cron = require('node-cron');
|
||||
|
||||
try {
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
logger.info('Database backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop existing schedule
|
||||
if (backupSchedule) {
|
||||
backupSchedule.stop();
|
||||
}
|
||||
|
||||
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
|
||||
const schedule = config.schedule || '0 3 * * *';
|
||||
|
||||
backupSchedule = cron.schedule(schedule, async () => {
|
||||
logger.info('Starting scheduled database backup');
|
||||
try {
|
||||
await databaseBackupService.backup();
|
||||
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
|
||||
} catch (error) {
|
||||
logger.error('Scheduled database backup failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Database backup service started with schedule: ${schedule}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start database backup service:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop scheduled database backups
|
||||
*/
|
||||
function stopScheduledBackups() {
|
||||
if (backupSchedule) {
|
||||
backupSchedule.stop();
|
||||
backupSchedule = null;
|
||||
logger.info('Database backup service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
databaseBackupService,
|
||||
startScheduledBackups,
|
||||
stopScheduledBackups,
|
||||
DatabaseBackupService // Export class for testing
|
||||
};
|
||||
@@ -1,11 +1,20 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
|
||||
// Generate hash from config for change detection
|
||||
function generateConfigHash(config) {
|
||||
const crypto = require('crypto');
|
||||
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
|
||||
return crypto.createHash('md5').update(configString).digest('hex');
|
||||
}
|
||||
|
||||
// Initialize transporter from database config
|
||||
async function initializeTransporter() {
|
||||
async function initializeTransporter(forceReinit = false) {
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
|
||||
@@ -14,6 +23,16 @@ async function initializeTransporter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if configuration has changed
|
||||
const currentConfigHash = generateConfigHash(config);
|
||||
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
|
||||
// Configuration hasn't changed, return existing transporter
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Configuration has changed or first initialization
|
||||
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp_host,
|
||||
port: config.smtp_port,
|
||||
@@ -28,29 +47,72 @@ async function initializeTransporter() {
|
||||
await transporter.verify();
|
||||
logger.info('Email transporter initialized successfully');
|
||||
|
||||
// Update the config hash
|
||||
lastConfigHash = currentConfigHash;
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize email transporter:', error);
|
||||
transporter = null;
|
||||
lastConfigHash = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the appropriate language for a recipient
|
||||
async function getRecipientLanguage(email) {
|
||||
// For now, check if the email domain ends with .de
|
||||
// In the future, this could check user preferences
|
||||
if (email && email.endsWith('.de')) {
|
||||
return 'de';
|
||||
async function getRecipientLanguage(email, eventId = null) {
|
||||
// First priority: Check event language setting if eventId is provided
|
||||
if (eventId) {
|
||||
try {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (event && event.language) {
|
||||
return event.language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event language:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a saved preference for this email
|
||||
// This could be expanded to check user preferences in the database
|
||||
// Second priority: Check app_settings for general default language
|
||||
try {
|
||||
const langSetting = await db('app_settings')
|
||||
.where('setting_key', 'general_default_language')
|
||||
.first();
|
||||
if (langSetting && langSetting.setting_value) {
|
||||
return langSetting.setting_value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching app settings language:', error);
|
||||
}
|
||||
|
||||
// Third priority: Check email configs for default language
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig && emailConfig.default_language) {
|
||||
return emailConfig.default_language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
@@ -60,6 +122,32 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
let logoUrl = '';
|
||||
@@ -93,27 +181,15 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||
|
||||
// Process welcome message section if present
|
||||
let welcomeMessageSection = '';
|
||||
if (variables.welcome_message && variables.welcome_message.trim() !== '') {
|
||||
const welcomeTitle = language === 'de' ? 'Persönliche Nachricht:' : 'Personal Message:';
|
||||
welcomeMessageSection = `
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">${welcomeTitle}</p>
|
||||
<p style="margin: 0; color: #4b5563;">${variables.welcome_message}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Replace variables
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
subject = subject.replace(regex, value || '');
|
||||
htmlBody = htmlBody.replace(regex, value || '');
|
||||
textBody = textBody.replace(regex, value || '');
|
||||
});
|
||||
|
||||
// Replace welcome message section placeholder
|
||||
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
@@ -256,11 +332,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
// Send email using template
|
||||
async function sendTemplateEmail(to, templateKey, variables) {
|
||||
try {
|
||||
// Always check for configuration changes before sending
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
// Get email template
|
||||
@@ -278,8 +353,8 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language
|
||||
const language = await getRecipientLanguage(to);
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
@@ -303,14 +378,33 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
try {
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
logger.info('Transporter not initialized, attempting to initialize...');
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,7 +412,9 @@ async function processEmailQueue() {
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = JSON.parse(email.email_data || '{}');
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data || '{}')
|
||||
: email.email_data || {};
|
||||
|
||||
await sendTemplateEmail(
|
||||
email.recipient_email,
|
||||
@@ -337,13 +433,24 @@ async function processEmailQueue() {
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
// Increment retry count
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message,
|
||||
updated_at: new Date()
|
||||
});
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
|
||||
// If update fails due to column issue, try without any potential auto-added fields
|
||||
if (updateError.message && updateError.message.includes('updated_at')) {
|
||||
logger.warn('Detected updated_at column issue, attempting raw query...');
|
||||
await db.raw(
|
||||
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
|
||||
[email.retry_count + 1, error.message, email.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to send email ${email.id}:`, error);
|
||||
}
|
||||
@@ -356,6 +463,8 @@ async function processEmailQueue() {
|
||||
// Queue an email for sending
|
||||
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
try {
|
||||
// Add eventId to emailData for language detection
|
||||
emailData.eventId = eventId;
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: recipientEmail,
|
||||
@@ -373,17 +482,45 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test email connection
|
||||
async function testEmailConnection() {
|
||||
try {
|
||||
if (!transporter) {
|
||||
await initializeTransporter();
|
||||
}
|
||||
if (!transporter) {
|
||||
return false;
|
||||
}
|
||||
await transporter.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Email connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start email queue processor
|
||||
let emailQueueInterval = null;
|
||||
|
||||
function startEmailQueueProcessor() {
|
||||
logger.info('Email queue processor: Attempting to start...');
|
||||
|
||||
if (!emailQueueInterval) {
|
||||
// Process immediately on start
|
||||
processEmailQueue();
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Initial processing failed:', err);
|
||||
});
|
||||
|
||||
// Then process every minute
|
||||
emailQueueInterval = setInterval(processEmailQueue, 60000);
|
||||
logger.info('Email queue processor started');
|
||||
emailQueueInterval = setInterval(() => {
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Periodic processing failed:', err);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
logger.info('Email queue processor started successfully');
|
||||
} else {
|
||||
logger.info('Email queue processor: Already running');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +544,6 @@ module.exports = {
|
||||
sendTemplateEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
startEmailQueueProcessor,
|
||||
stopEmailQueueProcessor
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -51,6 +51,13 @@ async function processNewPhoto(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
|
||||
// Skip temporary upload files
|
||||
const filename = path.basename(filePath);
|
||||
if (filename.startsWith('temp_')) {
|
||||
logger.debug(`Skipping temporary upload file: ${filename}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the event
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Configure sharp for better memory management with large batches
|
||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||
@@ -10,7 +11,7 @@ const THUMBNAIL_WIDTH = 300;
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||
|
||||
async function generateThumbnail(imagePath) {
|
||||
async function generateThumbnail(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const thumbnailFilename = `thumb_${filename}`;
|
||||
const thumbnailDir = getThumbnailPath();
|
||||
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
|
||||
// Ensure thumbnail directory exists
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
|
||||
// Check if we need to regenerate (for broken thumbnails)
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
|
||||
} catch (err) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate thumbnail with memory-efficient settings
|
||||
// First, verify the source image is complete and valid
|
||||
const metadata = await sharp(imagePath).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image metadata - file may be incomplete');
|
||||
}
|
||||
|
||||
// Generate thumbnail with memory-efficient settings and error handling
|
||||
await sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true // More memory efficient for large images
|
||||
sequentialRead: true, // More memory efficient for large images
|
||||
failOnError: false // Don't fail on minor issues
|
||||
})
|
||||
.resize(THUMBNAIL_WIDTH, null, {
|
||||
withoutEnlargement: true,
|
||||
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
|
||||
})
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
// Verify the thumbnail was created successfully
|
||||
const stats = await fs.stat(thumbnailPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Generated thumbnail is empty');
|
||||
}
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${filename}:`, error);
|
||||
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
|
||||
|
||||
// Clean up any partially created file
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch (unlinkErr) {
|
||||
// Ignore unlink errors
|
||||
}
|
||||
|
||||
// Return null if thumbnail generation fails, don't fail the whole upload
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail };
|
||||
/**
|
||||
* Check if a thumbnail exists and is valid
|
||||
*/
|
||||
async function isThumbnailValid(thumbnailPath) {
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), thumbnailPath);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
// Check if file exists and has content
|
||||
if (stats.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to read metadata to ensure it's a valid image
|
||||
await sharp(fullPath).metadata();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate thumbnail if it's broken or missing
|
||||
*/
|
||||
async function ensureThumbnail(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if thumbnail exists and is valid
|
||||
if (photo.thumbnail_path) {
|
||||
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||
if (isValid) {
|
||||
return photo.thumbnail_path;
|
||||
}
|
||||
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
// Generate new thumbnail
|
||||
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
if (newThumbnailPath) {
|
||||
// Update database with new thumbnail path
|
||||
const { db } = require('../database/db');
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({ thumbnail_path: newThumbnailPath });
|
||||
|
||||
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||
return newThumbnailPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache for rate limit settings
|
||||
let settingsCache = null;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_DURATION = 60000; // 1 minute cache
|
||||
|
||||
/**
|
||||
* Get rate limit settings from database with caching
|
||||
*/
|
||||
async function getRateLimitSettings() {
|
||||
try {
|
||||
// Check cache
|
||||
if (settingsCache && Date.now() < cacheExpiry) {
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'rate_limit_enabled',
|
||||
'rate_limit_window_minutes',
|
||||
'rate_limit_max_requests',
|
||||
'rate_limit_auth_max_requests',
|
||||
'rate_limit_skip_authenticated',
|
||||
'rate_limit_public_endpoints_only'
|
||||
]);
|
||||
|
||||
// Parse settings into object
|
||||
const config = {
|
||||
enabled: true,
|
||||
windowMinutes: 15,
|
||||
maxRequests: 100,
|
||||
authMaxRequests: 5,
|
||||
skipAuthenticated: true,
|
||||
publicEndpointsOnly: false
|
||||
};
|
||||
|
||||
settings.forEach(setting => {
|
||||
const value = JSON.parse(setting.setting_value);
|
||||
switch (setting.setting_key) {
|
||||
case 'rate_limit_enabled':
|
||||
config.enabled = value;
|
||||
break;
|
||||
case 'rate_limit_window_minutes':
|
||||
config.windowMinutes = value;
|
||||
break;
|
||||
case 'rate_limit_max_requests':
|
||||
config.maxRequests = value;
|
||||
break;
|
||||
case 'rate_limit_auth_max_requests':
|
||||
config.authMaxRequests = value;
|
||||
break;
|
||||
case 'rate_limit_skip_authenticated':
|
||||
config.skipAuthenticated = value;
|
||||
break;
|
||||
case 'rate_limit_public_endpoints_only':
|
||||
config.publicEndpointsOnly = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Update cache
|
||||
settingsCache = config;
|
||||
cacheExpiry = Date.now() + CACHE_DURATION;
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch rate limit settings:', error);
|
||||
// Return defaults on error
|
||||
return {
|
||||
enabled: true,
|
||||
windowMinutes: 15,
|
||||
maxRequests: 100,
|
||||
authMaxRequests: 5,
|
||||
skipAuthenticated: true,
|
||||
publicEndpointsOnly: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear settings cache (call when settings are updated)
|
||||
*/
|
||||
function clearSettingsCache() {
|
||||
settingsCache = null;
|
||||
cacheExpiry = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request has valid authentication
|
||||
*/
|
||||
function isAuthenticated(req) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if token is valid
|
||||
if (!decoded || typeof decoded !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid token found - check type
|
||||
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||
req.tokenPayload = decoded;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if rate limiting should be applied to this request
|
||||
*/
|
||||
function shouldSkipRateLimit(req, config) {
|
||||
// If rate limiting is disabled globally
|
||||
if (!config.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Never skip rate limiting for auth endpoints
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
if (isAuthEndpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we should skip authenticated requests
|
||||
if (config.skipAuthenticated && isAuthenticated(req)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we only rate limit public endpoints
|
||||
if (config.publicEndpointsOnly) {
|
||||
const isPublicEndpoint = req.path.startsWith('/api/public/') ||
|
||||
req.path.startsWith('/api/gallery/') ||
|
||||
isAuthEndpoint;
|
||||
return !isPublicEndpoint;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dynamic rate limiter
|
||||
*/
|
||||
async function createRateLimiter() {
|
||||
const config = await getRateLimitSettings();
|
||||
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: async (req) => {
|
||||
// Refresh config for each request
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
|
||||
// Different limits for auth endpoints
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
skip: async (req) => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
authenticated: isAuthenticated(req),
|
||||
tokenType: req.tokenType,
|
||||
userAgent: req.headers['user-agent'],
|
||||
referer: req.headers['referer'],
|
||||
origin: req.headers['origin'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip']
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Too many requests, please try again later.',
|
||||
retryAfter: res.getHeader('Retry-After')
|
||||
});
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in headers
|
||||
legacyHeaders: false, // Disable X-RateLimit headers
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth-specific rate limiter
|
||||
*/
|
||||
async function createAuthRateLimiter() {
|
||||
const config = await getRateLimitSettings();
|
||||
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: config.authMaxRequests,
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
skip: async () => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip']
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Too many authentication attempts, please try again later.',
|
||||
retryAfter: res.getHeader('Retry-After')
|
||||
});
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRateLimitSettings,
|
||||
clearSettingsCache,
|
||||
createRateLimiter,
|
||||
createAuthRateLimiter,
|
||||
isAuthenticated,
|
||||
shouldSkipRateLimit
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
const S3StorageAdapter = require('../s3Storage');
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs');
|
||||
const stream = require('stream');
|
||||
|
||||
// Mock AWS SDK
|
||||
jest.mock('@aws-sdk/client-s3');
|
||||
jest.mock('@aws-sdk/lib-storage');
|
||||
jest.mock('@aws-sdk/s3-request-presigner');
|
||||
|
||||
describe('S3StorageAdapter', () => {
|
||||
let mockS3Client;
|
||||
let mockSend;
|
||||
let s3Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock S3Client
|
||||
mockSend = jest.fn();
|
||||
mockS3Client = {
|
||||
send: mockSend
|
||||
};
|
||||
S3Client.mockImplementation(() => mockS3Client);
|
||||
|
||||
// Create adapter instance
|
||||
s3Storage = new S3StorageAdapter({
|
||||
bucket: 'test-bucket',
|
||||
region: 'us-east-1',
|
||||
accessKeyId: 'test-key',
|
||||
secretAccessKey: 'test-secret'
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with required config', () => {
|
||||
expect(s3Storage.bucket).toBe('test-bucket');
|
||||
expect(s3Storage.config.region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('should throw error if bucket is not provided', () => {
|
||||
expect(() => {
|
||||
new S3StorageAdapter({ region: 'us-east-1' });
|
||||
}).toThrow('S3 bucket name is required');
|
||||
});
|
||||
|
||||
it('should configure for MinIO with path style', () => {
|
||||
const minioStorage = new S3StorageAdapter({
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'http://localhost:9000',
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false
|
||||
});
|
||||
|
||||
expect(S3Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
endpoint: 'http://localhost:9000',
|
||||
forcePathStyle: true
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should successfully test connection', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
const result = await s3Storage.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on connection failure', async () => {
|
||||
mockSend.mockRejectedValueOnce(new Error('Access Denied'));
|
||||
|
||||
await expect(s3Storage.testConnection()).rejects.toThrow('S3 connection test failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
let mockUpload;
|
||||
let mockDone;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDone = jest.fn().mockResolvedValue({
|
||||
Location: 'https://test-bucket.s3.amazonaws.com/test-key',
|
||||
ETag: '"test-etag"'
|
||||
});
|
||||
|
||||
mockUpload = {
|
||||
on: jest.fn().mockReturnThis(),
|
||||
done: mockDone
|
||||
};
|
||||
|
||||
Upload.mockImplementation(() => mockUpload);
|
||||
|
||||
// Mock fs.stat
|
||||
jest.spyOn(fs.promises, 'stat').mockResolvedValue({
|
||||
size: 1024
|
||||
});
|
||||
|
||||
// Mock fs.createReadStream
|
||||
jest.spyOn(fs, 'createReadStream').mockReturnValue(new stream.Readable());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should upload file successfully', async () => {
|
||||
const result = await s3Storage.upload('/path/to/file.jpg', 'test-key');
|
||||
|
||||
expect(result.Location).toBe('https://test-bucket.s3.amazonaws.com/test-key');
|
||||
expect(Upload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
client: mockS3Client,
|
||||
params: expect.objectContaining({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'test-key',
|
||||
ContentType: 'application/octet-stream'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should track upload progress', async () => {
|
||||
const onProgress = jest.fn();
|
||||
let progressCallback;
|
||||
|
||||
mockUpload.on.mockImplementation((event, callback) => {
|
||||
if (event === 'httpUploadProgress') {
|
||||
progressCallback = callback;
|
||||
}
|
||||
return mockUpload;
|
||||
});
|
||||
|
||||
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
||||
onProgress
|
||||
});
|
||||
|
||||
// Simulate progress
|
||||
progressCallback({ loaded: 512, total: 1024 });
|
||||
|
||||
await uploadPromise;
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
||||
});
|
||||
|
||||
it('should emit upload events', async () => {
|
||||
const uploadStartSpy = jest.fn();
|
||||
const uploadCompleteSpy = jest.fn();
|
||||
|
||||
s3Storage.on('uploadStart', uploadStartSpy);
|
||||
s3Storage.on('uploadComplete', uploadCompleteSpy);
|
||||
|
||||
await s3Storage.upload('/path/to/file.jpg', 'test-key');
|
||||
|
||||
expect(uploadStartSpy).toHaveBeenCalledWith({ key: 'test-key', size: 1024 });
|
||||
expect(uploadCompleteSpy).toHaveBeenCalledWith({
|
||||
key: 'test-key',
|
||||
location: 'https://test-bucket.s3.amazonaws.com/test-key'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists', () => {
|
||||
it('should return true if object exists', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
const result = await s3Storage.exists('test-key');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket', Key: 'test-key' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if object does not exist', async () => {
|
||||
const error = new Error('Not Found');
|
||||
error.name = 'NotFound';
|
||||
error.$metadata = { httpStatusCode: 404 };
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await s3Storage.exists('test-key');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateKey', () => {
|
||||
it('should generate unique key with timestamp and random string', () => {
|
||||
const key = s3Storage.generateKey('photo.jpg');
|
||||
|
||||
expect(key).toMatch(/^\d+_[a-f0-9]{16}_photo\.jpg$/);
|
||||
});
|
||||
|
||||
it('should add prefix if provided', () => {
|
||||
const key = s3Storage.generateKey('photo.jpg', 'events/wedding');
|
||||
|
||||
expect(key).toMatch(/^events\/wedding\/\d+_[a-f0-9]{16}_photo\.jpg$/);
|
||||
});
|
||||
|
||||
it('should sanitize filename', () => {
|
||||
const key = s3Storage.generateKey('my photo (1).jpg');
|
||||
|
||||
expect(key).toMatch(/^\d+_[a-f0-9]{16}_my_photo__1_\.jpg$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry logic', () => {
|
||||
it('should retry on retryable errors', async () => {
|
||||
const retryableError = new Error('Connection reset');
|
||||
retryableError.code = 'ECONNRESET';
|
||||
|
||||
// First attempt fails, second succeeds
|
||||
mockSend
|
||||
.mockRejectedValueOnce(retryableError)
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
|
||||
// Advance timers
|
||||
jest.runAllTimers();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const nonRetryableError = new Error('Invalid credentials');
|
||||
nonRetryableError.code = 'InvalidCredentials';
|
||||
|
||||
mockSend.mockRejectedValueOnce(nonRetryableError);
|
||||
|
||||
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop retrying after max attempts', async () => {
|
||||
const retryableError = new Error('Service unavailable');
|
||||
retryableError.code = 'ServiceUnavailable';
|
||||
|
||||
mockSend.mockRejectedValue(retryableError);
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
|
||||
// Advance timers for all retries
|
||||
for (let i = 0; i < 4; i++) {
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
await expect(promise).rejects.toThrow('Service unavailable');
|
||||
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should calculate storage statistics', async () => {
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'file1.jpg', Size: 1024 },
|
||||
{ Key: 'file2.jpg', Size: 2048 }
|
||||
],
|
||||
NextContinuationToken: 'token123'
|
||||
}).mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'file3.jpg', Size: 3072 }
|
||||
]
|
||||
});
|
||||
|
||||
const stats = await s3Storage.getStats('events/');
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalSize: 6144,
|
||||
totalCount: 3,
|
||||
totalSizeFormatted: '6 KB'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('_formatBytes', () => {
|
||||
it('should format bytes correctly', () => {
|
||||
expect(s3Storage._formatBytes(0)).toBe('0 Bytes');
|
||||
expect(s3Storage._formatBytes(1024)).toBe('1 KB');
|
||||
expect(s3Storage._formatBytes(1048576)).toBe('1 MB');
|
||||
expect(s3Storage._formatBytes(1073741824)).toBe('1 GB');
|
||||
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Example usage of the S3StorageAdapter
|
||||
*
|
||||
* This file demonstrates how to use the S3 storage adapter for various operations
|
||||
*/
|
||||
|
||||
const S3StorageAdapter = require('./s3Storage');
|
||||
|
||||
// Example 1: Basic AWS S3 Configuration
|
||||
const s3Storage = new S3StorageAdapter({
|
||||
bucket: 'my-photo-bucket',
|
||||
region: 'us-east-1',
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
// Example 2: MinIO Configuration (S3-compatible)
|
||||
const minioStorage = new S3StorageAdapter({
|
||||
bucket: 'photo-storage',
|
||||
endpoint: 'http://localhost:9000', // MinIO endpoint
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
sslEnabled: false // For local development
|
||||
});
|
||||
|
||||
// Example 3: DigitalOcean Spaces Configuration
|
||||
const spacesStorage = new S3StorageAdapter({
|
||||
bucket: 'my-space-name',
|
||||
endpoint: 'https://nyc3.digitaloceanspaces.com',
|
||||
region: 'nyc3',
|
||||
accessKeyId: process.env.DO_SPACES_KEY,
|
||||
secretAccessKey: process.env.DO_SPACES_SECRET
|
||||
});
|
||||
|
||||
// Usage Examples
|
||||
async function examples() {
|
||||
try {
|
||||
// Test connection
|
||||
await s3Storage.testConnection();
|
||||
console.log('Connection successful!');
|
||||
|
||||
// Upload a file with progress tracking
|
||||
const uploadResult = await s3Storage.upload(
|
||||
'/path/to/local/photo.jpg',
|
||||
'events/wedding-2024/photo.jpg',
|
||||
{
|
||||
contentType: 'image/jpeg',
|
||||
metadata: {
|
||||
event: 'wedding-2024',
|
||||
photographer: 'John Doe'
|
||||
},
|
||||
onProgress: (loaded, total) => {
|
||||
const percentage = Math.round((loaded / total) * 100);
|
||||
console.log(`Upload progress: ${percentage}%`);
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('Upload complete:', uploadResult.Location);
|
||||
|
||||
// Upload from stream
|
||||
const readStream = fs.createReadStream('/path/to/large-video.mp4');
|
||||
await s3Storage.uploadStream(readStream, 'events/wedding-2024/video.mp4', {
|
||||
contentType: 'video/mp4',
|
||||
onProgress: (loaded, total) => {
|
||||
console.log(`Streamed ${loaded} of ${total} bytes`);
|
||||
}
|
||||
});
|
||||
|
||||
// Download a file
|
||||
await s3Storage.download(
|
||||
'events/wedding-2024/photo.jpg',
|
||||
'/path/to/downloaded/photo.jpg',
|
||||
{
|
||||
onProgress: (loaded, total) => {
|
||||
const percentage = Math.round((loaded / total) * 100);
|
||||
console.log(`Download progress: ${percentage}%`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get a download stream
|
||||
const downloadStream = await s3Storage.downloadStream('events/wedding-2024/photo.jpg');
|
||||
downloadStream.pipe(fs.createWriteStream('/path/to/output.jpg'));
|
||||
|
||||
// List files
|
||||
const listing = await s3Storage.list('events/wedding-2024/');
|
||||
console.log(`Found ${listing.Contents.length} files`);
|
||||
listing.Contents.forEach(file => {
|
||||
console.log(`- ${file.Key} (${file.Size} bytes)`);
|
||||
});
|
||||
|
||||
// Generate pre-signed URL for temporary access
|
||||
const downloadUrl = await s3Storage.getSignedUrl('getObject', 'events/wedding-2024/photo.jpg', {
|
||||
expiresIn: 3600 // 1 hour
|
||||
});
|
||||
console.log('Pre-signed download URL:', downloadUrl);
|
||||
|
||||
// Generate pre-signed upload URL
|
||||
const uploadUrl = await s3Storage.getSignedUrl('putObject', 'events/wedding-2024/new-photo.jpg', {
|
||||
expiresIn: 1800, // 30 minutes
|
||||
params: {
|
||||
ContentType: 'image/jpeg'
|
||||
}
|
||||
});
|
||||
console.log('Pre-signed upload URL:', uploadUrl);
|
||||
|
||||
// Check if file exists
|
||||
const exists = await s3Storage.exists('events/wedding-2024/photo.jpg');
|
||||
console.log('File exists:', exists);
|
||||
|
||||
// Get metadata
|
||||
const metadata = await s3Storage.getMetadata('events/wedding-2024/photo.jpg');
|
||||
console.log('File metadata:', metadata);
|
||||
|
||||
// Copy file
|
||||
await s3Storage.copy(
|
||||
'events/wedding-2024/photo.jpg',
|
||||
'events/wedding-2024/photo-copy.jpg'
|
||||
);
|
||||
|
||||
// Move file
|
||||
await s3Storage.move(
|
||||
'events/wedding-2024/photo-copy.jpg',
|
||||
'events/wedding-2024/archived/photo.jpg'
|
||||
);
|
||||
|
||||
// Delete file
|
||||
await s3Storage.delete('events/wedding-2024/temp-photo.jpg');
|
||||
|
||||
// Delete multiple files
|
||||
const deleteResult = await s3Storage.deleteMany([
|
||||
'events/wedding-2024/temp1.jpg',
|
||||
'events/wedding-2024/temp2.jpg',
|
||||
'events/wedding-2024/temp3.jpg'
|
||||
]);
|
||||
console.log(`Deleted ${deleteResult.Deleted.length} files`);
|
||||
|
||||
// Get storage statistics
|
||||
const stats = await s3Storage.getStats('events/');
|
||||
console.log(`Total files: ${stats.totalCount}`);
|
||||
console.log(`Total size: ${stats.totalSizeFormatted}`);
|
||||
|
||||
// Listen to events
|
||||
s3Storage.on('uploadProgress', (data) => {
|
||||
console.log(`Uploading ${data.key}: ${data.loaded}/${data.total}`);
|
||||
});
|
||||
|
||||
s3Storage.on('uploadComplete', (data) => {
|
||||
console.log(`Upload completed: ${data.key}`);
|
||||
});
|
||||
|
||||
s3Storage.on('uploadError', (data) => {
|
||||
console.error(`Upload failed for ${data.key}:`, data.error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Integration with existing photo upload workflow
|
||||
async function integrateWithPhotoUpload(eventId, files) {
|
||||
const storage = new S3StorageAdapter({
|
||||
bucket: process.env.S3_BUCKET,
|
||||
region: process.env.AWS_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
});
|
||||
|
||||
const uploadedPhotos = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Generate unique S3 key
|
||||
const s3Key = storage.generateKey(file.originalname, `events/${eventId}`);
|
||||
|
||||
// Upload to S3
|
||||
const result = await storage.upload(file.path, s3Key, {
|
||||
contentType: file.mimetype,
|
||||
metadata: {
|
||||
eventId: eventId,
|
||||
originalName: file.originalname,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
uploadedPhotos.push({
|
||||
filename: s3Key,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
s3Location: result.Location,
|
||||
s3Key: s3Key
|
||||
});
|
||||
|
||||
// Clean up local temp file
|
||||
await fs.promises.unlink(file.path);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload ${file.originalname}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedPhotos;
|
||||
}
|
||||
|
||||
// Environment variables needed:
|
||||
// AWS_ACCESS_KEY_ID=your-access-key
|
||||
// AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
// AWS_REGION=us-east-1
|
||||
// S3_BUCKET=your-bucket-name
|
||||
|
||||
// For MinIO:
|
||||
// MINIO_ENDPOINT=http://localhost:9000
|
||||
// MINIO_ACCESS_KEY=minioadmin
|
||||
// MINIO_SECRET_KEY=minioadmin
|
||||
// MINIO_BUCKET=photo-storage
|
||||
|
||||
module.exports = { examples, integrateWithPhotoUpload };
|
||||
@@ -0,0 +1,733 @@
|
||||
const { S3Client, HeadBucketCommand, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, CopyObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const fs = require('fs');
|
||||
const fsPromises = require('fs').promises;
|
||||
const path = require('path');
|
||||
const stream = require('stream');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
/**
|
||||
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
|
||||
*
|
||||
* Features:
|
||||
* - Support for S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
|
||||
* - Multipart upload for large files (>100MB)
|
||||
* - Progress tracking with event emitter
|
||||
* - Retry logic with exponential backoff
|
||||
* - Stream support for memory efficiency
|
||||
* - Path-style URL support for MinIO
|
||||
* - Connection testing
|
||||
* - Comprehensive error handling
|
||||
*
|
||||
* @class S3StorageAdapter
|
||||
*/
|
||||
class S3StorageAdapter extends stream.EventEmitter {
|
||||
/**
|
||||
* Creates an instance of S3StorageAdapter
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {string} config.bucket - S3 bucket name
|
||||
* @param {string} [config.region='us-east-1'] - AWS region
|
||||
* @param {string} [config.endpoint] - Custom endpoint URL for S3-compatible services
|
||||
* @param {string} [config.accessKeyId] - AWS access key ID
|
||||
* @param {string} [config.secretAccessKey] - AWS secret access key
|
||||
* @param {boolean} [config.forcePathStyle=false] - Force path-style URLs (required for MinIO)
|
||||
* @param {boolean} [config.sslEnabled=true] - Enable SSL for connections
|
||||
* @param {number} [config.multipartThreshold=104857600] - Threshold for multipart upload (default 100MB)
|
||||
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
|
||||
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
|
||||
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
|
||||
*/
|
||||
constructor(config) {
|
||||
super();
|
||||
|
||||
// Validate required config
|
||||
if (!config.bucket) {
|
||||
throw new Error('S3 bucket name is required');
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
this.config = {
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: false,
|
||||
sslEnabled: true,
|
||||
multipartThreshold: 100 * 1024 * 1024, // 100MB
|
||||
partSize: 10 * 1024 * 1024, // 10MB
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
...config
|
||||
};
|
||||
|
||||
// Initialize S3 client
|
||||
const s3Config = {
|
||||
region: this.config.region,
|
||||
forcePathStyle: this.config.forcePathStyle
|
||||
};
|
||||
|
||||
// Add credentials if provided
|
||||
if (this.config.accessKeyId && this.config.secretAccessKey) {
|
||||
s3Config.credentials = {
|
||||
accessKeyId: this.config.accessKeyId,
|
||||
secretAccessKey: this.config.secretAccessKey
|
||||
};
|
||||
}
|
||||
|
||||
// Add custom endpoint if provided (for S3-compatible services)
|
||||
if (this.config.endpoint) {
|
||||
s3Config.endpoint = this.config.endpoint;
|
||||
// For MinIO and other S3-compatible services
|
||||
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
|
||||
s3Config.endpoint = `https://${this.config.endpoint}`;
|
||||
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
|
||||
s3Config.endpoint = `http://${this.config.endpoint}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client(s3Config);
|
||||
this.bucket = this.config.bucket;
|
||||
|
||||
// Bind methods to preserve context
|
||||
this.upload = this.upload.bind(this);
|
||||
this.uploadStream = this.uploadStream.bind(this);
|
||||
this.download = this.download.bind(this);
|
||||
this.downloadStream = this.downloadStream.bind(this);
|
||||
this.delete = this.delete.bind(this);
|
||||
this.exists = this.exists.bind(this);
|
||||
this.list = this.list.bind(this);
|
||||
this.copy = this.copy.bind(this);
|
||||
this.move = this.move.bind(this);
|
||||
this.getSignedUrl = this.getSignedUrl.bind(this);
|
||||
this.testConnection = this.testConnection.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to S3 bucket
|
||||
*
|
||||
* @returns {Promise<boolean>} - True if connection successful
|
||||
* @throws {Error} - If connection fails
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect to S3 bucket ${this.bucket}:`, error);
|
||||
throw new Error(`S3 connection test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3 with automatic multipart for large files
|
||||
*
|
||||
* @param {string} localPath - Local file path to upload
|
||||
* @param {string} s3Key - S3 object key (path in bucket)
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @param {Object} [options.metadata] - Object metadata
|
||||
* @param {string} [options.contentType] - Content type
|
||||
* @param {string} [options.cacheControl] - Cache control header
|
||||
* @param {Function} [options.onProgress] - Progress callback function(loaded, total)
|
||||
* @returns {Promise<Object>} - Upload result with Location, ETag, etc.
|
||||
*/
|
||||
async upload(localPath, s3Key, options = {}) {
|
||||
try {
|
||||
const stats = await fsPromises.stat(localPath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// Emit upload start event
|
||||
this.emit('uploadStart', { key: s3Key, size: fileSize });
|
||||
|
||||
// Create file stream
|
||||
const fileStream = fs.createReadStream(localPath);
|
||||
|
||||
// Prepare upload parameters
|
||||
const uploadParams = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
Body: fileStream,
|
||||
ContentType: options.contentType || 'application/octet-stream',
|
||||
Metadata: options.metadata || {},
|
||||
CacheControl: options.cacheControl
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
|
||||
|
||||
// Use AWS SDK v3 Upload for automatic multipart handling
|
||||
const parallelUploads3 = new Upload({
|
||||
client: this.s3Client,
|
||||
params: uploadParams,
|
||||
queueSize: 4, // Optional: concurrent uploads
|
||||
partSize: this.config.partSize,
|
||||
leavePartsOnError: false
|
||||
});
|
||||
|
||||
// Track upload progress
|
||||
parallelUploads3.on('httpUploadProgress', (progress) => {
|
||||
if (options.onProgress) {
|
||||
options.onProgress(progress.loaded, progress.total);
|
||||
}
|
||||
this.emit('uploadProgress', { key: s3Key, loaded: progress.loaded, total: progress.total });
|
||||
});
|
||||
|
||||
// Perform upload with retry
|
||||
const result = await this._retryOperation(() => parallelUploads3.done());
|
||||
|
||||
this.emit('uploadComplete', { key: s3Key, location: result.Location });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload file ${localPath} to S3:`, error);
|
||||
this.emit('uploadError', { key: s3Key, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a stream to S3
|
||||
*
|
||||
* @param {stream.Readable} readStream - Readable stream to upload
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @returns {Promise<Object>} - Upload result
|
||||
*/
|
||||
async uploadStream(readStream, s3Key, options = {}) {
|
||||
const uploadParams = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
Body: readStream,
|
||||
ContentType: options.contentType || 'application/octet-stream',
|
||||
Metadata: options.metadata || {},
|
||||
CacheControl: options.cacheControl
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
|
||||
|
||||
const parallelUploads3 = new Upload({
|
||||
client: this.s3Client,
|
||||
params: uploadParams,
|
||||
queueSize: 4,
|
||||
partSize: this.config.partSize,
|
||||
leavePartsOnError: false
|
||||
});
|
||||
|
||||
// Track progress if callback provided
|
||||
if (options.onProgress) {
|
||||
parallelUploads3.on('httpUploadProgress', (progress) => {
|
||||
options.onProgress(progress.loaded, progress.total);
|
||||
this.emit('uploadProgress', { key: s3Key, ...progress });
|
||||
});
|
||||
}
|
||||
|
||||
return await this._retryOperation(() => parallelUploads3.done());
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from S3
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {string} localPath - Local file path to save to
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @param {Function} [options.onProgress] - Progress callback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async download(s3Key, localPath, options = {}) {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await fsPromises.mkdir(path.dirname(localPath), { recursive: true });
|
||||
|
||||
// Get object metadata first for progress tracking
|
||||
const headResult = await this._retryOperation(() =>
|
||||
this.s3Client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key
|
||||
}))
|
||||
);
|
||||
|
||||
const fileSize = headResult.ContentLength;
|
||||
this.emit('downloadStart', { key: s3Key, size: fileSize });
|
||||
|
||||
// Get object
|
||||
const getObjectResult = await this._retryOperation(() =>
|
||||
this.s3Client.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key
|
||||
}))
|
||||
);
|
||||
|
||||
// Create write stream
|
||||
const writeStream = fs.createWriteStream(localPath);
|
||||
|
||||
// Download with progress tracking
|
||||
await new Promise((resolve, reject) => {
|
||||
let downloaded = 0;
|
||||
|
||||
const bodyStream = getObjectResult.Body;
|
||||
|
||||
bodyStream.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
if (options.onProgress) {
|
||||
options.onProgress(downloaded, fileSize);
|
||||
}
|
||||
this.emit('downloadProgress', { key: s3Key, loaded: downloaded, total: fileSize });
|
||||
});
|
||||
|
||||
bodyStream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
writeStream.on('finish', resolve);
|
||||
|
||||
bodyStream.pipe(writeStream);
|
||||
});
|
||||
|
||||
this.emit('downloadComplete', { key: s3Key });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${s3Key} from S3:`, error);
|
||||
this.emit('downloadError', { key: s3Key, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a download stream from S3
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @param {string} [options.range] - Byte range to download (e.g., 'bytes=0-1023')
|
||||
* @returns {Promise<stream.Readable>} - Readable stream
|
||||
*/
|
||||
async downloadStream(s3Key, options = {}) {
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
Range: options.range
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
||||
|
||||
const result = await this.s3Client.send(new GetObjectCommand(params));
|
||||
return result.Body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from S3
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delete(s3Key) {
|
||||
return await this._retryOperation(() =>
|
||||
this.s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files from S3
|
||||
*
|
||||
* @param {string[]} s3Keys - Array of S3 object keys
|
||||
* @returns {Promise<Object>} - Delete result
|
||||
*/
|
||||
async deleteMany(s3Keys) {
|
||||
if (!s3Keys || s3Keys.length === 0) {
|
||||
return { Deleted: [], Errors: [] };
|
||||
}
|
||||
|
||||
// S3 deleteObjects has a limit of 1000 keys per request
|
||||
const chunks = [];
|
||||
for (let i = 0; i < s3Keys.length; i += 1000) {
|
||||
chunks.push(s3Keys.slice(i, i + 1000));
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
chunks.map(chunk =>
|
||||
this._retryOperation(() =>
|
||||
this.s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: chunk.map(key => ({ Key: key })),
|
||||
Quiet: false
|
||||
}
|
||||
}))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Combine results
|
||||
return {
|
||||
Deleted: results.flatMap(r => r.Deleted || []),
|
||||
Errors: results.flatMap(r => r.Errors || [])
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in S3
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @returns {Promise<boolean>} - True if exists
|
||||
*/
|
||||
async exists(s3Key) {
|
||||
try {
|
||||
await this.s3Client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in S3
|
||||
*
|
||||
* @param {string} prefix - S3 prefix to list
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @param {number} [options.maxKeys=1000] - Maximum number of keys to return
|
||||
* @param {string} [options.continuationToken] - Continuation token for pagination
|
||||
* @returns {Promise<Object>} - List result with Contents array and NextContinuationToken
|
||||
*/
|
||||
async list(prefix, options = {}) {
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: options.maxKeys || 1000,
|
||||
ContinuationToken: options.continuationToken
|
||||
};
|
||||
|
||||
return await this._retryOperation(() =>
|
||||
this.s3Client.send(new ListObjectsV2Command(params))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within S3
|
||||
*
|
||||
* @param {string} sourceKey - Source S3 object key
|
||||
* @param {string} targetKey - Target S3 object key
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @returns {Promise<Object>} - Copy result
|
||||
*/
|
||||
async copy(sourceKey, targetKey, options = {}) {
|
||||
const copySource = `${this.bucket}/${sourceKey}`;
|
||||
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
CopySource: copySource,
|
||||
Key: targetKey,
|
||||
MetadataDirective: options.metadata ? 'REPLACE' : 'COPY',
|
||||
Metadata: options.metadata,
|
||||
ContentType: options.contentType,
|
||||
CacheControl: options.cacheControl
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
||||
|
||||
return await this._retryOperation(() =>
|
||||
this.s3Client.send(new CopyObjectCommand(params))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file within S3 (copy then delete)
|
||||
*
|
||||
* @param {string} sourceKey - Source S3 object key
|
||||
* @param {string} targetKey - Target S3 object key
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @returns {Promise<Object>} - Move result
|
||||
*/
|
||||
async move(sourceKey, targetKey, options = {}) {
|
||||
// First copy the object
|
||||
const copyResult = await this.copy(sourceKey, targetKey, options);
|
||||
|
||||
// Then delete the original
|
||||
await this.delete(sourceKey);
|
||||
|
||||
return copyResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-signed URL for downloading or uploading
|
||||
*
|
||||
* @param {string} operation - Operation type ('getObject' or 'putObject')
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {Object} [options={}] - Additional options
|
||||
* @param {number} [options.expiresIn=3600] - URL expiration in seconds
|
||||
* @param {Object} [options.params] - Additional parameters for the operation
|
||||
* @returns {Promise<string>} - Pre-signed URL
|
||||
*/
|
||||
async getSignedUrl(operation, s3Key, options = {}) {
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
...options.params
|
||||
};
|
||||
|
||||
let command;
|
||||
switch (operation.toLowerCase()) {
|
||||
case 'getobject':
|
||||
command = new GetObjectCommand(params);
|
||||
break;
|
||||
case 'putobject':
|
||||
command = new PutObjectCommand(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${operation}`);
|
||||
}
|
||||
|
||||
return await getSignedUrl(this.s3Client, command, {
|
||||
expiresIn: options.expiresIn || 3600
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object metadata
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @returns {Promise<Object>} - Object metadata
|
||||
*/
|
||||
async getMetadata(s3Key) {
|
||||
return await this._retryOperation(() =>
|
||||
this.s3Client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update object metadata
|
||||
*
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {Object} metadata - New metadata
|
||||
* @returns {Promise<Object>} - Update result
|
||||
*/
|
||||
async updateMetadata(s3Key, metadata) {
|
||||
// S3 requires copying the object to itself to update metadata
|
||||
return await this.copy(s3Key, s3Key, { metadata });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual multipart upload for advanced use cases
|
||||
*
|
||||
* @param {string} localPath - Local file path
|
||||
* @param {string} s3Key - S3 object key
|
||||
* @param {number} fileSize - File size in bytes
|
||||
* @param {Object} options - Upload options
|
||||
* @returns {Promise<Object>} - Upload result
|
||||
* @private
|
||||
*/
|
||||
async _manualMultipartUpload(localPath, s3Key, fileSize, options) {
|
||||
logger.info(`Starting manual multipart upload for ${s3Key} (${fileSize} bytes)`);
|
||||
|
||||
// Initiate multipart upload
|
||||
const multipartParams = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
ContentType: options.contentType || 'application/octet-stream',
|
||||
Metadata: options.metadata || {},
|
||||
CacheControl: options.cacheControl
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(multipartParams).forEach(key => multipartParams[key] === undefined && delete multipartParams[key]);
|
||||
|
||||
const multipart = await this._retryOperation(() =>
|
||||
this.s3Client.send(new CreateMultipartUploadCommand(multipartParams))
|
||||
);
|
||||
|
||||
const uploadId = multipart.UploadId;
|
||||
const partSize = this.config.partSize;
|
||||
const numParts = Math.ceil(fileSize / partSize);
|
||||
|
||||
let uploaded = 0;
|
||||
const parts = [];
|
||||
|
||||
try {
|
||||
// Upload parts
|
||||
for (let partNum = 1; partNum <= numParts; partNum++) {
|
||||
const start = (partNum - 1) * partSize;
|
||||
const end = Math.min(start + partSize, fileSize);
|
||||
|
||||
const partStream = fs.createReadStream(localPath, {
|
||||
start,
|
||||
end: end - 1
|
||||
});
|
||||
|
||||
const partParams = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
PartNumber: partNum,
|
||||
UploadId: uploadId,
|
||||
Body: partStream
|
||||
};
|
||||
|
||||
// Upload part with retry
|
||||
const partResult = await this._retryOperation(() =>
|
||||
this.s3Client.send(new UploadPartCommand(partParams))
|
||||
);
|
||||
|
||||
parts.push({
|
||||
ETag: partResult.ETag,
|
||||
PartNumber: partNum
|
||||
});
|
||||
|
||||
uploaded += (end - start);
|
||||
|
||||
if (options.onProgress) {
|
||||
options.onProgress(uploaded, fileSize);
|
||||
}
|
||||
this.emit('uploadProgress', { key: s3Key, loaded: uploaded, total: fileSize });
|
||||
|
||||
logger.info(`Uploaded part ${partNum}/${numParts} for ${s3Key}`);
|
||||
}
|
||||
|
||||
// Complete multipart upload
|
||||
const completeParams = {
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: { Parts: parts }
|
||||
};
|
||||
|
||||
const result = await this._retryOperation(() =>
|
||||
this.s3Client.send(new CompleteMultipartUploadCommand(completeParams))
|
||||
);
|
||||
|
||||
this.emit('uploadComplete', { key: s3Key, location: result.Location });
|
||||
logger.info(`Completed multipart upload for ${s3Key}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Abort multipart upload on error
|
||||
logger.error(`Multipart upload failed for ${s3Key}, aborting:`, error);
|
||||
|
||||
try {
|
||||
await this.s3Client.send(new AbortMultipartUploadCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: s3Key,
|
||||
UploadId: uploadId
|
||||
}));
|
||||
} catch (abortError) {
|
||||
logger.error(`Failed to abort multipart upload:`, abortError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry operation with exponential backoff
|
||||
* @private
|
||||
*/
|
||||
async _retryOperation(operation, retryCount = 0) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (retryCount >= this.config.maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
|
||||
const isRetryable = retryableErrors.some(code =>
|
||||
error.code === code ||
|
||||
error.name === code ||
|
||||
error.$metadata?.httpStatusCode === 503 ||
|
||||
error.$metadata?.httpStatusCode === 500
|
||||
);
|
||||
|
||||
if (!isRetryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff and jitter
|
||||
const delay = Math.min(
|
||||
this.config.retryDelay * Math.pow(2, retryCount) + Math.random() * 1000,
|
||||
30000 // Max 30 seconds
|
||||
);
|
||||
|
||||
logger.warn(`Retrying operation after ${delay}ms (attempt ${retryCount + 1}/${this.config.maxRetries}):`, error.message);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
return this._retryOperation(operation, retryCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique S3 key for a file
|
||||
*
|
||||
* @param {string} originalName - Original filename
|
||||
* @param {string} [prefix=''] - Optional prefix for the key
|
||||
* @returns {string} - Generated S3 key
|
||||
*/
|
||||
generateKey(originalName, prefix = '') {
|
||||
const timestamp = Date.now();
|
||||
const randomStr = crypto.randomBytes(8).toString('hex');
|
||||
const ext = path.extname(originalName);
|
||||
const basename = path.basename(originalName, ext);
|
||||
|
||||
// Sanitize basename
|
||||
const safeName = basename.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
|
||||
const key = `${timestamp}_${randomStr}_${safeName}${ext}`;
|
||||
|
||||
return prefix ? path.posix.join(prefix, key) : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*
|
||||
* @param {string} [prefix=''] - Optional prefix to filter
|
||||
* @returns {Promise<Object>} - Storage statistics
|
||||
*/
|
||||
async getStats(prefix = '') {
|
||||
let totalSize = 0;
|
||||
let totalCount = 0;
|
||||
let continuationToken;
|
||||
|
||||
do {
|
||||
const result = await this.list(prefix, { continuationToken });
|
||||
|
||||
if (result.Contents) {
|
||||
totalCount += result.Contents.length;
|
||||
totalSize += result.Contents.reduce((sum, obj) => sum + (obj.Size || 0), 0);
|
||||
}
|
||||
|
||||
continuationToken = result.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
return {
|
||||
totalSize,
|
||||
totalCount,
|
||||
totalSizeFormatted: this._formatBytes(totalSize)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
* @private
|
||||
*/
|
||||
_formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = S3StorageAdapter;
|
||||
@@ -56,30 +56,30 @@ class WatermarkService {
|
||||
let left, top;
|
||||
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
left = padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'top-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
left = padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'center':
|
||||
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
||||
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
||||
break;
|
||||
default:
|
||||
// Default to bottom-right
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
case 'top-left':
|
||||
left = padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'top-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
left = padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'center':
|
||||
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
||||
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
||||
break;
|
||||
default:
|
||||
// Default to bottom-right
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
}
|
||||
|
||||
return { left: Math.max(0, left), top: Math.max(0, top) };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('./logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Clean up old temporary upload directories
|
||||
* Removes temp directories older than 1 hour
|
||||
*/
|
||||
async function cleanupTempUploads() {
|
||||
const tempPath = path.join(getStoragePath(), 'temp');
|
||||
|
||||
try {
|
||||
// Ensure temp directory exists
|
||||
await fs.mkdir(tempPath, { recursive: true });
|
||||
|
||||
// Read all items in temp directory
|
||||
const items = await fs.readdir(tempPath);
|
||||
|
||||
let cleanedCount = 0;
|
||||
const oneHourAgo = Date.now() - (60 * 60 * 1000); // 1 hour
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(tempPath, item);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(itemPath);
|
||||
|
||||
// Only process directories that match our upload pattern
|
||||
if (stats.isDirectory() && item.startsWith('upload_')) {
|
||||
// Extract timestamp from directory name
|
||||
const parts = item.split('_');
|
||||
if (parts.length >= 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
|
||||
// Remove if older than 1 hour
|
||||
if (!isNaN(timestamp) && timestamp < oneHourAgo) {
|
||||
logger.info(`Cleaning up old temp upload directory: ${item}`);
|
||||
await fs.rm(itemPath, { recursive: true, force: true });
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing temp item ${item}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.info(`Cleaned up ${cleanedCount} old temp upload directories`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error during temp upload cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of temp uploads
|
||||
* Runs every hour
|
||||
*/
|
||||
function startTempUploadCleanup() {
|
||||
// Run immediately on startup
|
||||
cleanupTempUploads();
|
||||
|
||||
// Then run every hour
|
||||
setInterval(() => {
|
||||
cleanupTempUploads();
|
||||
}, 60 * 60 * 1000); // 1 hour
|
||||
|
||||
logger.info('Temp upload cleanup service started');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanupTempUploads,
|
||||
startTempUploadCleanup
|
||||
};
|
||||
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
|
||||
}
|
||||
}
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
// Ensure proper date parsing
|
||||
let dateObj;
|
||||
if (date instanceof Date) {
|
||||
dateObj = date;
|
||||
} else if (typeof date === 'string') {
|
||||
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
|
||||
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
// Parse YYYY-MM-DD format as local date
|
||||
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
|
||||
dateObj = new Date(year, month - 1, day);
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
console.error('Invalid date provided to formatDate:', date);
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
|
||||
// Use appropriate locale based on language
|
||||
let locale = dateConfig.locale || 'en-GB';
|
||||
@@ -39,33 +60,33 @@ async function formatDate(date, language = 'en') {
|
||||
|
||||
// Format based on the configured format
|
||||
switch (dateConfig.format) {
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Formatters for email content and other text transformations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert plain text line breaks to HTML line breaks
|
||||
* @param {string} text - The text to format
|
||||
* @returns {string} - Text with HTML line breaks
|
||||
*/
|
||||
function nl2br(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Convert newlines to <br> tags
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format welcome message for email templates
|
||||
* @param {string} message - The welcome message
|
||||
* @returns {string} - Formatted message for HTML emails
|
||||
*/
|
||||
function formatWelcomeMessage(message) {
|
||||
if (!message || message.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return nl2br(message);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nl2br,
|
||||
formatWelcomeMessage
|
||||
};
|
||||
+77
-10
@@ -1,31 +1,98 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Custom format for production logs
|
||||
const productionFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Ensure all security events are properly formatted
|
||||
if (info.level === 'warn' && (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT'))) {
|
||||
return JSON.stringify({
|
||||
timestamp: info.timestamp,
|
||||
level: info.level,
|
||||
message: info.message,
|
||||
security: true,
|
||||
...info
|
||||
});
|
||||
}
|
||||
return JSON.stringify(info);
|
||||
})
|
||||
);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
format: productionFormat,
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/error.log'),
|
||||
level: 'error'
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/combined.log')
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 10,
|
||||
tailable: true
|
||||
}),
|
||||
// Separate security log for authentication and rate limiting
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'security.log'),
|
||||
level: 'warn',
|
||||
maxsize: 20 * 1024 * 1024, // 20MB
|
||||
maxFiles: 10,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Only log security-related warnings
|
||||
if (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT') ||
|
||||
info.message.includes('lockout') ||
|
||||
info.message.includes('suspicious')) {
|
||||
return JSON.stringify(info);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
})
|
||||
]
|
||||
].filter(Boolean)
|
||||
});
|
||||
|
||||
// Add console logging for non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(info => {
|
||||
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
|
||||
})
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// In production, also log to console for container environments
|
||||
if (process.env.LOG_TO_CONSOLE === 'true') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: productionFormat
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
|
||||
@@ -78,6 +78,16 @@ function validatePassword(password, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip zxcvbn check if explicitly disabled (for gallery passwords)
|
||||
if (options.skipStrengthCheck) {
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
score: 2, // Default moderate score for gallery passwords
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Use zxcvbn for strength analysis
|
||||
const strength = zxcvbn(password);
|
||||
|
||||
@@ -103,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
|
||||
@@ -110,8 +198,50 @@ function validatePassword(password, options = {}) {
|
||||
* @param {Object} userData - Additional user data for context-aware validation
|
||||
* @returns {Object} - Validation result
|
||||
*/
|
||||
function validatePasswordInContext(password, context, userData = {}) {
|
||||
// Base validation
|
||||
async function validatePasswordInContext(password, context, userData = {}) {
|
||||
// For gallery context, use dynamic complexity settings
|
||||
if (context === 'gallery') {
|
||||
// Get complexity settings from database
|
||||
const complexityLevel = await getPasswordComplexitySettings();
|
||||
|
||||
// Get configuration for the complexity level
|
||||
const galleryOptions = {
|
||||
...getPasswordConfigForComplexity(complexityLevel),
|
||||
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
|
||||
};
|
||||
|
||||
// Base validation with gallery-specific options
|
||||
const result = validatePassword(password, galleryOptions);
|
||||
|
||||
// Override validation for common date formats
|
||||
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
|
||||
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||
if (datePattern.test(password)) {
|
||||
// Date format is valid for gallery passwords
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
score: 2,
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Additional gallery-specific checks
|
||||
if (password.length < 6) {
|
||||
result.valid = false;
|
||||
result.errors = ['Password must be at least 6 characters long'];
|
||||
}
|
||||
|
||||
// Check if it's too simple (e.g., just "123456")
|
||||
if (/^\d{1,6}$/.test(password)) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Base validation for other contexts
|
||||
const result = validatePassword(password);
|
||||
|
||||
// Context-specific validation
|
||||
@@ -136,16 +266,6 @@ function validatePasswordInContext(password, context, userData = {}) {
|
||||
result.errors.push('Password must not contain parts of your email');
|
||||
}
|
||||
}
|
||||
} else if (context === 'gallery') {
|
||||
// Gallery passwords can be more lenient for user convenience
|
||||
// Allow passwords with score >= 1 (weak but acceptable)
|
||||
if (result.score < 1) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password is too simple. Please add more complexity');
|
||||
}
|
||||
|
||||
// Don't check for event name in password - allow date-based passwords
|
||||
// This allows passwords like "Sommer2025!" which users prefer
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -236,5 +356,7 @@ module.exports = {
|
||||
generateSecurePassword,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure,
|
||||
getPasswordComplexitySettings,
|
||||
getPasswordConfigForComplexity,
|
||||
PASSWORD_CONFIG
|
||||
};
|
||||
@@ -47,7 +47,7 @@ function escapeLikePattern(input) {
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/%/g, '\\%') // Escape percent signs
|
||||
.replace(/_/g, '\\_') // Escape underscores
|
||||
.replace(/'/g, "''"); // Escape single quotes for safety
|
||||
.replace(/'/g, '\'\''); // Escape single quotes for safety
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
# Comprehensive Backup & Restore Implementation Plan
|
||||
|
||||
## Introduction
|
||||
|
||||
This plan extends the existing backup system to include full database backups, S3/MinIO support, intelligent change detection, and complete restore functionality. The system will create versioned, encrypted backups with manifests for easy restoration while minimizing storage usage through incremental backups and smart scheduling.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Enhanced Database Schema & Core Infrastructure
|
||||
|
||||
### Task 1.1: Create Enhanced Database Migration
|
||||
**File:** `backend/migrations/030_enhance_backup_system.js`
|
||||
**Purpose:** Add tables for database backups, restore operations, and backup manifests
|
||||
|
||||
```sql
|
||||
-- backup_manifests table
|
||||
- id (primary key)
|
||||
- backup_run_id (FK to backup_runs)
|
||||
- manifest_version (e.g., "1.0.0")
|
||||
- created_at
|
||||
- database_dump_path
|
||||
- database_checksum
|
||||
- files_manifest (JSON with all file paths/checksums)
|
||||
- metadata (JSON with system info, versions, etc.)
|
||||
|
||||
-- restore_operations table
|
||||
- id (primary key)
|
||||
- started_at
|
||||
- completed_at
|
||||
- status (pending, running, completed, failed)
|
||||
- restore_type (full, partial, database_only, files_only)
|
||||
- source_backup_id (FK to backup_runs)
|
||||
- restored_by (FK to admin_users)
|
||||
- error_message
|
||||
- restore_log (detailed log)
|
||||
|
||||
-- backup_change_tracking table
|
||||
- id (primary key)
|
||||
- table_name
|
||||
- last_change_timestamp
|
||||
- row_count
|
||||
- checksum
|
||||
- last_backed_up
|
||||
```
|
||||
|
||||
### Task 1.2: Add S3 Configuration Settings
|
||||
**File:** Update migration `029_add_backup_service_tables.js`
|
||||
**Add settings:**
|
||||
- `backup_s3_use_ssl` (boolean)
|
||||
- `backup_s3_path_style` (for MinIO compatibility)
|
||||
- `backup_encryption_enabled` (boolean)
|
||||
- `backup_encryption_key` (encrypted storage)
|
||||
- `backup_database_included` (boolean)
|
||||
- `backup_incremental_enabled` (boolean)
|
||||
- `backup_versioning_enabled` (boolean)
|
||||
- `backup_versions_to_keep` (number)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: S3/MinIO Implementation
|
||||
|
||||
### Task 2.1: Install S3 Dependencies
|
||||
**File:** `backend/package.json`
|
||||
**Command:** `npm install @aws-sdk/client-s3 @aws-sdk/lib-storage mime-types`
|
||||
**Purpose:** AWS SDK v3 for S3-compatible storage
|
||||
|
||||
### Task 2.2: Create S3 Storage Adapter
|
||||
**File:** `backend/src/services/storage/s3Storage.js`
|
||||
**Implementation:**
|
||||
```javascript
|
||||
class S3StorageAdapter {
|
||||
constructor(config)
|
||||
connect() // Test connection
|
||||
uploadFile(localPath, remotePath, metadata)
|
||||
uploadStream(stream, remotePath, metadata)
|
||||
downloadFile(remotePath, localPath)
|
||||
listFiles(prefix)
|
||||
deleteFile(remotePath)
|
||||
getSignedUrl(remotePath, expiresIn)
|
||||
createMultipartUpload(remotePath) // For large files
|
||||
uploadPart(uploadId, partNumber, data)
|
||||
completeMultipartUpload(uploadId, parts)
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.3: Implement MinIO Compatibility Layer
|
||||
**File:** `backend/src/services/storage/minioCompat.js`
|
||||
**Features:**
|
||||
- Path-style URL handling
|
||||
- Custom endpoint configuration
|
||||
- SSL/TLS options
|
||||
- Bucket creation if not exists
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Database Backup Integration
|
||||
|
||||
### Task 3.1: Create Database Dump Service
|
||||
**File:** `backend/src/services/databaseBackup.js`
|
||||
**Implementation:**
|
||||
```javascript
|
||||
class DatabaseBackupService {
|
||||
async createBackup(format = 'sql') // sql or json
|
||||
async dumpSQLite(outputPath)
|
||||
async dumpPostgreSQL(outputPath)
|
||||
async compressBackup(inputPath, outputPath)
|
||||
async encryptBackup(inputPath, outputPath, key)
|
||||
async validateBackup(backupPath)
|
||||
async getTableChecksums() // For change detection
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Implement Change Detection for Database
|
||||
**File:** `backend/src/services/changeDetection.js`
|
||||
**Features:**
|
||||
- Track table modifications using triggers
|
||||
- Calculate table checksums
|
||||
- Compare with last backup state
|
||||
- Intelligent backup decision making
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Enhanced Backup Service
|
||||
|
||||
### Task 4.1: Refactor Backup Service for S3
|
||||
**File:** `backend/src/services/backupService.js`
|
||||
**Modifications:**
|
||||
- Add `performS3Backup()` implementation
|
||||
- Support multipart uploads for large files
|
||||
- Add progress tracking callbacks
|
||||
- Implement retry logic with exponential backoff
|
||||
|
||||
### Task 4.2: Create Backup Manifest Generator
|
||||
**File:** `backend/src/services/backupManifest.js`
|
||||
**Structure:**
|
||||
```javascript
|
||||
{
|
||||
version: "1.0.0",
|
||||
created_at: "2024-01-20T10:00:00Z",
|
||||
system_info: {
|
||||
app_version: "1.0.74",
|
||||
node_version: "18.x",
|
||||
database_type: "sqlite|postgresql"
|
||||
},
|
||||
database: {
|
||||
dump_file: "database/dump.sql.gz",
|
||||
checksum: "sha256:...",
|
||||
tables: { /* table info */ }
|
||||
},
|
||||
files: {
|
||||
count: 1234,
|
||||
total_size: 5678901234,
|
||||
entries: [
|
||||
{
|
||||
path: "events/active/...",
|
||||
checksum: "sha256:...",
|
||||
size: 12345,
|
||||
modified: "2024-01-20T09:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
settings: { /* app settings snapshot */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.3: Implement Incremental Backup Logic
|
||||
**File:** `backend/src/services/incrementalBackup.js`
|
||||
**Features:**
|
||||
- Track changed files since last full backup
|
||||
- Create incremental manifest
|
||||
- Link to parent backup
|
||||
- Merge incremental backups
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Restore Functionality
|
||||
|
||||
### Task 5.1: Create Restore Service
|
||||
**File:** `backend/src/services/restoreService.js`
|
||||
**Implementation:**
|
||||
```javascript
|
||||
class RestoreService {
|
||||
async validateBackup(backupId)
|
||||
async prepareRestore(backupId, options)
|
||||
async restoreDatabase(manifestPath)
|
||||
async restoreFiles(manifestPath, options)
|
||||
async performFullRestore(backupId)
|
||||
async performPartialRestore(backupId, selections)
|
||||
async rollbackRestore(restoreId)
|
||||
async verifyRestore(restoreId)
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: Implement Safe Restore Process
|
||||
**File:** `backend/src/services/restoreValidation.js`
|
||||
**Safety Features:**
|
||||
- Pre-restore backup creation
|
||||
- Validation checksums
|
||||
- Atomic operations
|
||||
- Rollback capability
|
||||
- Post-restore verification
|
||||
|
||||
### Task 5.3: Create Restore CLI Tool
|
||||
**File:** `backend/scripts/restore-backup.js`
|
||||
**Purpose:** Emergency restore without running application
|
||||
**Features:**
|
||||
- Interactive mode
|
||||
- Dry-run option
|
||||
- Progress display
|
||||
- Validation reports
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Admin API Extensions
|
||||
|
||||
### Task 6.1: Add Restore Endpoints
|
||||
**File:** `backend/src/routes/adminBackup.js`
|
||||
**New Endpoints:**
|
||||
```javascript
|
||||
POST /api/admin/backup/restore/validate
|
||||
POST /api/admin/backup/restore/start
|
||||
GET /api/admin/backup/restore/:id/status
|
||||
POST /api/admin/backup/restore/:id/cancel
|
||||
GET /api/admin/backup/manifests/:backupId
|
||||
GET /api/admin/backup/download/:backupId
|
||||
```
|
||||
|
||||
### Task 6.2: Add S3 Management Endpoints
|
||||
**File:** `backend/src/routes/adminBackup.js`
|
||||
**New Endpoints:**
|
||||
```javascript
|
||||
GET /api/admin/backup/s3/buckets
|
||||
GET /api/admin/backup/s3/files
|
||||
DELETE /api/admin/backup/s3/cleanup
|
||||
POST /api/admin/backup/s3/test-upload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Frontend Implementation
|
||||
|
||||
### Task 7.1: Create Backup Management Page
|
||||
**File:** `frontend/src/pages/admin/BackupManagement.jsx`
|
||||
**Components:**
|
||||
- Backup configuration form
|
||||
- Backup history table
|
||||
- Manual backup trigger
|
||||
- Restore interface
|
||||
- Progress indicators
|
||||
|
||||
### Task 7.2: Create Backup Status Dashboard
|
||||
**File:** `frontend/src/components/admin/BackupDashboard.jsx`
|
||||
**Features:**
|
||||
- Real-time backup status
|
||||
- Storage usage charts
|
||||
- Backup success rate
|
||||
- Next scheduled backup
|
||||
- Recent backup/restore operations
|
||||
|
||||
### Task 7.3: Implement Restore Wizard
|
||||
**File:** `frontend/src/components/admin/RestoreWizard.jsx`
|
||||
**Steps:**
|
||||
1. Select backup to restore
|
||||
2. Choose restore type (full/partial)
|
||||
3. Select components (database/files/settings)
|
||||
4. Review and confirm
|
||||
5. Monitor progress
|
||||
6. Verify results
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Background Job Enhancements
|
||||
|
||||
### Task 8.1: Implement Smart Scheduling
|
||||
**File:** `backend/src/services/smartScheduler.js`
|
||||
**Features:**
|
||||
- Skip backup if no changes detected
|
||||
- Adaptive scheduling based on activity
|
||||
- Priority queuing for critical backups
|
||||
- Resource usage monitoring
|
||||
|
||||
### Task 8.2: Create Backup Monitor Service
|
||||
**File:** `backend/src/services/backupMonitor.js`
|
||||
**Purpose:** Monitor backup health and alert on issues
|
||||
**Features:**
|
||||
- Check last successful backup age
|
||||
- Verify backup integrity periodically
|
||||
- Monitor storage usage
|
||||
- Alert on failures or anomalies
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Security & Encryption
|
||||
|
||||
### Task 9.1: Implement Backup Encryption
|
||||
**File:** `backend/src/utils/encryption.js`
|
||||
**Features:**
|
||||
- AES-256-GCM encryption
|
||||
- Key derivation from master key
|
||||
- Encrypted manifest headers
|
||||
- Secure key storage
|
||||
|
||||
### Task 9.2: Add Access Control
|
||||
**File:** `backend/src/middleware/backupAuth.js`
|
||||
**Features:**
|
||||
- Separate permissions for backup/restore
|
||||
- Audit logging for all operations
|
||||
- IP whitelist for restore operations
|
||||
- Two-factor authentication for restore
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Testing & Validation
|
||||
|
||||
### Task 10.1: Create Backup Test Suite
|
||||
**File:** `backend/__tests__/services/backup.test.js`
|
||||
**Tests:**
|
||||
- Unit tests for each backup method
|
||||
- Integration tests with real S3/MinIO
|
||||
- Database backup/restore cycles
|
||||
- Encryption/decryption validation
|
||||
- Manifest generation and parsing
|
||||
|
||||
### Task 10.2: Create Restore Test Suite
|
||||
**File:** `backend/__tests__/services/restore.test.js`
|
||||
**Tests:**
|
||||
- Full restore scenarios
|
||||
- Partial restore validation
|
||||
- Rollback testing
|
||||
- Corruption recovery
|
||||
- Cross-version compatibility
|
||||
|
||||
### Task 10.3: Create E2E Backup/Restore Tests
|
||||
**File:** `backend/__tests__/e2e/backupRestore.test.js`
|
||||
**Scenarios:**
|
||||
- Complete backup/restore cycle
|
||||
- Disaster recovery simulation
|
||||
- Performance benchmarks
|
||||
- Storage optimization validation
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Documentation & Deployment
|
||||
|
||||
### Task 11.1: Create Backup Administrator Guide
|
||||
**File:** `docs/backup-admin-guide.md`
|
||||
**Contents:**
|
||||
- Configuration guide
|
||||
- Best practices
|
||||
- Troubleshooting
|
||||
- Recovery procedures
|
||||
- Performance tuning
|
||||
|
||||
### Task 11.2: Update Docker Configuration
|
||||
**Files:** `docker-compose.yml`, `Dockerfile`
|
||||
**Changes:**
|
||||
- Add S3/MinIO service for development
|
||||
- Volume mappings for backups
|
||||
- Environment variable templates
|
||||
- Health checks for backup service
|
||||
|
||||
### Task 11.3: Create Backup Playbook
|
||||
**File:** `docs/backup-playbook.md`
|
||||
**Scenarios:**
|
||||
- Daily backup verification
|
||||
- Disaster recovery steps
|
||||
- Migration procedures
|
||||
- Troubleshooting flowchart
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order & Priority
|
||||
|
||||
### Critical Path (Must Have):
|
||||
1. S3 Storage Adapter (Task 2.2)
|
||||
2. Database Backup Service (Task 3.1)
|
||||
3. Enhanced Backup Service (Task 4.1)
|
||||
4. Basic Restore Service (Task 5.1)
|
||||
5. Admin API Extensions (Task 6.1)
|
||||
6. Backup Test Suite (Task 10.1)
|
||||
|
||||
### High Priority (Should Have):
|
||||
1. Backup Manifest Generator (Task 4.2)
|
||||
2. Change Detection (Task 3.2)
|
||||
3. Frontend Backup Page (Task 7.1)
|
||||
4. Encryption Implementation (Task 9.1)
|
||||
5. Restore Wizard (Task 7.3)
|
||||
|
||||
### Nice to Have:
|
||||
1. Incremental Backups (Task 4.3)
|
||||
2. Smart Scheduling (Task 8.1)
|
||||
3. Advanced Monitoring (Task 8.2)
|
||||
4. MinIO Compatibility (Task 2.3)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### S3 Configuration:
|
||||
```javascript
|
||||
{
|
||||
backup_destination_type: "s3",
|
||||
backup_s3_endpoint: "https://s3.amazonaws.com",
|
||||
backup_s3_bucket: "wedding-backups",
|
||||
backup_s3_access_key: "AKIA...",
|
||||
backup_s3_secret_key: "secret",
|
||||
backup_s3_region: "us-east-1",
|
||||
backup_s3_use_ssl: true,
|
||||
backup_s3_path_style: false
|
||||
}
|
||||
```
|
||||
|
||||
### MinIO Configuration:
|
||||
```javascript
|
||||
{
|
||||
backup_destination_type: "s3",
|
||||
backup_s3_endpoint: "https://minio.example.com:9000",
|
||||
backup_s3_bucket: "picpeak-backups",
|
||||
backup_s3_access_key: "minioadmin",
|
||||
backup_s3_secret_key: "minioadmin",
|
||||
backup_s3_region: "us-east-1",
|
||||
backup_s3_use_ssl: true,
|
||||
backup_s3_path_style: true // Required for MinIO
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices Implementation
|
||||
|
||||
1. **Change Detection**: Use database triggers and file checksums to detect changes
|
||||
2. **Compression**: Always compress before encryption for better ratios
|
||||
3. **Chunking**: Split large backups into manageable chunks
|
||||
4. **Versioning**: Keep multiple backup versions with rotation
|
||||
5. **Validation**: Verify every backup immediately after creation
|
||||
6. **Monitoring**: Alert on backup failures within 5 minutes
|
||||
7. **Testing**: Perform monthly restore drills
|
||||
8. **Documentation**: Log every backup/restore operation with details
|
||||
|
||||
---
|
||||
|
||||
## Key Features of This Implementation
|
||||
|
||||
### Intelligent Change Detection
|
||||
- Only backs up when changes are detected
|
||||
- Tracks database modifications via checksums
|
||||
- Monitors file system changes
|
||||
- Reduces unnecessary backup operations
|
||||
|
||||
### Comprehensive Backup Scope
|
||||
- Full database dumps (SQLite/PostgreSQL)
|
||||
- All event photos and thumbnails
|
||||
- Application settings and configuration
|
||||
- Email templates and user data
|
||||
- Complete system state capture
|
||||
|
||||
### Flexible Storage Options
|
||||
- Local directory backup
|
||||
- Remote server via rsync
|
||||
- S3-compatible storage (AWS, MinIO, etc.)
|
||||
- Encrypted storage for security
|
||||
- Compression for space efficiency
|
||||
|
||||
### Robust Restore Capabilities
|
||||
- Full system restore
|
||||
- Partial restore (specific events/data)
|
||||
- Point-in-time recovery
|
||||
- Pre-restore validation
|
||||
- Rollback on failure
|
||||
|
||||
### Enterprise-Grade Features
|
||||
- Backup manifests for verification
|
||||
- Incremental backup support
|
||||
- Version retention policies
|
||||
- Automated cleanup of old backups
|
||||
- Comprehensive audit logging
|
||||
|
||||
This comprehensive plan provides a robust, enterprise-grade backup solution with full disaster recovery capabilities.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Nginx Configuration Fix for Photo Authentication
|
||||
|
||||
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
|
||||
|
||||
## Common Issue
|
||||
|
||||
The `Authorization` header is often not passed through by default in nginx proxy configurations.
|
||||
|
||||
## Fix
|
||||
|
||||
Add these lines to your nginx configuration for the PicPeak location block:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
|
||||
# Important: Pass the Authorization header
|
||||
proxy_pass_header Authorization;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
# Other standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative Fix Using Traefik
|
||||
|
||||
If using Traefik, ensure headers are passed:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
picpeak:
|
||||
labels:
|
||||
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Check if Authorization header is reaching the backend:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
|
||||
```
|
||||
|
||||
2. Check nginx logs to see if the header is present:
|
||||
```bash
|
||||
tail -f /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
## Docker Compose Fix
|
||||
|
||||
If using docker-compose with nginx proxy, add:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NGINX_PROXY_PASS_HEADER=Authorization
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
+13
-8
@@ -1,11 +1,16 @@
|
||||
# Backend API URL
|
||||
VITE_API_URL=http://localhost:3000
|
||||
# For local development:
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# Umami Analytics Configuration
|
||||
# Get these values from your Umami installation
|
||||
VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
|
||||
# For production behind reverse proxy (Traefik, nginx, etc):
|
||||
# VITE_API_URL=/api
|
||||
|
||||
# Optional: Umami share URL for embedding full dashboard
|
||||
# This is the public share URL from Umami's share feature
|
||||
VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
|
||||
# Umami Analytics Configuration (OPTIONAL - Fallback only)
|
||||
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
|
||||
# These environment variables serve as fallbacks when backend settings are not available
|
||||
# Useful for: development environments, initial setup, or when backend is unavailable
|
||||
#
|
||||
# Example values:
|
||||
# VITE_UMAMI_URL=https://analytics.example.com
|
||||
# VITE_UMAMI_WEBSITE_ID=abc123def-4567-89ab-cdef-0123456789ab
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.example.com/share/xyz789/wedding-photos
|
||||
@@ -8,7 +8,11 @@ VITE_API_URL=/api
|
||||
# For development or if frontend/backend are on different domains:
|
||||
# VITE_API_URL=https://api.yourdomain.com
|
||||
|
||||
# Umami Analytics Configuration (optional)
|
||||
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
|
||||
# Umami Analytics Configuration (OPTIONAL - Fallback only)
|
||||
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
|
||||
# These environment variables serve as fallbacks when backend settings are not available
|
||||
#
|
||||
# Real-world example values:
|
||||
# VITE_UMAMI_URL=https://analytics.picpeak.com
|
||||
# VITE_UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.picpeak.com/share/Ab3Cd5Fg/picpeak-gallery
|
||||
Generated
+602
-462
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user