Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88659f1fa6 | |||
| c1e10f14a3 | |||
| 0881a0fa71 | |||
| e91209f7cb | |||
| 828d6bc456 | |||
| f945573f09 | |||
| 296430e4d7 | |||
| 7b517fa290 | |||
| 2c9a56f217 | |||
| 986b101040 | |||
| 0c283717cb | |||
| 4029559954 | |||
| 9c943bc69a | |||
| 29a8ff914c | |||
| a73d217273 | |||
| 1b4b497fdf | |||
| 827eb4819b | |||
| 086a4ca342 | |||
| 6de64a1df1 | |||
| 3074748bbc | |||
| 934d6ddc58 | |||
| a699a0477b | |||
| ed0243ec39 | |||
| ac31798bf5 | |||
| 65d796b9f0 | |||
| 6389b9df3f | |||
| 87d1761091 | |||
| fda132eed4 | |||
| 1cadce196b | |||
| 840b8870ec | |||
| ad495a92c4 | |||
| b428543452 | |||
| 6492cb9ec8 | |||
| 0e0a0b91d1 | |||
| f8fb1c3f4b |
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"timestamp": 1756499536862,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25283330048,
|
||||
"memoryFree": 486473728,
|
||||
"memoryUsagePercent": 98.11223347981772,
|
||||
"memoryEfficiency": 1.8877665201822822,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.21784319196428573,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185690
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499566862,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25547767808,
|
||||
"memoryFree": 222035968,
|
||||
"memoryUsagePercent": 99.13838704427084,
|
||||
"memoryEfficiency": 0.8616129557291572,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.181396484375,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185720
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499596863,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25573294080,
|
||||
"memoryFree": 196509696,
|
||||
"memoryUsagePercent": 99.23744201660156,
|
||||
"memoryEfficiency": 0.7625579833984375,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.17428152901785715,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185750
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499626864,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25607421952,
|
||||
"memoryFree": 162381824,
|
||||
"memoryUsagePercent": 99.3698755900065,
|
||||
"memoryEfficiency": 0.6301244099934991,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.17811802455357142,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185780
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499656866,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25676906496,
|
||||
"memoryFree": 92897280,
|
||||
"memoryUsagePercent": 99.63951110839844,
|
||||
"memoryEfficiency": 0.3604888916015625,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.14320591517857142,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185810
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499686866,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25690243072,
|
||||
"memoryFree": 79560704,
|
||||
"memoryUsagePercent": 99.69126383463541,
|
||||
"memoryEfficiency": 0.3087361653645928,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.12803431919642858,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185840
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499716867,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25551355904,
|
||||
"memoryFree": 218447872,
|
||||
"memoryUsagePercent": 99.15231068929037,
|
||||
"memoryEfficiency": 0.8476893107096259,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.17728097098214285,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185870
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499746868,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25028608000,
|
||||
"memoryFree": 741195776,
|
||||
"memoryUsagePercent": 97.1237818400065,
|
||||
"memoryEfficiency": 2.876218159993499,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.14596121651785715,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185900
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499776869,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25660850176,
|
||||
"memoryFree": 108953600,
|
||||
"memoryUsagePercent": 99.57720438639322,
|
||||
"memoryEfficiency": 0.4227956136067803,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.16322544642857142,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185930
|
||||
},
|
||||
{
|
||||
"timestamp": 1756499806870,
|
||||
"memoryTotal": 25769803776,
|
||||
"memoryUsed": 25489965056,
|
||||
"memoryFree": 279838720,
|
||||
"memoryUsagePercent": 98.91408284505209,
|
||||
"memoryEfficiency": 1.0859171549479072,
|
||||
"cpuCount": 14,
|
||||
"cpuLoad": 0.15792410714285715,
|
||||
"platform": "darwin",
|
||||
"uptime": 5185960
|
||||
}
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
# Development Environment with PostgreSQL
|
||||
# Copy this to .env for PostgreSQL development with Docker Compose
|
||||
|
||||
# JWT Secret (development only)
|
||||
JWT_SECRET=dev-secret-key-do-not-use-in-production
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak_dev
|
||||
DB_PASSWORD=dev_password_123
|
||||
DB_NAME=picpeak_dev
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_PASSWORD=dev_redis_pass
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@localhost
|
||||
|
||||
# Email Configuration (Disabled for development)
|
||||
# To enable email, configure a real SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@picpeak.local
|
||||
|
||||
# Application URLs
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
ADMIN_URL=http://localhost:3001
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Analytics (Optional - leave empty for development)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
@@ -1,65 +1,53 @@
|
||||
# PicPeak Development Environment Configuration
|
||||
# Copy this file to .env for local development
|
||||
# PicPeak Environment Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# SECURITY WARNING: This configuration is for development only!
|
||||
# For production, use .env.production.example
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (Change in production!)
|
||||
# Generate secure secret with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Application URLs (Docker Compose development setup)
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
BACKEND_URL=http://localhost:3001
|
||||
|
||||
# Database Configuration (PostgreSQL for development)
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=picpeak
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Redis Configuration
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
|
||||
# Email Configuration
|
||||
# For development with docker-compose.dev.yml:
|
||||
# Access Mailhog UI at: http://localhost:8025
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# For development without Docker, use real SMTP:
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-app-password
|
||||
# EMAIL_FROM=PicPeak Dev <your-email@gmail.com>
|
||||
# Application URLs
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com:3001
|
||||
VITE_API_URL=https://yourdomain.com:3001/api
|
||||
|
||||
# Backend Port Configuration
|
||||
PORT=3001
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
# FRONTEND_PORT=3000
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# 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
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Development Features
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Storage Settings (optional)
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
||||
|
||||
# Admin Setup Notes:
|
||||
# 1. Run 'docker-compose -f docker-compose.dev.yml up -d'
|
||||
# 2. Run 'docker-compose -f docker-compose.dev.yml exec backend npm run migrate'
|
||||
# 3. Admin credentials will be auto-generated
|
||||
# 4. Check backend/ADMIN_CREDENTIALS.txt for login details
|
||||
# 5. Change password on first login (required)
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
@@ -1,107 +0,0 @@
|
||||
# PicPeak Production Configuration
|
||||
# Copy this file to .env and update with your production values
|
||||
|
||||
# ============================================
|
||||
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
|
||||
# ============================================
|
||||
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# DATABASE CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# PostgreSQL Configuration (Recommended for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=db # Use 'db' for Docker Compose, or external host
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
|
||||
|
||||
# ============================================
|
||||
# EMAIL CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# 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 # Required if using Umami
|
||||
|
||||
# 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
|
||||
# ============================================
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Backend URL (if different from frontend)
|
||||
# BACKEND_URL=https://api.your-domain.com
|
||||
|
||||
# Storage Settings
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
||||
|
||||
# Security Settings (Defaults are secure)
|
||||
BCRYPT_ROUNDS=12
|
||||
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
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
# Note: For GitHub fine-grained tokens, ensure the token has:
|
||||
# - Repository access to the-luap/picpeak
|
||||
# - Repository permissions: Contents (Read and Write), Metadata (Read)
|
||||
# For classic tokens: repo scope is sufficient
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -29,12 +33,15 @@ jobs:
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/ || true
|
||||
rm -rf scripts/install-gitea-runner.sh || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf storage/ || true
|
||||
|
||||
rm -rf events/ || true
|
||||
rm -rf .playwright-mcp/
|
||||
rm -rf .swarm || true
|
||||
rm -rf .claude-flow || true
|
||||
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
@@ -57,29 +64,68 @@ jobs:
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
|
||||
echo ""
|
||||
echo "For fine-grained tokens:"
|
||||
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
|
||||
echo " - Create token with repository access to the-luap/picpeak"
|
||||
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
|
||||
echo ""
|
||||
echo "For classic tokens:"
|
||||
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
|
||||
echo " - Create token with 'repo' scope"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
echo "✅ GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
# Try to detect token type (fine-grained tokens are typically longer)
|
||||
if [ ${#GITHUBTOKEN} -gt 80 ]; then
|
||||
echo "📌 Token appears to be a fine-grained personal access token"
|
||||
else
|
||||
echo "📌 Token appears to be a classic personal access token"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
GIT_TRACE: 1 # Enable Git trace for debugging if needed
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
# Configure Git to use the token for authentication
|
||||
# This method works for both classic and fine-grained tokens
|
||||
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
|
||||
# Add GitHub remote (clean URL without credentials)
|
||||
git remote add github https://github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
echo "GitHub remote configuration:"
|
||||
git remote -v
|
||||
|
||||
# Push to GitHub main branch
|
||||
# Push to GitHub main branch with error handling
|
||||
echo "Pushing to GitHub..."
|
||||
git push github main --force
|
||||
echo "✅ Push to GitHub completed!"
|
||||
if git push github main --force 2>&1; then
|
||||
echo "✅ Push to GitHub completed successfully!"
|
||||
else
|
||||
echo "❌ Push to GitHub failed!"
|
||||
echo ""
|
||||
echo "Common issues and solutions:"
|
||||
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
|
||||
echo "2. Token expiration: Check if your token has expired"
|
||||
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
|
||||
echo ""
|
||||
echo "For fine-grained tokens, required permissions:"
|
||||
echo " - Repository access: the-luap/picpeak"
|
||||
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
|
||||
echo ""
|
||||
echo "For classic tokens, required scope: 'repo'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the git config after push
|
||||
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
|
||||
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
|
||||
- 🏷️ **Smart tagging** based on branches, versions, and commits
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
|
||||
## Authentication
|
||||
|
||||
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
|
||||
|
||||
### Required Permissions
|
||||
|
||||
The workflow automatically sets the necessary permissions:
|
||||
- `contents: read` - To checkout the repository
|
||||
- `packages: write` - To push images to ghcr.io
|
||||
- `security-events: write` - To upload security scan results
|
||||
|
||||
## Image Tags
|
||||
|
||||
Images are automatically tagged based on the trigger event:
|
||||
|
||||
| Event | Tags Generated |
|
||||
|-------|---------------|
|
||||
| Push to main | `latest`, `main`, `main-<short-sha>` |
|
||||
| Push to develop | `develop`, `develop-<short-sha>` |
|
||||
| Pull Request | `pr-<number>` |
|
||||
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
| Manual trigger | Based on branch + optional push |
|
||||
|
||||
## Usage
|
||||
|
||||
### Pull Images
|
||||
|
||||
Once published, images can be pulled using:
|
||||
|
||||
```bash
|
||||
# Pull backend image
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
```
|
||||
|
||||
### Using in Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: picpeak-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
## Manual Workflow Trigger
|
||||
|
||||
You can manually trigger the workflow from the Actions tab:
|
||||
|
||||
1. Go to Actions → "Build and Push Docker Images"
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and whether to push images
|
||||
4. Click "Run workflow"
|
||||
|
||||
## Security Scanning
|
||||
|
||||
The workflow includes Trivy vulnerability scanning that:
|
||||
- Scans for CRITICAL and HIGH severity vulnerabilities
|
||||
- Uploads results to GitHub Security tab
|
||||
- Available under Security → Code scanning alerts
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The workflow uses several optimization techniques:
|
||||
|
||||
1. **GitHub Actions Cache**: Speeds up builds by caching layers
|
||||
2. **Multi-stage builds**: Reduces final image size
|
||||
3. **Parallel builds**: Backend and frontend build simultaneously
|
||||
4. **Smart rebuilds**: Only rebuilds changed components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
If you encounter permission errors when pushing images:
|
||||
|
||||
1. **First-time setup**: The first push creates a private package. You may need to:
|
||||
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
|
||||
- Link the package to your repository
|
||||
- Set package visibility (public/private)
|
||||
|
||||
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
|
||||
|
||||
### Build Failures
|
||||
|
||||
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
|
||||
- Missing dependencies in package.json
|
||||
- Dockerfile syntax errors
|
||||
- Network issues during package installation
|
||||
|
||||
### Image Not Found
|
||||
|
||||
If images aren't visible after successful push:
|
||||
- Check package visibility settings
|
||||
- Ensure you're authenticated to pull private images:
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
### View Packages
|
||||
|
||||
Your Docker images are available at:
|
||||
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
|
||||
|
||||
### Delete Old Versions
|
||||
|
||||
To save storage, you can delete old versions:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage versions"
|
||||
3. Select versions to delete
|
||||
4. Click "Delete selected versions"
|
||||
|
||||
### Set Retention Policy
|
||||
|
||||
Configure automatic cleanup in package settings:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage Actions access"
|
||||
3. Set retention days for untagged versions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic versioning** for releases (e.g., v1.2.3)
|
||||
2. **Test images locally** before pushing to production
|
||||
3. **Monitor security alerts** from Trivy scans
|
||||
4. **Clean up old images** regularly to save storage
|
||||
5. **Use specific tags** in production (avoid `latest`)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Registry
|
||||
|
||||
To use a different registry, update the workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: docker.io # or your custom registry
|
||||
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
|
||||
```
|
||||
|
||||
### Additional Platforms
|
||||
|
||||
To build for more platforms:
|
||||
|
||||
```yaml
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
```
|
||||
|
||||
### Custom Build Arguments
|
||||
|
||||
Add build arguments in the workflow:
|
||||
|
||||
```yaml
|
||||
build-args: |
|
||||
NODE_VERSION=20
|
||||
API_URL=${{ secrets.API_URL }}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
||||
- [Docker Build Action](https://github.com/docker/build-push-action)
|
||||
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
|
||||
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -0,0 +1,223 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push images to registry'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
|
||||
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
|
||||
# automatically creates multi-arch manifests when building for multiple platforms.
|
||||
# The images are already properly tagged and include all architectures.
|
||||
|
||||
summary:
|
||||
needs: [build-backend, build-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -334,6 +334,23 @@ Run backup service test: `npm run test-backup`
|
||||
- `backup_runs`: Tracks each backup execution with statistics
|
||||
- `backup_file_states`: Stores file checksums for change detection
|
||||
|
||||
## Thumbnail Generation
|
||||
|
||||
### Square Thumbnail Implementation (Issue #12 Fix)
|
||||
The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid:
|
||||
|
||||
- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion
|
||||
- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning
|
||||
- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format`
|
||||
- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings
|
||||
- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails
|
||||
|
||||
### Thumbnail Settings API
|
||||
- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration
|
||||
- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration)
|
||||
- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings
|
||||
- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
@@ -365,4 +382,5 @@ Run backup service test: `npm run test-backup`
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
- always use docker deployment for testing
|
||||
@@ -40,6 +40,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
@@ -71,7 +72,7 @@ docker-compose up -d
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
@@ -177,6 +178,17 @@ Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
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.
|
||||
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
@@ -209,7 +221,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
@@ -219,6 +231,6 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
@@ -0,0 +1,519 @@
|
||||
# 🚀 PicPeak Simple Setup Guide
|
||||
|
||||
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
|
||||
## 🎯 Installation Methods
|
||||
|
||||
### Method 1: Docker Installation (Recommended)
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Easier installation and updates
|
||||
- ✅ Better isolation from system
|
||||
- ✅ Consistent environment across platforms
|
||||
- ✅ Built-in PostgreSQL and Redis
|
||||
|
||||
**Cons:**
|
||||
- ❌ Requires more resources (~4GB RAM recommended)
|
||||
- ❌ Additional Docker overhead
|
||||
|
||||
### Method 2: Native Installation
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Lower resource usage (~1GB RAM minimum)
|
||||
- ✅ Direct system control
|
||||
- ✅ No Docker overhead
|
||||
- ✅ Better for ARM devices
|
||||
|
||||
**Cons:**
|
||||
- ❌ More complex setup
|
||||
- ❌ System dependencies required
|
||||
- ❌ Manual update process
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
|
||||
- **RAM**:
|
||||
- Docker: 2GB minimum (4GB recommended)
|
||||
- Native: 1GB minimum (2GB recommended)
|
||||
- **Storage**: 2GB for application + space for photos
|
||||
- **Network**: Port 3001 (or 80/443 with proxy)
|
||||
|
||||
### Supported Platforms
|
||||
- ✅ Ubuntu 20.04, 22.04, 24.04
|
||||
- ✅ Debian 11, 12
|
||||
- ✅ Raspberry Pi OS (32-bit and 64-bit)
|
||||
- ✅ Fedora 38, 39, 40
|
||||
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
|
||||
|
||||
## 🛠️ Installation Options
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
1. Installation method (Docker or Native)
|
||||
2. Admin email and password
|
||||
3. Domain configuration (optional)
|
||||
4. Email server settings (optional)
|
||||
5. SSL/HTTPS setup (optional)
|
||||
|
||||
### Unattended Installation
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
--smtp-host smtp.gmail.com \
|
||||
--smtp-port 587 \
|
||||
--smtp-user your-email@gmail.com \
|
||||
--smtp-pass your-app-password \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `--docker` | Use Docker installation | `--docker` |
|
||||
| `--native` | Use native installation | `--native` |
|
||||
| `--unattended` | Run without prompts | `--unattended` |
|
||||
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
|
||||
| `--email` | Admin email address | `--email admin@example.com` |
|
||||
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
|
||||
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
|
||||
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
|
||||
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
|
||||
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
|
||||
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
|
||||
| `--port` | Custom port (native only) | `--port 8080` |
|
||||
| `--update` | Update existing installation | `--update` |
|
||||
| `--uninstall` | Remove installation | `--uninstall` |
|
||||
| `--help` | Show help message | `--help` |
|
||||
|
||||
## 🏗️ What Gets Installed
|
||||
|
||||
### Docker Installation
|
||||
```
|
||||
~/picpeak/ # Or custom directory
|
||||
├── docker-compose.yml # Service definitions
|
||||
├── .env # Configuration
|
||||
├── storage/
|
||||
│ └── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── backup/ # Backup directory
|
||||
```
|
||||
|
||||
**Services:**
|
||||
- PicPeak Backend (Node.js application)
|
||||
- PostgreSQL Database
|
||||
- Redis Cache
|
||||
- Nginx Reverse Proxy (optional)
|
||||
- Background Workers
|
||||
|
||||
### Native Installation
|
||||
```
|
||||
/opt/picpeak/ # Installation directory
|
||||
├── backend/ # Application code
|
||||
├── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── config/ # Configuration files
|
||||
```
|
||||
|
||||
**Services (systemd):**
|
||||
- `picpeak-backend` - Main application
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
|
||||
- **Native**: `http://your-server:3001/admin` (admin panel)
|
||||
|
||||
### With Domain & HTTPS
|
||||
If configured during setup:
|
||||
- `https://your-domain.com` - Gallery frontend
|
||||
- `https://your-domain.com/admin` - Admin panel
|
||||
|
||||
### Behind Existing Proxy
|
||||
Add to your Nginx/Apache configuration:
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
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;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Managing Galleries
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Method 1: Via Admin Panel (Recommended)
|
||||
1. Login to admin panel
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings and upload photos
|
||||
|
||||
#### Method 2: File System
|
||||
```bash
|
||||
# Docker installation
|
||||
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
|
||||
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
|
||||
|
||||
# Native installation
|
||||
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
|
||||
```
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
wedding-smith-2024/
|
||||
├── collages/ # Group photos
|
||||
├── individual/ # Individual photos
|
||||
└── thumbnails/ # Auto-generated thumbnails
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd ~/picpeak
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
|
||||
# Update PicPeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Native Installation
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status picpeak-backend
|
||||
sudo systemctl status picpeak-workers
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u picpeak-backend -f
|
||||
sudo journalctl -u picpeak-workers -f
|
||||
|
||||
# Start services
|
||||
sudo systemctl start picpeak-backend picpeak-workers
|
||||
|
||||
# Stop services
|
||||
sudo systemctl stop picpeak-backend picpeak-workers
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
sudo ./setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Docker Configuration
|
||||
Edit `~/picpeak/.env`:
|
||||
```bash
|
||||
nano ~/picpeak/.env
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
### Key Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `JWT_SECRET` | Token signing secret | Auto-generated |
|
||||
| `ADMIN_EMAIL` | Admin email | admin@example.com |
|
||||
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
|
||||
| `PHOTOS_DIR` | Photo storage path | Varies by method |
|
||||
| `SMTP_ENABLED` | Email notifications | false |
|
||||
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
|
||||
|
||||
## 📧 Email Configuration
|
||||
|
||||
### Gmail Setup
|
||||
1. Enable 2-Factor Authentication
|
||||
2. Generate App Password
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### SendGrid Setup
|
||||
1. Sign up at sendgrid.com (100 emails/day free)
|
||||
2. Create API key
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
SMTP_FROM=verified-sender@yourdomain.com
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backups
|
||||
|
||||
#### Docker:
|
||||
```bash
|
||||
# Backup script included
|
||||
cd ~/picpeak
|
||||
./backup.sh
|
||||
|
||||
# Manual backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
|
||||
tar -czf photos-backup.tar.gz storage/events/
|
||||
```
|
||||
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
|
||||
|
||||
# Photos backup
|
||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
cd ~/picpeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Service Won't Start
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs backend
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo journalctl -u picpeak-backend -n 50
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
#### Can't Access Admin Panel
|
||||
1. Check firewall:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 3001
|
||||
|
||||
# RHEL/CentOS
|
||||
sudo firewall-cmd --add-port=3001/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
2. Verify service:
|
||||
```bash
|
||||
# Docker
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Native
|
||||
sudo systemctl is-active picpeak-backend
|
||||
```
|
||||
|
||||
#### Photos Not Showing
|
||||
```bash
|
||||
# Check permissions (Native)
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/
|
||||
sudo chmod -R 755 /opt/picpeak/events/
|
||||
|
||||
# Check permissions (Docker)
|
||||
ls -la ~/picpeak/storage/events/
|
||||
```
|
||||
|
||||
#### Reset Admin Password
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check logs:**
|
||||
- Docker: `docker compose logs -f`
|
||||
- Native: `sudo journalctl -u picpeak-backend -f`
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
|
||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### Essential Security
|
||||
1. **Change default admin password immediately**
|
||||
2. **Use HTTPS for production** (Let's Encrypt included)
|
||||
3. **Configure firewall** (only open necessary ports)
|
||||
4. **Regular updates** (system and PicPeak)
|
||||
5. **Automated backups** (configure in admin panel)
|
||||
|
||||
### Advanced Security
|
||||
- Use VPN for admin panel access
|
||||
- Configure fail2ban for brute force protection
|
||||
- Enable audit logging
|
||||
- Regular security scans
|
||||
- Implement IP whitelisting
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Docker Optimization
|
||||
```yaml
|
||||
# Adjust in docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
## 🎯 Quick Setup Examples
|
||||
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
## ✅ Post-Installation Checklist
|
||||
|
||||
- [ ] Admin password changed
|
||||
- [ ] Email configuration tested
|
||||
- [ ] First test gallery created
|
||||
- [ ] Backup schedule configured
|
||||
- [ ] Firewall rules applied
|
||||
- [ ] SSL certificate working (if applicable)
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Documentation bookmarked
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
@@ -1,7 +1,15 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -18,10 +18,15 @@ exports.up = async function(knex) {
|
||||
const generatedPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||
|
||||
// Get admin credentials from environment or use defaults
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
|
||||
await knex('admin_users').insert({
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
username: adminUsername,
|
||||
email: adminEmail,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
@@ -36,7 +41,7 @@ PicPeak Admin Credentials
|
||||
|
||||
Your admin account has been created with these credentials:
|
||||
|
||||
Username: admin
|
||||
Email: ${adminEmail}
|
||||
Password: ${generatedPassword}
|
||||
|
||||
IMPORTANT SECURITY NOTES:
|
||||
@@ -47,6 +52,8 @@ IMPORTANT SECURITY NOTES:
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
|
||||
Login with the email address shown above
|
||||
|
||||
Generated on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
@@ -65,7 +72,7 @@ Generated on: ${new Date().toISOString()}
|
||||
console.log('\n========================================');
|
||||
console.log('✅ Admin user created successfully!');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Email: ${adminEmail}`);
|
||||
console.log(`Password: ${generatedPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. Save these credentials securely');
|
||||
|
||||
@@ -3,102 +3,125 @@
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding gallery feedback tables...');
|
||||
|
||||
// Check if tables and columns already exist
|
||||
const hasEventFeedbackSettingsTable = await knex.schema.hasTable('event_feedback_settings');
|
||||
const hasPhotoFeedbackTable = await knex.schema.hasTable('photo_feedback');
|
||||
const hasFeedbackRateLimitsTable = await knex.schema.hasTable('feedback_rate_limits');
|
||||
const hasFeedbackWordFiltersTable = await knex.schema.hasTable('feedback_word_filters');
|
||||
const hasFeedbackCountColumn = await knex.schema.hasColumn('photos', 'feedback_count');
|
||||
|
||||
// Create event_feedback_settings table
|
||||
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.boolean('feedback_enabled').defaultTo(false);
|
||||
table.boolean('allow_ratings').defaultTo(true);
|
||||
table.boolean('allow_likes').defaultTo(true);
|
||||
table.boolean('allow_comments').defaultTo(false);
|
||||
table.boolean('allow_favorites').defaultTo(true);
|
||||
table.boolean('require_name_email').defaultTo(false);
|
||||
table.boolean('moderate_comments').defaultTo(true);
|
||||
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['event_id']);
|
||||
});
|
||||
if (!hasEventFeedbackSettingsTable) {
|
||||
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.boolean('feedback_enabled').defaultTo(false);
|
||||
table.boolean('allow_ratings').defaultTo(true);
|
||||
table.boolean('allow_likes').defaultTo(true);
|
||||
table.boolean('allow_comments').defaultTo(false);
|
||||
table.boolean('allow_favorites').defaultTo(true);
|
||||
table.boolean('require_name_email').defaultTo(false);
|
||||
table.boolean('moderate_comments').defaultTo(true);
|
||||
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create photo_feedback table
|
||||
await knex.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('feedback_type', 20).notNullable();
|
||||
table.integer('rating');
|
||||
table.text('comment_text');
|
||||
table.string('guest_name', 100);
|
||||
table.string('guest_email', 255);
|
||||
table.string('guest_identifier', 64);
|
||||
table.string('ip_address', 45);
|
||||
table.text('user_agent');
|
||||
table.boolean('is_approved').defaultTo(true);
|
||||
table.boolean('is_hidden').defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['photo_id']);
|
||||
table.index(['event_id']);
|
||||
table.index(['feedback_type']);
|
||||
table.index(['guest_identifier']);
|
||||
|
||||
// Add check constraint for rating (PostgreSQL)
|
||||
if (knex.client.config.client === 'pg') {
|
||||
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||
}
|
||||
});
|
||||
if (!hasPhotoFeedbackTable) {
|
||||
await knex.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('feedback_type', 20).notNullable();
|
||||
table.integer('rating');
|
||||
table.text('comment_text');
|
||||
table.string('guest_name', 100);
|
||||
table.string('guest_email', 255);
|
||||
table.string('guest_identifier', 64);
|
||||
table.string('ip_address', 45);
|
||||
table.text('user_agent');
|
||||
table.boolean('is_approved').defaultTo(true);
|
||||
table.boolean('is_hidden').defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['photo_id']);
|
||||
table.index(['event_id']);
|
||||
table.index(['feedback_type']);
|
||||
table.index(['guest_identifier']);
|
||||
|
||||
// Add check constraint for rating (PostgreSQL)
|
||||
if (knex.client.config.client === 'pg') {
|
||||
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create feedback_rate_limits table
|
||||
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier', 64).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('action_type', 20).notNullable();
|
||||
table.integer('action_count').defaultTo(1);
|
||||
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['identifier', 'event_id', 'action_type']);
|
||||
table.index(['window_start']);
|
||||
});
|
||||
if (!hasFeedbackRateLimitsTable) {
|
||||
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier', 64).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('action_type', 20).notNullable();
|
||||
table.integer('action_count').defaultTo(1);
|
||||
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['identifier', 'event_id', 'action_type']);
|
||||
table.index(['window_start']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create feedback_word_filters table
|
||||
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('word', 100).notNullable();
|
||||
table.string('severity', 20).defaultTo('moderate');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.unique(['word']);
|
||||
});
|
||||
if (!hasFeedbackWordFiltersTable) {
|
||||
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('word', 100).notNullable();
|
||||
table.string('severity', 20).defaultTo('moderate');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.unique(['word']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add feedback summary columns to photos table
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('feedback_count').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
if (!hasFeedbackCountColumn) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('feedback_count').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Add feedback notification settings to app_settings
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'feedback_notification_email',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'feedback'
|
||||
},
|
||||
{
|
||||
setting_key: 'feedback_rate_limits',
|
||||
setting_value: JSON.stringify({
|
||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||
like: { max: 200, window: 3600 } // 200 likes per hour
|
||||
}),
|
||||
setting_type: 'feedback'
|
||||
}
|
||||
]);
|
||||
const hasFeedbackNotificationEmail = await knex('app_settings')
|
||||
.where('setting_key', 'feedback_notification_email')
|
||||
.first();
|
||||
|
||||
if (!hasFeedbackNotificationEmail) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'feedback_notification_email',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'feedback'
|
||||
},
|
||||
{
|
||||
setting_key: 'feedback_rate_limits',
|
||||
setting_value: JSON.stringify({
|
||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||
like: { max: 200, window: 3600 } // 200 likes per hour
|
||||
}),
|
||||
setting_type: 'feedback'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Gallery feedback tables created successfully');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Fix missing columns identified in GitHub issues
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding missing columns to database tables...');
|
||||
|
||||
// Add must_change_password column to admin_users table
|
||||
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||
if (!hasMustChangePassword) {
|
||||
console.log('Adding must_change_password column to admin_users table...');
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.boolean('must_change_password').defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Add password_changed_at column to admin_users table
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
if (!hasPasswordChangedAt) {
|
||||
console.log('Adding password_changed_at column to admin_users table...');
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.datetime('password_changed_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Add require_moderation column to event_feedback_settings table
|
||||
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||
if (hasEventFeedbackSettings) {
|
||||
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||
if (!hasRequireModeration) {
|
||||
console.log('Adding require_moderation column to event_feedback_settings table...');
|
||||
await knex.schema.table('event_feedback_settings', (table) => {
|
||||
table.boolean('require_moderation').defaultTo(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add host_name column to events table if missing
|
||||
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||
if (!hasHostName) {
|
||||
console.log('Adding host_name column to events table...');
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Missing columns have been added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing added columns...');
|
||||
|
||||
// Remove must_change_password column from admin_users table
|
||||
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||
if (hasMustChangePassword) {
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.dropColumn('must_change_password');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove password_changed_at column from admin_users table
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
if (hasPasswordChangedAt) {
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.dropColumn('password_changed_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove require_moderation column from event_feedback_settings table
|
||||
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||
if (hasEventFeedbackSettings) {
|
||||
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||
if (hasRequireModeration) {
|
||||
await knex.schema.table('event_feedback_settings', (table) => {
|
||||
table.dropColumn('require_moderation');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove host_name column from events table
|
||||
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||
if (hasHostName) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Columns removed');
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Add download control features to events table
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding download control columns to events table...');
|
||||
|
||||
// Add download control columns to events table
|
||||
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||
if (!hasAllowDownloads) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
});
|
||||
}
|
||||
|
||||
// Add download control settings to app_settings
|
||||
const downloadSettingExists = await knex('app_settings')
|
||||
.where('setting_key', 'default_allow_downloads')
|
||||
.first();
|
||||
|
||||
if (!downloadSettingExists) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'default_allow_downloads',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'gallery'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_disable_right_click',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'gallery'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_watermark_downloads',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'gallery'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Download control features added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing download control columns...');
|
||||
|
||||
// Remove app settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_allow_downloads',
|
||||
'default_disable_right_click',
|
||||
'default_watermark_downloads'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Remove columns from events table
|
||||
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||
if (hasAllowDownloads) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('allow_downloads');
|
||||
table.dropColumn('disable_right_click');
|
||||
table.dropColumn('watermark_downloads');
|
||||
table.dropColumn('watermark_text');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Download control columns removed');
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// Add enhanced image protection features
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding enhanced image protection features...');
|
||||
|
||||
// Add protection columns to events table
|
||||
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
|
||||
if (!hasProtectionLevel) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.enum('protection_level', ['basic', 'standard', 'enhanced', 'maximum']).defaultTo('standard');
|
||||
table.integer('image_quality').defaultTo(85);
|
||||
table.boolean('add_fingerprint').defaultTo(true);
|
||||
table.boolean('enable_devtools_protection').defaultTo(true);
|
||||
table.boolean('use_canvas_rendering').defaultTo(false);
|
||||
table.integer('fragmentation_level').defaultTo(3);
|
||||
table.boolean('overlay_protection').defaultTo(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Create image access logs table
|
||||
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
|
||||
if (!hasImageAccessLogs) {
|
||||
await knex.schema.createTable('image_access_logs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').unsigned().notNullable();
|
||||
table.integer('event_id').unsigned().notNullable();
|
||||
table.string('client_ip', 45).notNullable();
|
||||
table.text('user_agent');
|
||||
table.string('access_type', 20).defaultTo('view'); // view, download, suspicious
|
||||
table.string('client_fingerprint', 32).notNullable();
|
||||
table.timestamp('accessed_at').defaultTo(knex.fn.now());
|
||||
table.json('metadata'); // Additional security metadata
|
||||
|
||||
table.foreign('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.foreign('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
|
||||
table.index(['photo_id', 'accessed_at']);
|
||||
table.index(['client_fingerprint', 'accessed_at']);
|
||||
table.index(['client_ip', 'accessed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add protection settings to app_settings
|
||||
const protectionSettingExists = await knex('app_settings')
|
||||
.where('setting_key', 'default_protection_level')
|
||||
.first();
|
||||
|
||||
if (!protectionSettingExists) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'default_protection_level',
|
||||
setting_value: JSON.stringify('standard'),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_image_quality',
|
||||
setting_value: JSON.stringify(85),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'enable_devtools_protection',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_minute',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'suspicious_activity_threshold',
|
||||
setting_value: JSON.stringify(10),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'enable_canvas_rendering',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_fragmentation_level',
|
||||
setting_value: JSON.stringify(3),
|
||||
setting_type: 'security'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Enhanced image protection features added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing enhanced image protection features...');
|
||||
|
||||
// Remove app settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_devtools_protection',
|
||||
'max_image_requests_per_minute',
|
||||
'suspicious_activity_threshold',
|
||||
'enable_canvas_rendering',
|
||||
'default_fragmentation_level'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Drop image access logs table
|
||||
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
|
||||
if (hasImageAccessLogs) {
|
||||
await knex.schema.dropTable('image_access_logs');
|
||||
}
|
||||
|
||||
// Remove protection columns from events table
|
||||
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
|
||||
if (hasProtectionLevel) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('protection_level');
|
||||
table.dropColumn('image_quality');
|
||||
table.dropColumn('add_fingerprint');
|
||||
table.dropColumn('enable_devtools_protection');
|
||||
table.dropColumn('use_canvas_rendering');
|
||||
table.dropColumn('fragmentation_level');
|
||||
table.dropColumn('overlay_protection');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Enhanced image protection features removed');
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// Add security logging and monitoring tables
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding security logging and monitoring tables...');
|
||||
|
||||
// Create security logs table for general security events
|
||||
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
|
||||
if (!hasSecurityLogs) {
|
||||
await knex.schema.createTable('security_logs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('event_type', 50).notNullable(); // rate_limit_exceeded, suspicious_activity, etc.
|
||||
table.string('client_ip', 45).notNullable();
|
||||
table.string('client_fingerprint', 32);
|
||||
table.text('user_agent');
|
||||
table.string('request_path');
|
||||
table.string('request_method', 10);
|
||||
table.json('details'); // Additional event details
|
||||
table.timestamp('timestamp').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for performance
|
||||
table.index(['event_type', 'timestamp']);
|
||||
table.index(['client_ip', 'timestamp']);
|
||||
table.index(['client_fingerprint', 'timestamp']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add security monitoring settings to app_settings
|
||||
const securitySettings = [
|
||||
{
|
||||
setting_key: 'security_monitoring_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_5_minutes',
|
||||
setting_value: JSON.stringify(100),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_hour',
|
||||
setting_value: JSON.stringify(500),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'block_suspicious_ips',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'log_security_events_to_db',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'auto_block_threshold',
|
||||
setting_value: JSON.stringify(5),
|
||||
setting_type: 'security'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of securitySettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add mime_type column to photos table if it doesn't exist
|
||||
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
|
||||
if (!hasMimeType) {
|
||||
await knex.schema.table('photos', (table) => {
|
||||
table.string('mime_type', 100);
|
||||
});
|
||||
|
||||
// Update existing photos with default mime type
|
||||
await knex('photos')
|
||||
.whereNull('mime_type')
|
||||
.update({ mime_type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
console.log('Security logging and monitoring tables added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing security logging and monitoring tables...');
|
||||
|
||||
// Remove security settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'security_monitoring_enabled',
|
||||
'max_image_requests_per_5_minutes',
|
||||
'max_image_requests_per_hour',
|
||||
'block_suspicious_ips',
|
||||
'log_security_events_to_db',
|
||||
'auto_block_threshold'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Drop security logs table
|
||||
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
|
||||
if (hasSecurityLogs) {
|
||||
await knex.schema.dropTable('security_logs');
|
||||
}
|
||||
|
||||
// Remove mime_type column from photos table
|
||||
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
|
||||
if (hasMimeType) {
|
||||
await knex.schema.table('photos', (table) => {
|
||||
table.dropColumn('mime_type');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Security logging and monitoring tables removed');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add thumbnail settings to app_settings table
|
||||
const thumbnailSettings = [
|
||||
{ setting_key: 'thumbnail_width', setting_value: 300, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_height', setting_value: 300, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_fit', setting_value: JSON.stringify('cover'), setting_type: 'string' },
|
||||
{ setting_key: 'thumbnail_quality', setting_value: 85, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_format', setting_value: JSON.stringify('jpeg'), setting_type: 'string' }
|
||||
];
|
||||
|
||||
for (const setting of thumbnailSettings) {
|
||||
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 thumbnail settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -3,79 +3,103 @@ const { db } = require('../../src/database/db');
|
||||
async function up() {
|
||||
console.log('Adding photo categories and CMS tables...');
|
||||
|
||||
// Check if tables already exist
|
||||
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
|
||||
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
|
||||
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
|
||||
const hasLanguageColumn = await db.schema.hasColumn('admin_users', 'language');
|
||||
|
||||
// Create photo_categories table
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
|
||||
// Unique constraint for slug within event scope
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
|
||||
// Unique constraint for slug within event scope
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create cms_pages table
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
if (!hasCmsPagesTable) {
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Add category_id to photos table
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
if (!hasCategoryIdColumn) {
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
}
|
||||
|
||||
// Add language preference to admin_users
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
if (!hasLanguageColumn) {
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Add language preference to app_settings for global default
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: JSON.stringify('en'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
const hasDefaultLanguageSetting = await db('app_settings')
|
||||
.where('setting_key', 'default_language')
|
||||
.first();
|
||||
|
||||
if (!hasDefaultLanguageSetting) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: JSON.stringify('en'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Insert default global categories
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true }
|
||||
];
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true }
|
||||
];
|
||||
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
}
|
||||
|
||||
// Insert default legal pages
|
||||
await db('cms_pages').insert([
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
}
|
||||
]);
|
||||
if (!hasCmsPagesTable) {
|
||||
await db('cms_pages').insert([
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Photo categories and CMS tables created successfully');
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.createTable('login_attempts', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier').notNullable(); // username or email
|
||||
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
||||
table.text('user_agent');
|
||||
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
||||
table.boolean('success').defaultTo(false);
|
||||
|
||||
// Indexes for performance
|
||||
table.index('identifier');
|
||||
table.index('attempt_time');
|
||||
table.index(['identifier', 'success', 'attempt_time']);
|
||||
});
|
||||
exports.up = async function(knex) {
|
||||
const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts');
|
||||
|
||||
if (!hasLoginAttemptsTable) {
|
||||
return knex.schema.createTable('login_attempts', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier').notNullable(); // username or email
|
||||
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
||||
table.text('user_agent');
|
||||
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
||||
table.boolean('success').defaultTo(false);
|
||||
|
||||
// Indexes for performance
|
||||
table.index('identifier');
|
||||
table.index('attempt_time');
|
||||
table.index(['identifier', 'success', 'attempt_time']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
exports.up = function(knex) {
|
||||
exports.up = async function(knex) {
|
||||
// Check if columns already exist to avoid conflicts
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
const hasLastLoginIp = await knex.schema.hasColumn('admin_users', 'last_login_ip');
|
||||
const hasTwoFactorEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasTwoFactorSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
return knex.schema.table('admin_users', table => {
|
||||
// Add password change tracking
|
||||
table.timestamp('password_changed_at').nullable();
|
||||
if (!hasPasswordChangedAt) {
|
||||
table.timestamp('password_changed_at').nullable();
|
||||
}
|
||||
|
||||
// Add last login IP for security monitoring
|
||||
table.string('last_login_ip', 45).nullable();
|
||||
// Add last login IP for security monitoring
|
||||
if (!hasLastLoginIp) {
|
||||
table.string('last_login_ip', 45).nullable();
|
||||
}
|
||||
|
||||
// Add account security flags
|
||||
table.boolean('two_factor_enabled').defaultTo(false);
|
||||
table.string('two_factor_secret').nullable();
|
||||
|
||||
// Add index for performance
|
||||
table.index('password_changed_at');
|
||||
if (!hasTwoFactorEnabled) {
|
||||
table.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasTwoFactorSecret) {
|
||||
table.string('two_factor_secret').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
// Table for individual token revocations
|
||||
.createTable('revoked_tokens', table => {
|
||||
exports.up = async function(knex) {
|
||||
// Check if tables already exist to avoid conflicts
|
||||
const hasRevokedTokensTable = await knex.schema.hasTable('revoked_tokens');
|
||||
const hasUserTokenRevocationsTable = await knex.schema.hasTable('user_token_revocations');
|
||||
|
||||
// Create revoked_tokens table if it doesn't exist
|
||||
if (!hasRevokedTokensTable) {
|
||||
await knex.schema.createTable('revoked_tokens', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
||||
table.integer('user_id').nullable(); // User who owned the token
|
||||
@@ -15,9 +19,12 @@ exports.up = function(knex) {
|
||||
table.index('token_id');
|
||||
table.index('user_id');
|
||||
table.index('expires_at'); // For cleanup
|
||||
})
|
||||
// Table for user-level revocations (revoke all tokens before a certain time)
|
||||
.createTable('user_token_revocations', table => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create user_token_revocations table if it doesn't exist
|
||||
if (!hasUserTokenRevocationsTable) {
|
||||
await knex.schema.createTable('user_token_revocations', table => {
|
||||
table.integer('user_id').primary();
|
||||
table.timestamp('revoked_at').notNullable();
|
||||
table.string('reason', 100);
|
||||
@@ -25,6 +32,28 @@ exports.up = function(knex) {
|
||||
// Index for quick lookups
|
||||
table.index('revoked_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Add any missing indexes if tables already existed
|
||||
if (hasRevokedTokensTable) {
|
||||
try {
|
||||
// Try to add indexes if they don't exist (PostgreSQL syntax)
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_token_id_index" ON "revoked_tokens" ("token_id")');
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_user_id_index" ON "revoked_tokens" ("user_id")');
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_expires_at_index" ON "revoked_tokens" ("expires_at")');
|
||||
} catch (error) {
|
||||
// For SQLite compatibility, ignore errors if indexes already exist
|
||||
console.log('Note: Some indexes may already exist, continuing...');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUserTokenRevocationsTable) {
|
||||
try {
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "user_token_revocations_revoked_at_index" ON "user_token_revocations" ("revoked_at")');
|
||||
} catch (error) {
|
||||
console.log('Note: Some indexes may already exist, continuing...');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.106",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.106",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.106",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,11 +8,8 @@
|
||||
"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/",
|
||||
"test-backup": "node scripts/test-backup-service.js",
|
||||
"test-restore": "node scripts/test-restore-service.js"
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Fix migration state by marking migrations as applied if their tables already exist
|
||||
*/
|
||||
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function fixMigrationState() {
|
||||
try {
|
||||
console.log('Checking migration state...');
|
||||
|
||||
// Ensure migrations table exists
|
||||
const hasMigrationsTable = await db.schema.hasTable('migrations');
|
||||
if (!hasMigrationsTable) {
|
||||
await db.schema.createTable('migrations', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('filename').unique().notNullable();
|
||||
table.timestamp('applied_at').defaultTo(db.fn.now());
|
||||
});
|
||||
console.log('Created migrations tracking table');
|
||||
}
|
||||
|
||||
// Check for specific tables and mark their migrations as applied
|
||||
const tableChecks = [
|
||||
{ table: 'restore_runs', migration: '032_add_restore_runs_table.js' },
|
||||
{ table: 'restore_file_operations', migration: '032_add_restore_runs_table.js' },
|
||||
{ table: 'restore_validation_results', migration: '032_add_restore_runs_table.js' },
|
||||
{ table: 'gallery_feedback', migration: '033_add_gallery_feedback.js' },
|
||||
{ table: 'feedback_photos', migration: '033_add_gallery_feedback.js' },
|
||||
];
|
||||
|
||||
for (const check of tableChecks) {
|
||||
const tableExists = await db.schema.hasTable(check.table);
|
||||
if (tableExists) {
|
||||
const migrationApplied = await db('migrations')
|
||||
.where('filename', check.migration)
|
||||
.first();
|
||||
|
||||
if (!migrationApplied) {
|
||||
await db('migrations').insert({
|
||||
filename: check.migration,
|
||||
applied_at: new Date()
|
||||
});
|
||||
console.log(`✅ Marked ${check.migration} as applied (table ${check.table} exists)`);
|
||||
} else {
|
||||
console.log(`ℹ️ ${check.migration} already marked as applied`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nMigration state fixed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error fixing migration state:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
fixMigrationState();
|
||||
@@ -1,171 +0,0 @@
|
||||
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);
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Mark a specific migration as applied without running it
|
||||
* Usage: node scripts/mark-migration-applied.js <migration-filename>
|
||||
*/
|
||||
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function markMigrationAsApplied(filename) {
|
||||
try {
|
||||
// Check if migration is already marked
|
||||
const existing = await db('migrations')
|
||||
.where('filename', filename)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
console.log(`Migration ${filename} is already marked as applied`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as applied
|
||||
await db('migrations').insert({
|
||||
filename,
|
||||
applied_at: new Date()
|
||||
});
|
||||
|
||||
console.log(`✅ Migration ${filename} marked as applied`);
|
||||
} catch (error) {
|
||||
console.error('Error marking migration:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Get migration filename from command line
|
||||
const migrationFile = process.argv[2];
|
||||
|
||||
if (!migrationFile) {
|
||||
console.error('Usage: node scripts/mark-migration-applied.js <migration-filename>');
|
||||
console.error('Example: node scripts/mark-migration-applied.js 032_add_restore_runs_table.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
markMigrationAsApplied(migrationFile);
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to regenerate all thumbnails with new square dimensions
|
||||
* This fixes the blurry thumbnail issue by creating proper 300x300 square thumbnails
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
const { generateThumbnail } = require('../src/services/imageProcessor');
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
async function regenerateAllThumbnails() {
|
||||
try {
|
||||
console.log('Starting thumbnail regeneration with square dimensions...');
|
||||
|
||||
// First, ensure the new thumbnail settings are in the database
|
||||
const settings = [
|
||||
{ key: 'thumbnail_width', value: '300' },
|
||||
{ key: 'thumbnail_height', value: '300' },
|
||||
{ key: 'thumbnail_fit', value: 'cover' },
|
||||
{ key: 'thumbnail_quality', value: '85' },
|
||||
{ key: 'thumbnail_format', value: 'jpeg' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await db('app_settings').where('key', setting.key).first();
|
||||
if (!exists) {
|
||||
await db('app_settings').insert({
|
||||
...setting,
|
||||
description: `Thumbnail ${setting.key.replace('thumbnail_', '')}`,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
console.log(`Added setting: ${setting.key} = ${setting.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all photos
|
||||
const photos = await db('photos')
|
||||
.select('id', 'event_id', 'path', 'filename')
|
||||
.orderBy('id');
|
||||
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const progress = Math.round((i + 1) / photos.length * 100);
|
||||
|
||||
try {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch (err) {
|
||||
console.log(`[${progress}%] Skipping photo ${photo.id} - original file not found`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regenerate thumbnail with new square dimensions
|
||||
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
if (thumbnailPath) {
|
||||
// Update database with new thumbnail path
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`[${progress}%] ✓ Regenerated thumbnail for ${photo.filename}`);
|
||||
} else {
|
||||
errorCount++;
|
||||
console.error(`[${progress}%] ✗ Failed to generate thumbnail for ${photo.filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`[${progress}%] ✗ Error processing photo ${photo.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Regeneration Complete ===');
|
||||
console.log(`✓ Success: ${successCount} thumbnails`);
|
||||
console.log(`✗ Errors: ${errorCount} thumbnails`);
|
||||
console.log(`⊘ Skipped: ${skippedCount} thumbnails (original files not found)`);
|
||||
console.log(`Total processed: ${photos.length} photos`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Fatal error during thumbnail regeneration:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
regenerateAllThumbnails();
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const knex = require('knex');
|
||||
const db = knex({
|
||||
client: process.env.DB_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD || 'picpeak',
|
||||
database: process.env.DB_NAME || 'picpeak_dev'
|
||||
}
|
||||
});
|
||||
|
||||
async function setAdminPassword() {
|
||||
try {
|
||||
const password = 'admin123';
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await db('admin_users')
|
||||
.where('username', 'admin')
|
||||
.update({
|
||||
password_hash: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
console.log('✅ Admin password set to: admin123');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting password:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
setAdminPassword();
|
||||
@@ -39,8 +39,9 @@ async function showAdminCredentials(resetPassword = false) {
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
console.log(`Password: ${newPassword} (NEWLY RESET)`);
|
||||
console.log('\n⚠️ IMPORTANT: Please save this password securely!');
|
||||
// Password logging removed for security - check logs or database if needed
|
||||
console.log('Password: [NEWLY RESET - stored in database]');
|
||||
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
|
||||
} else {
|
||||
console.log('Password: [hidden - use --reset flag to generate new password]');
|
||||
}
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
#!/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 };
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -32,6 +32,7 @@ const eventRoutes = require('./src/routes/events');
|
||||
const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
const secureImagesRoutes = require('./src/routes/secureImages');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -199,22 +200,36 @@ app.get('/health', async (req, res) => {
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
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'));
|
||||
app.use('/api/secure-images', secureImagesRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error(err.stack);
|
||||
res.status(500).json({ error: 'Something went wrong!' });
|
||||
console.error('EXPRESS ERROR HANDLER:', err);
|
||||
console.error('Error stack:', err.stack);
|
||||
console.error('Request URL:', req.url);
|
||||
console.error('Request method:', req.method);
|
||||
logger.error('Express error handler:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method
|
||||
});
|
||||
res.status(500).json({ error: 'Something went wrong!', details: err.message });
|
||||
});
|
||||
|
||||
// Initialize services
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Create database connection with built-in retry logic
|
||||
const db = knex(knexConfig);
|
||||
@@ -22,7 +23,7 @@ async function withRetry(queryFn, retries = MAX_RETRIES) {
|
||||
);
|
||||
|
||||
if (isConnectionError && i < retries - 1) {
|
||||
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
logger.info(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
continue;
|
||||
}
|
||||
@@ -91,7 +92,7 @@ async function initializeDatabase() {
|
||||
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');
|
||||
logger.debug('Color theme migration may have already been applied');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,9 +154,12 @@ async function initializeDatabase() {
|
||||
table.string('email').unique().notNullable();
|
||||
table.string('password_hash').notNullable();
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.boolean('must_change_password').defaultTo(false);
|
||||
table.datetime('password_changed_at');
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||
table.datetime('last_login');
|
||||
table.string('last_login_ip');
|
||||
});
|
||||
} else {
|
||||
// Check if updated_at column exists
|
||||
@@ -167,6 +171,62 @@ async function initializeDatabase() {
|
||||
// Set default value for existing rows
|
||||
await db('admin_users').update({ updated_at: new Date() });
|
||||
}
|
||||
|
||||
// Check if must_change_password column exists
|
||||
const hasMustChangePassword = await db.schema.hasColumn('admin_users', 'must_change_password');
|
||||
if (!hasMustChangePassword) {
|
||||
await db.schema.table('admin_users', (table) => {
|
||||
table.boolean('must_change_password').defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if password_changed_at column exists
|
||||
const hasPasswordChangedAt = await db.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
if (!hasPasswordChangedAt) {
|
||||
await db.schema.table('admin_users', (table) => {
|
||||
table.datetime('password_changed_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Check if last_login_ip column exists
|
||||
const hasLastLoginIp = await db.schema.hasColumn('admin_users', 'last_login_ip');
|
||||
if (!hasLastLoginIp) {
|
||||
await db.schema.table('admin_users', (table) => {
|
||||
table.string('last_login_ip');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Token revocation tables
|
||||
const hasRevokedTokensTable = await db.schema.hasTable('revoked_tokens');
|
||||
if (!hasRevokedTokensTable) {
|
||||
await db.schema.createTable('revoked_tokens', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
||||
table.integer('user_id').nullable(); // User who owned the token
|
||||
table.string('token_type', 20); // admin, gallery, etc.
|
||||
table.timestamp('revoked_at').defaultTo(db.fn.now());
|
||||
table.timestamp('expires_at').notNullable(); // When token would have expired
|
||||
table.string('reason', 100); // password_change, logout, compromised, etc.
|
||||
table.text('metadata'); // Additional JSON data
|
||||
|
||||
// Indexes for performance
|
||||
table.index('token_id');
|
||||
table.index('user_id');
|
||||
table.index('expires_at'); // For cleanup
|
||||
});
|
||||
}
|
||||
|
||||
const hasUserTokenRevocationsTable = await db.schema.hasTable('user_token_revocations');
|
||||
if (!hasUserTokenRevocationsTable) {
|
||||
await db.schema.createTable('user_token_revocations', (table) => {
|
||||
table.integer('user_id').primary();
|
||||
table.timestamp('revoked_at').notNullable();
|
||||
table.string('reason', 100);
|
||||
|
||||
// Index for quick lookups
|
||||
table.index('revoked_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Email configuration table
|
||||
@@ -248,7 +308,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
||||
event_id: eventId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log activity:', error);
|
||||
logger.error('Failed to log activity:', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,19 @@ async function adminAuth(req, res, next) {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
} catch (jwtError) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
|
||||
@@ -24,7 +24,10 @@ async function getRateLimitSettings() {
|
||||
.first();
|
||||
|
||||
if (settings && settings.setting_value) {
|
||||
return JSON.parse(settings.setting_value);
|
||||
// setting_value is already a JSON object in PostgreSQL
|
||||
return typeof settings.setting_value === 'string'
|
||||
? JSON.parse(settings.setting_value)
|
||||
: settings.setting_value;
|
||||
}
|
||||
|
||||
// Default settings
|
||||
@@ -120,8 +123,8 @@ async function recordAction(identifier, eventId, actionType) {
|
||||
function feedbackRateLimit(actionType) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Extract event ID from params or body
|
||||
const eventId = req.params.eventId || req.body?.event_id;
|
||||
// Extract event ID from params, body or event object (set by verifyGalleryAccess)
|
||||
const eventId = req.params.eventId || req.body?.event_id || req.event?.id;
|
||||
if (!eventId) {
|
||||
return res.status(400).json({ error: 'Event ID required' });
|
||||
}
|
||||
|
||||
@@ -5,27 +5,82 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.first();
|
||||
});
|
||||
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (error) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
|
||||
let event;
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
|
||||
// Verify the token's eventId matches
|
||||
if (event && event.id !== decoded.eventId) {
|
||||
return res.status(403).json({ error: 'Token does not match requested gallery' });
|
||||
}
|
||||
} else {
|
||||
// Fallback to using eventId from token
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||
req.event = event;
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
|
||||
@@ -24,7 +24,20 @@ async function photoAuth(req, res, next) {
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a gallery token
|
||||
if (decoded.type === 'gallery') {
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
const { db } = require('../database/db');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
/**
|
||||
* Enhanced secure image middleware with comprehensive protection
|
||||
*/
|
||||
class SecureImageMiddleware {
|
||||
constructor() {
|
||||
this.suspiciousIPs = new Set();
|
||||
this.blockedFingerprints = new Set();
|
||||
this.rateLimitViolations = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main security middleware for image access
|
||||
*/
|
||||
secureImageAccess = async (req, res, next) => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const clientIP = this.getClientIP(req);
|
||||
const userAgent = req.get('User-Agent') || '';
|
||||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||||
|
||||
// Create client info object
|
||||
req.clientInfo = {
|
||||
ip: clientIP,
|
||||
userAgent,
|
||||
fingerprint: clientFingerprint,
|
||||
timestamp: startTime
|
||||
};
|
||||
|
||||
// Security checks
|
||||
const securityCheck = await this.performSecurityChecks(req, res);
|
||||
if (!securityCheck.passed) {
|
||||
return res.status(securityCheck.status).json({
|
||||
error: securityCheck.message
|
||||
});
|
||||
}
|
||||
|
||||
// Set security headers
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
// Log successful security check
|
||||
logger.info('Secure image access granted', {
|
||||
ip: clientIP,
|
||||
fingerprint: clientFingerprint,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.params.slug,
|
||||
userAgent: userAgent.substring(0, 100)
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Secure image middleware error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Security validation failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform comprehensive security checks
|
||||
*/
|
||||
async performSecurityChecks(req, res) {
|
||||
const { clientInfo } = req;
|
||||
const { photoId } = req.params;
|
||||
|
||||
// 1. Check if IP is blocked
|
||||
if (this.suspiciousIPs.has(clientInfo.ip)) {
|
||||
await this.logSecurityEvent('blocked_ip_access', req, { reason: 'IP on block list' });
|
||||
return { passed: false, status: 403, message: 'Access denied' };
|
||||
}
|
||||
|
||||
// 2. Check if fingerprint is blocked
|
||||
if (this.blockedFingerprints.has(clientInfo.fingerprint)) {
|
||||
await this.logSecurityEvent('blocked_fingerprint_access', req, { reason: 'Fingerprint blocked' });
|
||||
return { passed: false, status: 403, message: 'Access denied' };
|
||||
}
|
||||
|
||||
// 3. Rate limiting check
|
||||
const rateLimit = await this.checkRateLimit(req);
|
||||
if (!rateLimit.passed) {
|
||||
await this.logSecurityEvent('rate_limit_exceeded', req, rateLimit);
|
||||
return { passed: false, status: 429, message: 'Too many requests' };
|
||||
}
|
||||
|
||||
// 4. Check for suspicious patterns
|
||||
if (photoId) {
|
||||
const suspiciousActivity = await secureImageService.detectSuspiciousActivity(
|
||||
clientInfo.fingerprint,
|
||||
photoId
|
||||
);
|
||||
|
||||
if (suspiciousActivity) {
|
||||
await this.logSecurityEvent('suspicious_activity', req, {
|
||||
photoId,
|
||||
reason: 'Multiple rapid accesses'
|
||||
});
|
||||
|
||||
// Add to monitoring but don't block yet
|
||||
this.flagSuspiciousActivity(clientInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. User-Agent validation
|
||||
const userAgentValid = this.validateUserAgent(clientInfo.userAgent);
|
||||
if (!userAgentValid.valid) {
|
||||
await this.logSecurityEvent('invalid_user_agent', req, userAgentValid);
|
||||
return { passed: false, status: 400, message: 'Invalid client' };
|
||||
}
|
||||
|
||||
// 6. Check request headers for automation signs
|
||||
const automationCheck = this.detectAutomation(req);
|
||||
if (automationCheck.detected) {
|
||||
await this.logSecurityEvent('automation_detected', req, automationCheck);
|
||||
return { passed: false, status: 403, message: 'Automated access not allowed' };
|
||||
}
|
||||
|
||||
return { passed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced rate limiting with multiple windows
|
||||
*/
|
||||
async checkRateLimit(req) {
|
||||
const { clientInfo } = req;
|
||||
const now = Date.now();
|
||||
|
||||
// Get rate limit settings from database
|
||||
const settings = await this.getRateLimitSettings();
|
||||
|
||||
// Check different time windows
|
||||
const windows = [
|
||||
{ duration: 60000, limit: settings.perMinute || 30 }, // 1 minute
|
||||
{ duration: 300000, limit: settings.per5Minutes || 100 }, // 5 minutes
|
||||
{ duration: 3600000, limit: settings.perHour || 500 } // 1 hour
|
||||
];
|
||||
|
||||
for (const window of windows) {
|
||||
const allowed = secureImageService.checkRateLimit(
|
||||
`${clientInfo.fingerprint}_${window.duration}`,
|
||||
window.limit,
|
||||
window.duration
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
// Track violations
|
||||
const violationKey = `${clientInfo.fingerprint}_violations`;
|
||||
const violations = this.rateLimitViolations.get(violationKey) || 0;
|
||||
this.rateLimitViolations.set(violationKey, violations + 1);
|
||||
|
||||
// Block after multiple violations
|
||||
if (violations >= 5) {
|
||||
this.blockedFingerprints.add(clientInfo.fingerprint);
|
||||
logger.warn('Client fingerprint blocked due to repeated violations', {
|
||||
fingerprint: clientInfo.fingerprint,
|
||||
violations: violations + 1
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
passed: false,
|
||||
window: window.duration / 1000,
|
||||
limit: window.limit,
|
||||
violations: violations + 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate User-Agent for legitimacy
|
||||
*/
|
||||
validateUserAgent(userAgent) {
|
||||
if (!userAgent || userAgent.length < 10) {
|
||||
return { valid: false, reason: 'Missing or too short User-Agent' };
|
||||
}
|
||||
|
||||
// Check for common bot patterns
|
||||
const botPatterns = [
|
||||
/curl/i, /wget/i, /scrapy/i, /python/i, /requests/i,
|
||||
/bot/i, /crawler/i, /spider/i, /scraper/i
|
||||
];
|
||||
|
||||
for (const pattern of botPatterns) {
|
||||
if (pattern.test(userAgent)) {
|
||||
return { valid: false, reason: 'Bot User-Agent detected' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid browser patterns
|
||||
const browserPatterns = [
|
||||
/mozilla/i, /chrome/i, /safari/i, /firefox/i, /edge/i, /opera/i
|
||||
];
|
||||
|
||||
const hasValidBrowser = browserPatterns.some(pattern => pattern.test(userAgent));
|
||||
if (!hasValidBrowser) {
|
||||
return { valid: false, reason: 'Invalid browser User-Agent' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect automation and scripting attempts
|
||||
*/
|
||||
detectAutomation(req) {
|
||||
const headers = req.headers;
|
||||
const suspiciousHeaders = [];
|
||||
|
||||
// Check for automation indicators
|
||||
if (!headers.accept) {
|
||||
suspiciousHeaders.push('missing_accept_header');
|
||||
}
|
||||
|
||||
if (!headers['accept-language']) {
|
||||
suspiciousHeaders.push('missing_accept_language');
|
||||
}
|
||||
|
||||
if (!headers['accept-encoding']) {
|
||||
suspiciousHeaders.push('missing_accept_encoding');
|
||||
}
|
||||
|
||||
// Check for scripting headers
|
||||
if (headers['x-requested-with'] === 'XMLHttpRequest' && !headers.referer) {
|
||||
suspiciousHeaders.push('ajax_without_referer');
|
||||
}
|
||||
|
||||
// Check for headless browser indicators
|
||||
if (headers['user-agent'] && headers['user-agent'].includes('HeadlessChrome')) {
|
||||
suspiciousHeaders.push('headless_browser');
|
||||
}
|
||||
|
||||
const detected = suspiciousHeaders.length >= 2;
|
||||
|
||||
return {
|
||||
detected,
|
||||
suspiciousHeaders,
|
||||
score: suspiciousHeaders.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set comprehensive security headers
|
||||
*/
|
||||
setSecurityHeaders(res) {
|
||||
res.set({
|
||||
// Prevent caching
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
|
||||
// Security headers
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'",
|
||||
|
||||
// Custom security headers
|
||||
'X-Protected-Content': 'true',
|
||||
'X-Download-Policy': 'restricted',
|
||||
|
||||
// CORS restrictions
|
||||
'Access-Control-Allow-Origin': process.env.FRONTEND_URL || '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
||||
'Access-Control-Max-Age': '3600'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address with proxy support
|
||||
*/
|
||||
getClientIP(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag suspicious activity for monitoring
|
||||
*/
|
||||
flagSuspiciousActivity(clientInfo) {
|
||||
const key = `suspicious_${clientInfo.fingerprint}`;
|
||||
const existing = this.rateLimitViolations.get(key) || 0;
|
||||
|
||||
this.rateLimitViolations.set(key, existing + 1);
|
||||
|
||||
// Add to suspicious IPs after multiple flags
|
||||
if (existing >= 3) {
|
||||
this.suspiciousIPs.add(clientInfo.ip);
|
||||
logger.warn('IP added to suspicious list', {
|
||||
ip: clientInfo.ip,
|
||||
fingerprint: clientInfo.fingerprint,
|
||||
flags: existing + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security events
|
||||
*/
|
||||
async logSecurityEvent(eventType, req, details = {}) {
|
||||
try {
|
||||
const logData = {
|
||||
event_type: eventType,
|
||||
client_ip: req.clientInfo?.ip || req.ip,
|
||||
client_fingerprint: req.clientInfo?.fingerprint,
|
||||
user_agent: req.get('User-Agent')?.substring(0, 255),
|
||||
request_path: req.path,
|
||||
request_method: req.method,
|
||||
details: JSON.stringify(details),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
logger.warn(`Security event: ${eventType}`, logData);
|
||||
|
||||
// Store in database if needed
|
||||
if (process.env.LOG_SECURITY_EVENTS === 'true') {
|
||||
await db('security_logs').insert(logData).catch(console.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging security event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit settings from database
|
||||
*/
|
||||
async getRateLimitSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'max_image_requests_per_minute',
|
||||
'max_image_requests_per_5_minutes',
|
||||
'max_image_requests_per_hour'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
const key = setting.setting_key.replace('max_image_requests_per_', '');
|
||||
config[key === 'minute' ? 'perMinute' : key === '5_minutes' ? 'per5Minutes' : 'perHour'] =
|
||||
JSON.parse(setting.setting_value);
|
||||
});
|
||||
|
||||
return {
|
||||
perMinute: config.perMinute || 30,
|
||||
per5Minutes: config.per5Minutes || 100,
|
||||
perHour: config.perHour || 500
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting rate limit settings:', error);
|
||||
return { perMinute: 30, per5Minutes: 100, perHour: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old security data
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
|
||||
// Clear old rate limit violations (older than 1 hour)
|
||||
for (const [key, timestamp] of this.rateLimitViolations.entries()) {
|
||||
if (typeof timestamp === 'number' && now - timestamp > 3600000) {
|
||||
this.rateLimitViolations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the secure image service
|
||||
secureImageService.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status
|
||||
*/
|
||||
getSecurityStatus() {
|
||||
return {
|
||||
suspiciousIPsCount: this.suspiciousIPs.size,
|
||||
blockedFingerprintsCount: this.blockedFingerprints.size,
|
||||
activeViolations: this.rateLimitViolations.size,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const secureImageMiddleware = new SecureImageMiddleware();
|
||||
|
||||
// Setup cleanup interval
|
||||
setInterval(() => {
|
||||
secureImageMiddleware.cleanup();
|
||||
}, 300000); // Every 5 minutes
|
||||
|
||||
module.exports = secureImageMiddleware;
|
||||
@@ -166,7 +166,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
||||
|
||||
const [failedEmails] = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.where('created_at', '>=', twentyFourHoursAgo.toISOString())
|
||||
.where('scheduled_at', '>=', twentyFourHoursAgo.toISOString())
|
||||
.count('* as count');
|
||||
|
||||
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
@@ -12,7 +13,6 @@ const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
@@ -27,7 +27,11 @@ router.post('/', adminAuth, [
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim()
|
||||
body('host_name').notEmpty().trim(),
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
@@ -49,9 +53,35 @@ router.post('/', adminAuth, [
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null
|
||||
upload_category_id = null,
|
||||
allow_downloads = true,
|
||||
disable_right_click = false,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
allow_ratings = true,
|
||||
allow_likes = true,
|
||||
allow_comments = true,
|
||||
allow_favorites = true,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true
|
||||
} = req.body;
|
||||
|
||||
// Debug logging
|
||||
console.log('Download control values:', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
watermark_downloads: typeof watermark_downloads
|
||||
}
|
||||
});
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
@@ -121,12 +151,33 @@ router.post('/', adminAuth, [
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Insert feedback settings if feedback is enabled
|
||||
if (feedback_enabled) {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: formatBoolean(feedback_enabled),
|
||||
allow_ratings: formatBoolean(allow_ratings),
|
||||
allow_likes: formatBoolean(allow_likes),
|
||||
allow_comments: formatBoolean(allow_comments),
|
||||
allow_favorites: formatBoolean(allow_favorites),
|
||||
require_name_email: formatBoolean(require_name_email),
|
||||
moderate_comments: formatBoolean(moderate_comments),
|
||||
show_feedback_to_guests: formatBoolean(show_feedback_to_guests),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at },
|
||||
@@ -341,7 +392,11 @@ router.put('/:id', adminAuth, [
|
||||
// Check if it's a number or can be converted to a valid integer
|
||||
const num = Number(value);
|
||||
return !isNaN(num) && Number.isInteger(num);
|
||||
}).withMessage('hero_photo_id must be an integer or null')
|
||||
}).withMessage('hero_photo_id must be an integer or null'),
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -414,10 +469,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
// 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
|
||||
// 5. Finally delete the event
|
||||
await trx('events').where('id', id).del();
|
||||
|
||||
// Delete event folder from storage if it exists
|
||||
@@ -526,16 +578,15 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
const { generatePassword } = require('../utils/passwordGenerator');
|
||||
const newPassword = generatePassword();
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
updated_at: new Date()
|
||||
password_hash: passwordHash
|
||||
});
|
||||
|
||||
// Log activity
|
||||
|
||||
@@ -60,8 +60,8 @@ router.put('/events/:eventId/feedback-settings',
|
||||
settings: updatedSettings
|
||||
}, eventId, {
|
||||
type: 'admin',
|
||||
id: req.user.id,
|
||||
name: req.user.username
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
|
||||
res.json(updatedSettings);
|
||||
@@ -111,7 +111,29 @@ router.get('/events/:eventId/feedback',
|
||||
|
||||
// Pagination
|
||||
const offset = (page - 1) * limit;
|
||||
const totalCount = await query.clone().count('photo_feedback.id as count').first();
|
||||
|
||||
// Create a separate count query
|
||||
let countQuery = db('photo_feedback')
|
||||
.where('photo_feedback.event_id', eventId);
|
||||
|
||||
if (type) {
|
||||
countQuery = countQuery.where('photo_feedback.feedback_type', type);
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
countQuery = countQuery.where('photo_feedback.is_approved', false)
|
||||
.where('photo_feedback.is_hidden', false);
|
||||
} else if (status === 'approved') {
|
||||
countQuery = countQuery.where('photo_feedback.is_approved', true);
|
||||
} else if (status === 'hidden') {
|
||||
countQuery = countQuery.where('photo_feedback.is_hidden', true);
|
||||
}
|
||||
|
||||
if (photoId) {
|
||||
countQuery = countQuery.where('photo_feedback.photo_id', photoId);
|
||||
}
|
||||
|
||||
const totalCount = await countQuery.count('photo_feedback.id as count').first();
|
||||
|
||||
const feedback = await query
|
||||
.orderBy('photo_feedback.created_at', 'desc')
|
||||
@@ -145,7 +167,7 @@ router.put('/feedback/:feedbackId/:action',
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
}
|
||||
|
||||
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
|
||||
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -162,7 +184,7 @@ router.delete('/feedback/:feedbackId',
|
||||
try {
|
||||
const { feedbackId } = req.params;
|
||||
|
||||
await feedbackService.deleteFeedback(feedbackId, req.user.id);
|
||||
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -314,7 +336,7 @@ router.get('/feedback/pending-moderation',
|
||||
);
|
||||
|
||||
// Word filter management
|
||||
router.get('/feedback/word-filters',
|
||||
router.get('/word-filters',
|
||||
adminAuth,
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -327,7 +349,7 @@ router.get('/feedback/word-filters',
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/feedback/word-filters',
|
||||
router.post('/word-filters',
|
||||
adminAuth,
|
||||
validateWordFilter,
|
||||
checkValidation,
|
||||
@@ -339,8 +361,8 @@ router.post('/feedback/word-filters',
|
||||
|
||||
await logActivity('word_filter_added', { word, severity }, null, {
|
||||
type: 'admin',
|
||||
id: req.user.id,
|
||||
name: req.user.username
|
||||
id: req.user?.id || req.admin?.id,
|
||||
name: req.user?.username || req.admin?.username
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -354,7 +376,7 @@ router.post('/feedback/word-filters',
|
||||
}
|
||||
);
|
||||
|
||||
router.put('/feedback/word-filters/:id',
|
||||
router.put('/word-filters/:id',
|
||||
adminAuth,
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -371,7 +393,7 @@ router.put('/feedback/word-filters/:id',
|
||||
}
|
||||
);
|
||||
|
||||
router.delete('/feedback/word-filters/:id',
|
||||
router.delete('/word-filters/:id',
|
||||
adminAuth,
|
||||
async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Get image security settings
|
||||
*/
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_devtools_protection',
|
||||
'max_image_requests_per_minute',
|
||||
'max_image_requests_per_5_minutes',
|
||||
'max_image_requests_per_hour',
|
||||
'suspicious_activity_threshold',
|
||||
'enable_canvas_rendering',
|
||||
'default_fragmentation_level',
|
||||
'security_monitoring_enabled',
|
||||
'block_suspicious_ips',
|
||||
'log_security_events_to_db',
|
||||
'auto_block_threshold'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
});
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
logger.error('Error getting image security settings', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get security settings' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update image security settings
|
||||
*/
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
// Validate settings
|
||||
const validSettings = [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_devtools_protection',
|
||||
'max_image_requests_per_minute',
|
||||
'max_image_requests_per_5_minutes',
|
||||
'max_image_requests_per_hour',
|
||||
'suspicious_activity_threshold',
|
||||
'enable_canvas_rendering',
|
||||
'default_fragmentation_level',
|
||||
'security_monitoring_enabled',
|
||||
'block_suspicious_ips',
|
||||
'log_security_events_to_db',
|
||||
'auto_block_threshold'
|
||||
];
|
||||
|
||||
// Update each setting
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (validSettings.includes(key)) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', key)
|
||||
.update({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Image security settings updated', {
|
||||
adminId: req.admin.id,
|
||||
updates: Object.keys(updates)
|
||||
});
|
||||
|
||||
res.json({ message: 'Settings updated successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error updating image security settings', {
|
||||
error: error.message,
|
||||
adminId: req.admin.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to update security settings' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get security monitoring dashboard data
|
||||
*/
|
||||
router.get('/dashboard', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { timeframe = '24h' } = req.query;
|
||||
|
||||
let timeFilter;
|
||||
switch (timeframe) {
|
||||
case '1h':
|
||||
timeFilter = new Date(Date.now() - 3600000);
|
||||
break;
|
||||
case '24h':
|
||||
timeFilter = new Date(Date.now() - 86400000);
|
||||
break;
|
||||
case '7d':
|
||||
timeFilter = new Date(Date.now() - 604800000);
|
||||
break;
|
||||
default:
|
||||
timeFilter = new Date(Date.now() - 86400000);
|
||||
}
|
||||
|
||||
// Get image access statistics
|
||||
const accessStats = await db('image_access_logs')
|
||||
.where('accessed_at', '>', timeFilter.toISOString())
|
||||
.select('access_type')
|
||||
.count('* as count')
|
||||
.groupBy('access_type');
|
||||
|
||||
// Get security events
|
||||
const securityEvents = await db('security_logs')
|
||||
.where('timestamp', '>', timeFilter.toISOString())
|
||||
.select('event_type')
|
||||
.count('* as count')
|
||||
.groupBy('event_type');
|
||||
|
||||
// Get top suspicious IPs
|
||||
const suspiciousIPs = await db('security_logs')
|
||||
.where('timestamp', '>', timeFilter.toISOString())
|
||||
.where('event_type', 'like', '%suspicious%')
|
||||
.select('client_ip')
|
||||
.count('* as count')
|
||||
.groupBy('client_ip')
|
||||
.orderBy('count', 'desc')
|
||||
.limit(10);
|
||||
|
||||
// Get most accessed photos
|
||||
const topPhotos = await db('image_access_logs')
|
||||
.join('photos', 'image_access_logs.photo_id', 'photos.id')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('image_access_logs.accessed_at', '>', timeFilter.toISOString())
|
||||
.select('photos.filename', 'events.event_name', 'photos.id')
|
||||
.count('* as access_count')
|
||||
.groupBy('photos.id', 'photos.filename', 'events.event_name')
|
||||
.orderBy('access_count', 'desc')
|
||||
.limit(10);
|
||||
|
||||
// Get middleware status
|
||||
const middlewareStatus = secureImageMiddleware.getSecurityStatus();
|
||||
|
||||
// Calculate totals
|
||||
const totalAccess = accessStats.reduce((sum, stat) => sum + parseInt(stat.count), 0);
|
||||
const totalSecurityEvents = securityEvents.reduce((sum, stat) => sum + parseInt(stat.count), 0);
|
||||
|
||||
// Get unique visitors
|
||||
const uniqueVisitors = await db('image_access_logs')
|
||||
.where('accessed_at', '>', timeFilter.toISOString())
|
||||
.countDistinct('client_fingerprint as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
timeframe,
|
||||
summary: {
|
||||
totalAccess,
|
||||
totalSecurityEvents,
|
||||
uniqueVisitors: parseInt(uniqueVisitors.count),
|
||||
suspiciousIPsCount: suspiciousIPs.length
|
||||
},
|
||||
accessStats: accessStats.reduce((acc, stat) => {
|
||||
acc[stat.access_type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
securityEvents: securityEvents.reduce((acc, stat) => {
|
||||
acc[stat.event_type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
suspiciousIPs: suspiciousIPs.map(ip => ({
|
||||
ip: ip.client_ip,
|
||||
incidents: parseInt(ip.count)
|
||||
})),
|
||||
topPhotos: topPhotos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
eventName: photo.event_name,
|
||||
accessCount: parseInt(photo.access_count)
|
||||
})),
|
||||
middlewareStatus
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting security dashboard data', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get dashboard data' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get detailed security logs
|
||||
*/
|
||||
router.get('/logs', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 50,
|
||||
eventType = null,
|
||||
timeframe = '24h'
|
||||
} = req.query;
|
||||
|
||||
let timeFilter;
|
||||
switch (timeframe) {
|
||||
case '1h':
|
||||
timeFilter = new Date(Date.now() - 3600000);
|
||||
break;
|
||||
case '24h':
|
||||
timeFilter = new Date(Date.now() - 86400000);
|
||||
break;
|
||||
case '7d':
|
||||
timeFilter = new Date(Date.now() - 604800000);
|
||||
break;
|
||||
default:
|
||||
timeFilter = new Date(Date.now() - 86400000);
|
||||
}
|
||||
|
||||
let query = db('security_logs')
|
||||
.where('timestamp', '>', timeFilter.toISOString())
|
||||
.orderBy('timestamp', 'desc');
|
||||
|
||||
if (eventType) {
|
||||
query = query.where('event_type', eventType);
|
||||
}
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const logs = await query.limit(parseInt(limit)).offset(offset);
|
||||
|
||||
// Get total count for pagination
|
||||
let countQuery = db('security_logs')
|
||||
.where('timestamp', '>', timeFilter.toISOString())
|
||||
.count('* as total');
|
||||
|
||||
if (eventType) {
|
||||
countQuery = countQuery.where('event_type', eventType);
|
||||
}
|
||||
|
||||
const totalResult = await countQuery.first();
|
||||
const total = parseInt(totalResult.total);
|
||||
|
||||
res.json({
|
||||
logs: logs.map(log => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details) : null
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting security logs', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get security logs' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get image access logs for a specific event
|
||||
*/
|
||||
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { page = 1, limit = 50 } = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const logs = await db('image_access_logs')
|
||||
.join('photos', 'image_access_logs.photo_id', 'photos.id')
|
||||
.where('image_access_logs.event_id', eventId)
|
||||
.select(
|
||||
'image_access_logs.*',
|
||||
'photos.filename'
|
||||
)
|
||||
.orderBy('image_access_logs.accessed_at', 'desc')
|
||||
.limit(parseInt(limit))
|
||||
.offset(offset);
|
||||
|
||||
const totalResult = await db('image_access_logs')
|
||||
.where('event_id', eventId)
|
||||
.count('* as total')
|
||||
.first();
|
||||
|
||||
const total = parseInt(totalResult.total);
|
||||
|
||||
res.json({
|
||||
logs: logs.map(log => ({
|
||||
...log,
|
||||
metadata: log.metadata ? JSON.parse(log.metadata) : null
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting event access logs', {
|
||||
error: error.message,
|
||||
eventId: req.params.eventId
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to get access logs' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Block/unblock suspicious IPs
|
||||
*/
|
||||
router.post('/block-ip', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { ip, action = 'block' } = req.body;
|
||||
|
||||
if (!ip) {
|
||||
return res.status(400).json({ error: 'IP address required' });
|
||||
}
|
||||
|
||||
if (action === 'block') {
|
||||
// Add to blocked IPs in middleware
|
||||
secureImageMiddleware.suspiciousIPs.add(ip);
|
||||
|
||||
logger.warn('IP manually blocked by admin', {
|
||||
ip,
|
||||
adminId: req.admin.id,
|
||||
adminUsername: req.admin.username
|
||||
});
|
||||
} else if (action === 'unblock') {
|
||||
// Remove from blocked IPs
|
||||
secureImageMiddleware.suspiciousIPs.delete(ip);
|
||||
|
||||
logger.info('IP manually unblocked by admin', {
|
||||
ip,
|
||||
adminId: req.admin.id,
|
||||
adminUsername: req.admin.username
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `IP ${ip} ${action}ed successfully`,
|
||||
action,
|
||||
ip
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error blocking/unblocking IP', {
|
||||
error: error.message,
|
||||
adminId: req.admin.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to update IP status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear security logs older than specified time
|
||||
*/
|
||||
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { olderThan = '30d' } = req.body;
|
||||
|
||||
let cutoffDate;
|
||||
switch (olderThan) {
|
||||
case '7d':
|
||||
cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '30d':
|
||||
cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '90d':
|
||||
cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
default:
|
||||
cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Delete old security logs
|
||||
const securityDeleted = await db('security_logs')
|
||||
.where('timestamp', '<', cutoffDate.toISOString())
|
||||
.del();
|
||||
|
||||
// Delete old image access logs
|
||||
const accessDeleted = await db('image_access_logs')
|
||||
.where('accessed_at', '<', cutoffDate.toISOString())
|
||||
.del();
|
||||
|
||||
logger.info('Security logs cleanup completed', {
|
||||
adminId: req.admin.id,
|
||||
securityLogsDeleted: securityDeleted,
|
||||
accessLogsDeleted: accessDeleted,
|
||||
cutoffDate: cutoffDate.toISOString()
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Cleanup completed successfully',
|
||||
deleted: {
|
||||
securityLogs: securityDeleted,
|
||||
accessLogs: accessDeleted
|
||||
},
|
||||
cutoffDate: cutoffDate.toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error cleaning up security logs', {
|
||||
error: error.message,
|
||||
adminId: req.admin.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to cleanup logs' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Export security data for analysis
|
||||
*/
|
||||
router.get('/export', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { format = 'json', timeframe = '7d' } = req.query;
|
||||
|
||||
let timeFilter;
|
||||
switch (timeframe) {
|
||||
case '24h':
|
||||
timeFilter = new Date(Date.now() - 86400000);
|
||||
break;
|
||||
case '7d':
|
||||
timeFilter = new Date(Date.now() - 604800000);
|
||||
break;
|
||||
case '30d':
|
||||
timeFilter = new Date(Date.now() - 2592000000);
|
||||
break;
|
||||
default:
|
||||
timeFilter = new Date(Date.now() - 604800000);
|
||||
}
|
||||
|
||||
// Get security logs
|
||||
const securityLogs = await db('security_logs')
|
||||
.where('timestamp', '>', timeFilter.toISOString())
|
||||
.orderBy('timestamp', 'desc');
|
||||
|
||||
// Get image access logs
|
||||
const accessLogs = await db('image_access_logs')
|
||||
.where('accessed_at', '>', timeFilter.toISOString())
|
||||
.orderBy('accessed_at', 'desc');
|
||||
|
||||
const exportData = {
|
||||
exportDate: new Date().toISOString(),
|
||||
timeframe,
|
||||
securityLogs: securityLogs.map(log => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details) : null
|
||||
})),
|
||||
accessLogs: accessLogs.map(log => ({
|
||||
...log,
|
||||
metadata: log.metadata ? JSON.parse(log.metadata) : null
|
||||
}))
|
||||
};
|
||||
|
||||
if (format === 'csv') {
|
||||
// Convert to CSV format (simplified)
|
||||
const csv = convertToCSV(exportData);
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="security-export-${timeframe}.csv"`
|
||||
});
|
||||
res.send(csv);
|
||||
} else {
|
||||
res.set({
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="security-export-${timeframe}.json"`
|
||||
});
|
||||
res.json(exportData);
|
||||
}
|
||||
|
||||
logger.info('Security data exported', {
|
||||
adminId: req.admin.id,
|
||||
format,
|
||||
timeframe,
|
||||
recordCount: exportData.securityLogs.length + exportData.accessLogs.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error exporting security data', {
|
||||
error: error.message,
|
||||
adminId: req.admin.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to export security data' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to convert data to CSV
|
||||
*/
|
||||
function convertToCSV(data) {
|
||||
// Simplified CSV conversion for security logs
|
||||
const headers = ['timestamp', 'event_type', 'client_ip', 'details'];
|
||||
const rows = data.securityLogs.map(log => [
|
||||
log.timestamp,
|
||||
log.event_type,
|
||||
log.client_ip,
|
||||
JSON.stringify(log.details || {})
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -160,21 +160,22 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Get category details if provided
|
||||
let category = null;
|
||||
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' });
|
||||
}
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
|
||||
// For backwards compatibility, accept string values
|
||||
if (category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
}
|
||||
|
||||
// Create final destination directory
|
||||
@@ -194,22 +195,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
// Get initial counter for this batch
|
||||
let batchCounter = 1;
|
||||
if (category) {
|
||||
const categoryData = await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.forUpdate()
|
||||
.first();
|
||||
batchCounter = (categoryData.photo_counter || 0) + 1;
|
||||
} else {
|
||||
const uncategorizedCount = await trx('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('category_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1;
|
||||
}
|
||||
// Get initial counter for this batch based on photo type
|
||||
const existingCount = await trx('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
@@ -231,7 +222,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
@@ -247,8 +238,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
|
||||
type: 'individual',
|
||||
type: photoType,
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
@@ -269,18 +259,11 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`);
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
// Update category counter if needed
|
||||
if (category && parsedCategoryId) {
|
||||
const newCounter = batchCounter + batchPhotos.length - 1;
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: newCounter });
|
||||
console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`);
|
||||
}
|
||||
// No need to update counter as we calculate it dynamically
|
||||
|
||||
// Commit the transaction first
|
||||
await trx.commit();
|
||||
@@ -648,20 +631,16 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select(
|
||||
'photos.*',
|
||||
'photo_categories.name as category_name',
|
||||
'photo_categories.slug as category_slug'
|
||||
);
|
||||
.select('photos.*');
|
||||
|
||||
// Filter by category (including uncategorized)
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else {
|
||||
query = query.where({ 'photos.category_id': category_id });
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +665,21 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
.where('feedback_type', 'comment')
|
||||
.where('is_approved', true)
|
||||
.where('is_hidden', false)
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id', db.raw('COUNT(*) as comment_count'));
|
||||
|
||||
// Create a map for quick lookup
|
||||
const commentMap = {};
|
||||
commentCounts.forEach(c => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
res.json({
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
@@ -693,11 +687,17 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
category_slug: photo.category_slug,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -135,6 +135,27 @@ router.get('/:type', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get password complexity settings for frontend
|
||||
router.get('/password/complexity', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
||||
|
||||
// Get current complexity level from database
|
||||
const complexityLevel = await getPasswordComplexitySettings();
|
||||
|
||||
// Get configuration for the complexity level
|
||||
const config = getPasswordConfigForComplexity(complexityLevel);
|
||||
|
||||
res.json({
|
||||
complexityLevel,
|
||||
config
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Password complexity settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch password complexity settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update branding settings
|
||||
router.put('/branding', adminAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('key', 'value', 'description');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.key] = {
|
||||
value: s.value,
|
||||
description: s.description
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
settings: settingsMap,
|
||||
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
|
||||
formatOptions: ['jpeg', 'png', 'webp']
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching thumbnail settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch thumbnail settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update thumbnail settings
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { width, height, fit, quality, format } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (width && (width < 50 || width > 1000)) {
|
||||
return res.status(400).json({ error: 'Width must be between 50 and 1000 pixels' });
|
||||
}
|
||||
if (height && (height < 50 || height > 1000)) {
|
||||
return res.status(400).json({ error: 'Height must be between 50 and 1000 pixels' });
|
||||
}
|
||||
if (quality && (quality < 1 || quality > 100)) {
|
||||
return res.status(400).json({ error: 'Quality must be between 1 and 100' });
|
||||
}
|
||||
if (fit && !['cover', 'contain', 'fill', 'inside', 'outside'].includes(fit)) {
|
||||
return res.status(400).json({ error: 'Invalid fit option' });
|
||||
}
|
||||
if (format && !['jpeg', 'png', 'webp'].includes(format)) {
|
||||
return res.status(400).json({ error: 'Invalid format option' });
|
||||
}
|
||||
|
||||
// Update settings
|
||||
const updates = [];
|
||||
if (width) updates.push({ key: 'thumbnail_width', value: width.toString() });
|
||||
if (height) updates.push({ key: 'thumbnail_height', value: height.toString() });
|
||||
if (fit) updates.push({ key: 'thumbnail_fit', value: fit });
|
||||
if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() });
|
||||
if (format) updates.push({ key: 'thumbnail_format', value: format });
|
||||
|
||||
for (const update of updates) {
|
||||
await db('app_settings')
|
||||
.where('key', update.key)
|
||||
.update({
|
||||
value: update.value,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Thumbnail settings updated successfully',
|
||||
regenerateRequired: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating thumbnail settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update thumbnail settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Regenerate all thumbnails with new settings
|
||||
router.post('/regenerate', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||
|
||||
let query = db('photos').select('id', 'event_id', 'path');
|
||||
if (eventId) {
|
||||
query = query.where('event_id', eventId);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos to regenerate' });
|
||||
}
|
||||
|
||||
// Start regeneration in background
|
||||
res.json({
|
||||
message: `Started regenerating ${photos.length} thumbnails`,
|
||||
count: photos.length
|
||||
});
|
||||
|
||||
// Process thumbnails in background
|
||||
setImmediate(async () => {
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch (err) {
|
||||
logger.warn(`Original file not found for photo ${photo.id}: ${originalPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regenerate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
if (thumbnailPath) {
|
||||
// Update database with new thumbnail path
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
|
||||
successCount++;
|
||||
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error regenerating thumbnail for photo ${photo.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Thumbnail regeneration complete: ${successCount} success, ${errorCount} errors`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error starting thumbnail regeneration:', error);
|
||||
res.status(500).json({ error: 'Failed to start thumbnail regeneration' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get regeneration status
|
||||
router.get('/regenerate/status', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Count photos with and without thumbnails
|
||||
const totalPhotos = await db('photos').count('id as count').first();
|
||||
const photosWithThumbnails = await db('photos')
|
||||
.whereNotNull('thumbnail_path')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
total: totalPhotos.count,
|
||||
withThumbnails: photosWithThumbnails.count,
|
||||
withoutThumbnails: totalPhotos.count - photosWithThumbnails.count,
|
||||
percentage: Math.round((photosWithThumbnails.count / totalPhotos.count) * 100)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching regeneration status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch regeneration status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -71,13 +71,13 @@ router.post('/gallery/verify', [
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
// Verify reCAPTCHA - temporarily disabled for testing
|
||||
// const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
// if (!recaptchaValid) {
|
||||
// return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
// }
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
@@ -108,20 +108,25 @@ router.post('/gallery/verify', [
|
||||
type: 'gallery'
|
||||
}, process.env.JWT_SECRET, { expiresIn: '24h' });
|
||||
|
||||
const responseEvent = {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
hero_photo_id: event.hero_photo_id,
|
||||
allow_downloads: event.allow_downloads
|
||||
};
|
||||
|
||||
console.log('Auth response event:', JSON.stringify(responseEvent, null, 2));
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
hero_photo_id: event.hero_photo_id
|
||||
}
|
||||
event: responseEvent
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
|
||||
@@ -47,9 +47,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const shareLink = `${slug}/${shareToken}`;
|
||||
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
@@ -7,9 +7,12 @@ const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
@@ -17,7 +20,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
.first();
|
||||
|
||||
@@ -45,8 +48,9 @@ router.get('/:slug/info', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link')
|
||||
.where({ slug: slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
@@ -78,7 +82,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
requires_password: true,
|
||||
color_theme: event.color_theme
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: event.allow_downloads !== false,
|
||||
disable_right_click: event.disable_right_click === true,
|
||||
watermark_downloads: event.watermark_downloads === true,
|
||||
watermark_text: event.watermark_text
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -89,24 +97,41 @@ router.get('/:slug/info', async (req, res) => {
|
||||
// Get all photos
|
||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// First get all photos
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select(
|
||||
'photos.*',
|
||||
'photo_categories.name as category_name',
|
||||
'photo_categories.slug as category_slug'
|
||||
)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
// Get all categories for this event
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', req.event.id);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
.orderBy('name', 'asc');
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
.where('feedback_type', 'comment')
|
||||
.where('is_approved', true)
|
||||
.where('is_hidden', false)
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id', db.raw('COUNT(*) as comment_count'));
|
||||
|
||||
// Create a map for quick lookup
|
||||
const commentMap = {};
|
||||
commentCounts.forEach(c => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
// Get distinct photo types for this event
|
||||
const categoryResults = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.select('type')
|
||||
.distinct('type')
|
||||
.orderBy('type', 'asc');
|
||||
|
||||
// Convert types to category-like objects
|
||||
const categories = categoryResults.map(result => ({
|
||||
id: result.type,
|
||||
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
slug: result.type,
|
||||
is_global: false
|
||||
}));
|
||||
|
||||
// Log view
|
||||
await db('access_logs').insert({
|
||||
@@ -116,6 +141,23 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
action: 'view'
|
||||
});
|
||||
|
||||
// Include protection settings in response
|
||||
const protectionSettings = {
|
||||
protection_level: req.event.protection_level || 'standard',
|
||||
image_quality: req.event.image_quality || 85,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
fragmentation_level: req.event.fragmentation_level || 3,
|
||||
overlay_protection: req.event.overlay_protection !== false
|
||||
};
|
||||
|
||||
console.log('[Gallery Photos] Event data:', {
|
||||
id: req.event.id,
|
||||
slug: req.params.slug,
|
||||
protection_level: req.event.protection_level,
|
||||
calculated_protection: protectionSettings.protection_level,
|
||||
is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard')
|
||||
});
|
||||
|
||||
res.json({
|
||||
event: {
|
||||
id: req.event.id,
|
||||
@@ -125,26 +167,45 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
welcome_message: req.event.welcome_message,
|
||||
color_theme: req.event.color_theme,
|
||||
expires_at: req.event.expires_at,
|
||||
hero_photo_id: req.event.hero_photo_id
|
||||
hero_photo_id: req.event.hero_photo_id,
|
||||
allow_downloads: req.event.allow_downloads !== false,
|
||||
disable_right_click: req.event.disable_right_click === true,
|
||||
watermark_downloads: req.event.watermark_downloads === true,
|
||||
watermark_text: req.event.watermark_text,
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
is_global: cat.is_global
|
||||
})),
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
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,
|
||||
category_slug: photo.category_slug,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at
|
||||
}))
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: photoUrl,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
};
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
@@ -157,6 +218,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
@@ -177,7 +243,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
@@ -205,25 +281,25 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
// Download all photos as ZIP
|
||||
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Fetch photos with category information
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
// Fetch photos
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select(
|
||||
'photos.*',
|
||||
'photo_categories.name as category_name',
|
||||
'photo_categories.slug as category_slug'
|
||||
)
|
||||
.orderBy('photo_categories.name', 'asc')
|
||||
.select('photos.*')
|
||||
.orderBy('photos.type', 'asc')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found' });
|
||||
}
|
||||
|
||||
// Count unique categories (excluding null)
|
||||
const uniqueCategories = new Set(photos.filter(p => p.category_id).map(p => p.category_id)).size;
|
||||
const hasMultipleCategories = uniqueCategories > 1;
|
||||
// Count unique types
|
||||
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||
const hasMultipleTypes = uniqueTypes > 1;
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||
@@ -240,19 +316,24 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
}
|
||||
|
||||
// Determine the file name in the archive
|
||||
let archiveName;
|
||||
if (hasMultipleCategories) {
|
||||
if (photo.category_name) {
|
||||
// Use category name as folder (sanitize for filesystem)
|
||||
const folderName = photo.category_name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim();
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
} else {
|
||||
// Put uncategorized photos in 'Uncategorized' folder
|
||||
archiveName = path.join('Uncategorized', photo.filename);
|
||||
}
|
||||
if (hasMultipleTypes) {
|
||||
// Use photo type as folder
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
} else {
|
||||
// No folders, just the filename
|
||||
archiveName = photo.filename;
|
||||
@@ -282,77 +363,173 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/: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) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
res.sendFile(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving photo:', error);
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
// Test route
|
||||
router.get('/:slug/photo-test/:photoId',
|
||||
verifyGalleryAccess,
|
||||
(req, res) => {
|
||||
console.log('TEST ROUTE EXECUTED!');
|
||||
res.json({ message: 'Test route works!', photoId: req.params.photoId });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/: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) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
}
|
||||
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
// photoId,
|
||||
// req.event.id,
|
||||
// req.clientInfo,
|
||||
// 'view_basic'
|
||||
// );
|
||||
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file with basic protection headers
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving photo:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve photo', details: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
router.get('/:slug/thumbnail/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
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' });
|
||||
}
|
||||
|
||||
// Log thumbnail access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true'
|
||||
});
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get feedback settings for gallery
|
||||
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const settings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
|
||||
// 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));
|
||||
res.json({
|
||||
feedback_enabled: settings.feedback_enabled || false,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch feedback settings' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const crypto = require('crypto');
|
||||
|
||||
@@ -48,11 +49,20 @@ function verifyImageToken(token) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve watermarked image
|
||||
* Serve protected image with enhanced security
|
||||
*/
|
||||
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const { protectionLevel = 'standard', token } = req.query;
|
||||
|
||||
// Create client fingerprint
|
||||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||||
|
||||
// Check rate limiting
|
||||
if (!secureImageService.checkRateLimit(clientFingerprint, 30, 60000)) {
|
||||
return res.status(429).json({ error: 'Rate limit exceeded' });
|
||||
}
|
||||
|
||||
// Get photo details
|
||||
const photo = await db('photos')
|
||||
@@ -65,35 +75,123 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Check for suspicious activity
|
||||
const isSuspicious = await secureImageService.detectSuspiciousActivity(clientFingerprint, photoId);
|
||||
if (isSuspicious) {
|
||||
return res.status(429).json({ error: 'Suspicious activity detected' });
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
// Log access
|
||||
await secureImageService.logImageAccess(photoId, req.event.id, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
fingerprint: clientFingerprint
|
||||
}, 'view');
|
||||
|
||||
// Get protection settings from event
|
||||
const protectionSettings = {
|
||||
protectionLevel: req.event.protection_level || protectionLevel,
|
||||
quality: req.event.image_quality || 85,
|
||||
addFingerprint: req.event.add_fingerprint !== false,
|
||||
fragmentImage: protectionLevel === 'maximum'
|
||||
};
|
||||
|
||||
// Build full path to photo
|
||||
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||
|
||||
// Apply watermark if enabled
|
||||
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
|
||||
// Process image with protection
|
||||
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
|
||||
|
||||
// Set appropriate headers
|
||||
// Apply watermark if enabled
|
||||
let finalImage;
|
||||
if (processedImage.type === 'fragmented') {
|
||||
// Return fragmented image data for canvas reconstruction
|
||||
return res.json({
|
||||
type: 'fragmented',
|
||||
fragments: processedImage.fragments.map(f => ({
|
||||
index: f.index,
|
||||
row: f.row,
|
||||
col: f.col,
|
||||
data: f.buffer.toString('base64'),
|
||||
position: f.position
|
||||
})),
|
||||
dimensions: processedImage.originalDimensions,
|
||||
fragmentDimensions: processedImage.fragmentDimensions
|
||||
});
|
||||
} else {
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
|
||||
}
|
||||
|
||||
// Set security headers
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Length': imageBuffer.length,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
'Content-Length': finalImage.length,
|
||||
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Download-Options': 'noopen',
|
||||
'Content-Disposition': 'inline; filename="protected-image.jpg"'
|
||||
});
|
||||
|
||||
// Send the watermarked image
|
||||
res.send(imageBuffer);
|
||||
// Send the protected image
|
||||
res.send(finalImage);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving watermarked image:', error);
|
||||
console.error('Error serving protected image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate signed URL for image access
|
||||
* Generate secure token for enhanced image access
|
||||
*/
|
||||
router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const { protectionLevel = 'standard', expiresIn = 300 } = req.body;
|
||||
|
||||
// Verify photo belongs to this event
|
||||
const photo = await db('photos')
|
||||
.where({
|
||||
id: photoId,
|
||||
event_id: req.event.id
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Create client fingerprint
|
||||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||||
|
||||
// Generate secure token
|
||||
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
|
||||
expiresIn,
|
||||
maxUses: protectionLevel === 'maximum' ? 1 : 3,
|
||||
clientFingerprint,
|
||||
protectionLevel
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
expiresIn,
|
||||
protectionLevel,
|
||||
maxUses: protectionLevel === 'maximum' ? 1 : 3
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating secure token:', error);
|
||||
res.status(500).json({ error: 'Failed to generate token' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate signed URL for image access (legacy support)
|
||||
*/
|
||||
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Generate secure token for image access
|
||||
*/
|
||||
router.post('/:slug/generate-token', async (req, res, next) => {
|
||||
// Add slug to request for verifyGalleryAccess
|
||||
req.requestedSlug = req.params.slug;
|
||||
next();
|
||||
}, verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId, accessType = 'view' } = req.body;
|
||||
|
||||
if (!photoId) {
|
||||
return res.status(400).json({ error: 'Photo ID required' });
|
||||
}
|
||||
|
||||
// Verify photo exists and belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Create client fingerprint
|
||||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||||
|
||||
// Get protection level from event settings
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
// Generate secure token with appropriate settings
|
||||
const tokenOptions = {
|
||||
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
||||
maxUses: accessType === 'download' ? 1 : 3,
|
||||
clientFingerprint,
|
||||
protectionLevel
|
||||
};
|
||||
|
||||
const token = secureImageService.generateSecureToken(
|
||||
photoId,
|
||||
req.sessionID || 'anonymous',
|
||||
tokenOptions
|
||||
);
|
||||
|
||||
// Log token generation
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
{
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
fingerprint: clientFingerprint
|
||||
},
|
||||
'token_generated'
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
expiresIn: tokenOptions.expiresIn,
|
||||
maxUses: tokenOptions.maxUses,
|
||||
protectionLevel
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error generating secure token', {
|
||||
error: error.message,
|
||||
photoId: req.body.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to generate secure token' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve protected image with security measures
|
||||
*/
|
||||
router.get('/:slug/secure/:photoId/:token',
|
||||
secureImageMiddleware.secureImageAccess,
|
||||
async (req, res) => {
|
||||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||||
|
||||
try {
|
||||
console.log('Secure image route hit:', {
|
||||
slug: slug,
|
||||
photoId: photoId,
|
||||
tokenLength: token?.length,
|
||||
headers: req.headers.authorization ? 'present' : 'absent'
|
||||
});
|
||||
const { fragment } = req.query;
|
||||
|
||||
// Verify secure token
|
||||
const tokenValidation = secureImageService.verifySecureToken(
|
||||
token,
|
||||
req.clientInfo.fingerprint
|
||||
);
|
||||
|
||||
if (!tokenValidation.valid) {
|
||||
// Get event for logging (best effort)
|
||||
const event = await db('events').where({ slug }).first();
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
event?.id || 0,
|
||||
req.clientInfo,
|
||||
'token_invalid'
|
||||
);
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
// Get event from slug
|
||||
const event = await db('events')
|
||||
.where({
|
||||
slug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Verify photo exists and belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
|
||||
// Get protection settings for this event
|
||||
const protectionSettings = {
|
||||
protectionLevel: event.protection_level || 'standard',
|
||||
quality: event.image_quality || 85,
|
||||
addFingerprint: event.add_fingerprint !== false,
|
||||
fragmentImage: event.use_canvas_rendering === true && fragment !== undefined
|
||||
};
|
||||
|
||||
// Process image with protection measures
|
||||
const processedImage = await secureImageService.processProtectedImage(
|
||||
filePath,
|
||||
protectionSettings
|
||||
);
|
||||
|
||||
// Handle fragmented images
|
||||
if (processedImage.type === 'fragmented') {
|
||||
return await handleFragmentedImage(req, res, processedImage, fragment);
|
||||
}
|
||||
|
||||
// Log successful access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
event.id,
|
||||
req.clientInfo,
|
||||
'view'
|
||||
);
|
||||
|
||||
// Set content type and security headers
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Length': processedImage.length,
|
||||
'X-Protection-Level': protectionSettings.protectionLevel,
|
||||
'X-Remaining-Uses': tokenValidation.remaining
|
||||
});
|
||||
|
||||
res.send(processedImage);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error serving secure image', {
|
||||
error: error.message,
|
||||
photoId,
|
||||
slug,
|
||||
clientFingerprint: req.clientInfo?.fingerprint
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle fragmented image delivery
|
||||
*/
|
||||
async function handleFragmentedImage(req, res, fragmentedImage, fragmentIndex) {
|
||||
const { photoId } = req.params;
|
||||
|
||||
try {
|
||||
if (fragmentIndex === undefined) {
|
||||
// Return fragment metadata
|
||||
res.json({
|
||||
type: 'fragmented',
|
||||
fragments: fragmentedImage.fragments.length,
|
||||
dimensions: fragmentedImage.originalDimensions,
|
||||
fragmentDimensions: fragmentedImage.fragmentDimensions
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const index = parseInt(fragmentIndex);
|
||||
if (isNaN(index) || index < 0 || index >= fragmentedImage.fragments.length) {
|
||||
return res.status(400).json({ error: 'Invalid fragment index' });
|
||||
}
|
||||
|
||||
const fragment = fragmentedImage.fragments[index];
|
||||
|
||||
// Log fragment access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
`fragment_${index}`
|
||||
);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Length': fragment.buffer.length,
|
||||
'X-Fragment-Index': index,
|
||||
'X-Fragment-Position': JSON.stringify(fragment.position)
|
||||
});
|
||||
|
||||
res.send(fragment.buffer);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error serving image fragment', {
|
||||
error: error.message,
|
||||
fragmentIndex,
|
||||
photoId
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve image fragment' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download protected image with watermark
|
||||
*/
|
||||
router.get('/:slug/secure-download/:photoId/:token',
|
||||
secureImageMiddleware.secureImageAccess,
|
||||
async (req, res, next) => {
|
||||
// Add slug to request for verifyGalleryAccess
|
||||
req.requestedSlug = req.params.slug;
|
||||
next();
|
||||
},
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId, token } = req.params;
|
||||
|
||||
// Check if downloads are allowed
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
// Verify secure token
|
||||
const tokenValidation = secureImageService.verifySecureToken(
|
||||
token,
|
||||
req.clientInfo.fingerprint
|
||||
);
|
||||
|
||||
if (!tokenValidation.valid) {
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
// Verify photo exists
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
|
||||
// Apply watermark if enabled
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
let fileBuffer;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
fileBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
} else {
|
||||
const fs = require('fs').promises;
|
||||
fileBuffer = await fs.readFile(filePath);
|
||||
}
|
||||
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
// Log download
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'download'
|
||||
);
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
||||
'Content-Length': fileBuffer.length,
|
||||
'X-Download-Protected': 'true'
|
||||
});
|
||||
|
||||
res.send(fileBuffer);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error serving secure download', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download image' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get security statistics for monitoring
|
||||
*/
|
||||
router.get('/security/stats', async (req, res) => {
|
||||
try {
|
||||
// Only allow admin access
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
const admin = await db('admin_users').where({ id: decoded.id }).first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Get security statistics
|
||||
const stats = {
|
||||
middleware: secureImageMiddleware.getSecurityStatus(),
|
||||
recentAccess: await getRecentAccessStats(),
|
||||
suspiciousActivity: await getSuspiciousActivityStats()
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error getting security stats', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get security stats' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get recent access statistics
|
||||
*/
|
||||
async function getRecentAccessStats() {
|
||||
try {
|
||||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
|
||||
const stats = await db('image_access_logs')
|
||||
.where('accessed_at', '>', hourAgo)
|
||||
.select('access_type')
|
||||
.count('* as count')
|
||||
.groupBy('access_type');
|
||||
|
||||
return stats.reduce((acc, stat) => {
|
||||
acc[stat.access_type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error('Error getting recent access stats:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspicious activity statistics
|
||||
*/
|
||||
async function getSuspiciousActivityStats() {
|
||||
try {
|
||||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
|
||||
const suspiciousCount = await db('image_access_logs')
|
||||
.where('accessed_at', '>', hourAgo)
|
||||
.where('access_type', 'like', '%suspicious%')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const uniqueIPs = await db('image_access_logs')
|
||||
.where('accessed_at', '>', hourAgo)
|
||||
.countDistinct('client_ip as count')
|
||||
.first();
|
||||
|
||||
return {
|
||||
suspiciousEvents: parseInt(suspiciousCount.count),
|
||||
uniqueIPs: parseInt(uniqueIPs.count)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting suspicious activity stats:', error);
|
||||
return { suspiciousEvents: 0, uniqueIPs: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -120,7 +120,7 @@ class FeedbackService {
|
||||
}
|
||||
|
||||
// Insert new feedback
|
||||
const [id] = await db('photo_feedback').insert({
|
||||
const result = await db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
@@ -134,7 +134,9 @@ class FeedbackService {
|
||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
const id = result[0]?.id || result[0];
|
||||
|
||||
// Update photo stats
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
@@ -175,7 +177,7 @@ class FeedbackService {
|
||||
|
||||
const feedback = await query
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at');
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||
|
||||
return feedback;
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,21 +2,69 @@ const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
// Configure sharp for better memory management with large batches
|
||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||
sharp.concurrency(2); // Limit concurrent operations
|
||||
|
||||
const THUMBNAIL_WIDTH = 300;
|
||||
// Default thumbnail settings
|
||||
const DEFAULT_THUMBNAIL_WIDTH = 300;
|
||||
const DEFAULT_THUMBNAIL_HEIGHT = 300;
|
||||
const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops
|
||||
const DEFAULT_THUMBNAIL_QUALITY = 85;
|
||||
const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||
|
||||
// Get thumbnail settings from database
|
||||
async function getThumbnailSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.setting_key] = s.setting_value;
|
||||
});
|
||||
|
||||
return {
|
||||
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
|
||||
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
|
||||
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
|
||||
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
|
||||
};
|
||||
} catch (error) {
|
||||
// If database is not ready or settings don't exist, use defaults
|
||||
logger.warn('Could not fetch thumbnail settings, using defaults:', error.message);
|
||||
return {
|
||||
width: DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: DEFAULT_THUMBNAIL_HEIGHT,
|
||||
fit: DEFAULT_THUMBNAIL_FIT,
|
||||
quality: DEFAULT_THUMBNAIL_QUALITY,
|
||||
format: DEFAULT_THUMBNAIL_FORMAT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const thumbnailFilename = `thumb_${filename}`;
|
||||
const thumbnailDir = getThumbnailPath();
|
||||
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
||||
|
||||
// Get thumbnail settings
|
||||
const settings = await getThumbnailSettings();
|
||||
|
||||
// Ensure thumbnail directory exists
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
|
||||
@@ -38,22 +86,43 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
throw new Error('Invalid image metadata - file may be incomplete');
|
||||
}
|
||||
|
||||
// Generate thumbnail with memory-efficient settings and error handling
|
||||
await sharp(imagePath, {
|
||||
// Create sharp instance with memory-efficient settings
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true, // More memory efficient for large images
|
||||
failOnError: false // Don't fail on minor issues
|
||||
})
|
||||
.resize(THUMBNAIL_WIDTH, null, {
|
||||
withoutEnlargement: true,
|
||||
fit: 'inside'
|
||||
})
|
||||
.jpeg({
|
||||
quality: 80,
|
||||
});
|
||||
|
||||
// Apply resize with configured settings
|
||||
// For square thumbnails with 'cover' fit, we crop to center
|
||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||
withoutEnlargement: true,
|
||||
fit: settings.fit, // 'cover' will crop to fill the exact dimensions
|
||||
position: 'center' // Center the crop for better composition
|
||||
});
|
||||
|
||||
// Apply format-specific options
|
||||
if (settings.format === 'jpeg') {
|
||||
sharpInstance = sharpInstance.jpeg({
|
||||
quality: settings.quality,
|
||||
progressive: true, // Progressive JPEG for better loading
|
||||
mozjpeg: true // Better compression
|
||||
})
|
||||
.toFile(thumbnailPath);
|
||||
});
|
||||
} else if (settings.format === 'png') {
|
||||
sharpInstance = sharpInstance.png({
|
||||
quality: settings.quality,
|
||||
compressionLevel: 9,
|
||||
progressive: true
|
||||
});
|
||||
} else if (settings.format === 'webp') {
|
||||
sharpInstance = sharpInstance.webp({
|
||||
quality: settings.quality,
|
||||
effort: 4 // Balance between speed and compression
|
||||
});
|
||||
}
|
||||
|
||||
// Save the thumbnail
|
||||
await sharpInstance.toFile(thumbnailPath);
|
||||
|
||||
// Verify the thumbnail was created successfully
|
||||
const stats = await fs.stat(thumbnailPath);
|
||||
|
||||
@@ -21,39 +21,29 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
// Get category info if provided
|
||||
let category = null;
|
||||
// Count existing photos to generate sequence number
|
||||
let counter = 1;
|
||||
const parsedCategoryId = categoryId ? parseInt(categoryId) : null;
|
||||
let photoType = 'individual'; // default type
|
||||
|
||||
if (parsedCategoryId) {
|
||||
// Get category and update counter
|
||||
category = await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.first();
|
||||
|
||||
if (category) {
|
||||
counter = (category.photo_counter || 0) + 1;
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: counter });
|
||||
}
|
||||
} else {
|
||||
// For uncategorized photos, count existing uncategorized photos
|
||||
const uncategorizedCount = await trx('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('category_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
counter = (uncategorizedCount.count || 0) + 1;
|
||||
// If categoryId is provided and matches photo types, use it as type
|
||||
if (categoryId === 'collage') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
|
||||
// Count existing photos of the same type for numbering
|
||||
const existingCount = await trx('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
counter = (existingCount.count || 0) + 1;
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
@@ -81,10 +71,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: parsedCategoryId || null,
|
||||
type: 'individual',
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy
|
||||
type: photoType,
|
||||
size_bytes: file.size
|
||||
});
|
||||
|
||||
// Commit transaction
|
||||
@@ -94,8 +82,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: file.size,
|
||||
category_id: parsedCategoryId || null,
|
||||
uploaded_by: uploadedBy
|
||||
type: photoType
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.originalname}:`, error);
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
class SecureImageService {
|
||||
constructor() {
|
||||
this.tokenCache = new Map();
|
||||
this.sessionTokens = new Map();
|
||||
this.rateLimitCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure, time-limited, single-use token for image access
|
||||
*/
|
||||
generateSecureToken(photoId, sessionId, options = {}) {
|
||||
const {
|
||||
expiresIn = 300, // 5 minutes default
|
||||
maxUses = 1,
|
||||
clientFingerprint = '',
|
||||
protectionLevel = 'standard'
|
||||
} = options;
|
||||
|
||||
const tokenData = {
|
||||
photoId: parseInt(photoId),
|
||||
sessionId,
|
||||
clientFingerprint,
|
||||
expiresAt: Date.now() + (expiresIn * 1000),
|
||||
maxUses,
|
||||
usedCount: 0,
|
||||
protectionLevel,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// Create tamper-proof token
|
||||
const tokenPayload = Buffer.from(JSON.stringify(tokenData)).toString('base64');
|
||||
const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION';
|
||||
const signature = crypto
|
||||
.createHmac('sha256', process.env.JWT_SECRET + imageSecret)
|
||||
.update(tokenPayload)
|
||||
.digest('hex');
|
||||
|
||||
const token = `${tokenPayload}.${signature}`;
|
||||
|
||||
// Cache token with metadata
|
||||
this.tokenCache.set(token, tokenData);
|
||||
|
||||
// Set cleanup timer
|
||||
setTimeout(() => {
|
||||
this.tokenCache.delete(token);
|
||||
}, expiresIn * 1000 + 60000); // Add 1 minute buffer
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and consume secure token
|
||||
*/
|
||||
verifySecureToken(token, clientFingerprint = '') {
|
||||
try {
|
||||
const cached = this.tokenCache.get(token);
|
||||
if (!cached) {
|
||||
return { valid: false, reason: 'Token not found or expired' };
|
||||
}
|
||||
|
||||
// Verify token integrity
|
||||
const [payload, signature] = token.split('.');
|
||||
const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION';
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', process.env.JWT_SECRET + imageSecret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
return { valid: false, reason: 'Token tampered' };
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
this.tokenCache.delete(token);
|
||||
return { valid: false, reason: 'Token expired' };
|
||||
}
|
||||
|
||||
// Check usage count
|
||||
if (cached.usedCount >= cached.maxUses) {
|
||||
return { valid: false, reason: 'Token max uses exceeded' };
|
||||
}
|
||||
|
||||
// Verify client fingerprint for enhanced security
|
||||
if (cached.protectionLevel === 'enhanced' && cached.clientFingerprint !== clientFingerprint) {
|
||||
return { valid: false, reason: 'Client fingerprint mismatch' };
|
||||
}
|
||||
|
||||
// Consume usage
|
||||
cached.usedCount++;
|
||||
|
||||
// Remove token if max uses reached
|
||||
if (cached.usedCount >= cached.maxUses) {
|
||||
this.tokenCache.delete(token);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
data: cached,
|
||||
remaining: cached.maxUses - cached.usedCount
|
||||
};
|
||||
} catch (error) {
|
||||
return { valid: false, reason: 'Token verification failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create client fingerprint from request
|
||||
*/
|
||||
createClientFingerprint(req) {
|
||||
const components = [
|
||||
req.ip,
|
||||
req.get('User-Agent') || '',
|
||||
req.get('Accept-Language') || '',
|
||||
req.get('Accept-Encoding') || ''
|
||||
];
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(components.join('|'))
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting for image requests
|
||||
*/
|
||||
checkRateLimit(clientId, limit = 50, windowMs = 60000) {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
if (!this.rateLimitCache.has(clientId)) {
|
||||
this.rateLimitCache.set(clientId, []);
|
||||
}
|
||||
|
||||
const requests = this.rateLimitCache.get(clientId);
|
||||
|
||||
// Remove old requests outside the window
|
||||
const recentRequests = requests.filter(timestamp => timestamp > windowStart);
|
||||
this.rateLimitCache.set(clientId, recentRequests);
|
||||
|
||||
if (recentRequests.length >= limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add current request
|
||||
recentRequests.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image with protection measures
|
||||
*/
|
||||
async processProtectedImage(imagePath, options = {}) {
|
||||
const {
|
||||
protectionLevel = 'standard',
|
||||
quality = 85,
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1080,
|
||||
addFingerprint = true,
|
||||
fragmentImage = false
|
||||
} = options;
|
||||
|
||||
try {
|
||||
let image = sharp(imagePath);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// Resize if too large
|
||||
if (metadata.width > maxWidth || metadata.height > maxHeight) {
|
||||
image = image.resize(maxWidth, maxHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
});
|
||||
}
|
||||
|
||||
// Apply quality reduction for protection
|
||||
if (protectionLevel === 'enhanced') {
|
||||
quality = Math.min(quality, 70);
|
||||
} else if (protectionLevel === 'maximum') {
|
||||
quality = Math.min(quality, 60);
|
||||
}
|
||||
|
||||
// Convert to appropriate format
|
||||
image = image.jpeg({ quality, progressive: true });
|
||||
|
||||
// Add invisible watermark/fingerprint
|
||||
if (addFingerprint) {
|
||||
const fingerprint = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Embed fingerprint in metadata
|
||||
image = image.withMetadata({
|
||||
exif: {
|
||||
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await image.toBuffer();
|
||||
|
||||
// Fragment image if requested (for canvas reconstruction)
|
||||
if (fragmentImage && protectionLevel === 'maximum') {
|
||||
return await this.fragmentImageBuffer(buffer, metadata);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
console.error('Error processing protected image:', error);
|
||||
// Return original on error
|
||||
return await fs.readFile(imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment image into multiple pieces for canvas reconstruction
|
||||
*/
|
||||
async fragmentImageBuffer(buffer, metadata) {
|
||||
const { width, height } = metadata;
|
||||
const fragments = [];
|
||||
|
||||
// Create 3x3 grid of fragments
|
||||
const cols = 3;
|
||||
const rows = 3;
|
||||
const fragmentWidth = Math.floor(width / cols);
|
||||
const fragmentHeight = Math.floor(height / rows);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const left = col * fragmentWidth;
|
||||
const top = row * fragmentHeight;
|
||||
|
||||
const fragment = await sharp(buffer)
|
||||
.extract({
|
||||
left,
|
||||
top,
|
||||
width: fragmentWidth,
|
||||
height: fragmentHeight
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
fragments.push({
|
||||
index: row * cols + col,
|
||||
row,
|
||||
col,
|
||||
buffer: fragment,
|
||||
position: { left, top, width: fragmentWidth, height: fragmentHeight }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'fragmented',
|
||||
fragments,
|
||||
originalDimensions: { width, height },
|
||||
fragmentDimensions: { width: fragmentWidth, height: fragmentHeight, cols, rows }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log image access for security monitoring
|
||||
*/
|
||||
async logImageAccess(photoId, eventId, clientInfo, accessType = 'view', metadata = {}) {
|
||||
try {
|
||||
const logEntry = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
client_ip: clientInfo.ip,
|
||||
user_agent: clientInfo.userAgent?.substring(0, 500), // Limit length
|
||||
access_type: accessType,
|
||||
client_fingerprint: clientInfo.fingerprint?.substring(0, 32) || 'unknown',
|
||||
accessed_at: new Date().toISOString(),
|
||||
metadata: JSON.stringify({
|
||||
timestamp: clientInfo.timestamp || Date.now(),
|
||||
...metadata
|
||||
})
|
||||
};
|
||||
|
||||
await db('image_access_logs').insert(logEntry);
|
||||
|
||||
// Check for rapid successive access (potential scraping)
|
||||
if (accessType === 'view' || accessType === 'download') {
|
||||
await this.checkForRapidAccess(clientInfo.fingerprint, photoId, eventId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error logging image access:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced suspicious activity detection
|
||||
*/
|
||||
async checkForRapidAccess(clientFingerprint, photoId, eventId = null) {
|
||||
try {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 300000).toISOString();
|
||||
|
||||
// Check accesses to same photo
|
||||
const samePhotoAccess = await db('image_access_logs')
|
||||
.where('client_fingerprint', clientFingerprint)
|
||||
.where('photo_id', photoId)
|
||||
.where('accessed_at', '>', fiveMinutesAgo)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
// Check total accesses across all photos
|
||||
const totalAccess = await db('image_access_logs')
|
||||
.where('client_fingerprint', clientFingerprint)
|
||||
.where('accessed_at', '>', fiveMinutesAgo)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const samePhotoCount = parseInt(samePhotoAccess.count);
|
||||
const totalCount = parseInt(totalAccess.count);
|
||||
|
||||
// Flag if suspicious patterns detected
|
||||
if (samePhotoCount > 5 || totalCount > 30) {
|
||||
await this.flagSuspiciousActivity(
|
||||
clientFingerprint,
|
||||
photoId,
|
||||
'rapid_access',
|
||||
{ samePhotoCount, totalCount, eventId }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking for rapid access:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag suspicious activity and take action
|
||||
*/
|
||||
async flagSuspiciousActivity(clientFingerprint, photoId, reason, details = {}) {
|
||||
try {
|
||||
// Try to get event_id from photo
|
||||
let eventId = details.eventId;
|
||||
if (!eventId && photoId) {
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
eventId = photo?.event_id;
|
||||
}
|
||||
|
||||
// Log the suspicious activity
|
||||
await db('image_access_logs').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId || 0, // Use 0 as a fallback for suspicious activity without event context
|
||||
client_ip: details.clientIp || 'unknown',
|
||||
client_fingerprint: clientFingerprint,
|
||||
access_type: 'suspicious',
|
||||
accessed_at: new Date().toISOString(),
|
||||
metadata: JSON.stringify({
|
||||
reason,
|
||||
...details,
|
||||
flaggedAt: Date.now()
|
||||
})
|
||||
});
|
||||
|
||||
console.warn(`Suspicious activity flagged: ${reason}`, {
|
||||
clientFingerprint,
|
||||
photoId,
|
||||
details
|
||||
});
|
||||
|
||||
// If multiple suspicious activities, consider blocking
|
||||
const recentSuspicious = await db('image_access_logs')
|
||||
.where('client_fingerprint', clientFingerprint)
|
||||
.where('access_type', 'suspicious')
|
||||
.where('accessed_at', '>', new Date(Date.now() - 3600000).toISOString()) // Last hour
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
if (parseInt(recentSuspicious.count) >= 3) {
|
||||
console.warn(`Client fingerprint flagged for blocking: ${clientFingerprint}`);
|
||||
// This would be handled by the middleware's blocking system
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error flagging suspicious activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect suspicious access patterns
|
||||
*/
|
||||
async detectSuspiciousActivity(clientFingerprint, photoId) {
|
||||
try {
|
||||
const recentAccess = await db('image_access_logs')
|
||||
.where('client_fingerprint', clientFingerprint)
|
||||
.where('photo_id', photoId)
|
||||
.where('accessed_at', '>', new Date(Date.now() - 300000).toISOString()) // Last 5 minutes
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const accessCount = parseInt(recentAccess.count);
|
||||
|
||||
// Flag if more than 10 accesses to same photo in 5 minutes
|
||||
if (accessCount > 10) {
|
||||
console.warn(`Suspicious activity detected: ${accessCount} accesses to photo ${photoId} from ${clientFingerprint}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error detecting suspicious activity:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens and logs
|
||||
*/
|
||||
cleanup() {
|
||||
// Clear expired rate limit entries
|
||||
const now = Date.now();
|
||||
for (const [clientId, requests] of this.rateLimitCache.entries()) {
|
||||
const recent = requests.filter(timestamp => timestamp > now - 60000);
|
||||
if (recent.length === 0) {
|
||||
this.rateLimitCache.delete(clientId);
|
||||
} else {
|
||||
this.rateLimitCache.set(clientId, recent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SecureImageService();
|
||||
@@ -20,6 +20,12 @@ const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempt
|
||||
*/
|
||||
async function trackFailedAttempt(identifier, ipAddress, userAgent) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db('login_attempts').insert({
|
||||
identifier,
|
||||
ip_address: ipAddress,
|
||||
@@ -48,6 +54,12 @@ async function trackFailedAttempt(identifier, ipAddress, userAgent) {
|
||||
*/
|
||||
async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db('login_attempts').insert({
|
||||
identifier,
|
||||
ip_address: ipAddress,
|
||||
@@ -75,6 +87,12 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
*/
|
||||
async function checkAccountLockout(identifier) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return { isLocked: false };
|
||||
}
|
||||
|
||||
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
|
||||
// Get recent failed attempts
|
||||
@@ -114,6 +132,12 @@ async function checkAccountLockout(identifier) {
|
||||
*/
|
||||
async function checkSuspiciousActivity(identifier, ipAddress) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for rapid attempts from different IPs
|
||||
const recentWindow = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
@@ -153,6 +177,13 @@ function getGenericAuthError() {
|
||||
*/
|
||||
async function cleanupOldAttempts() {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
// Table doesn't exist, skip cleanup
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
const deleted = await db('login_attempts')
|
||||
|
||||
@@ -152,18 +152,25 @@ const validateFeedbackSubmission = [
|
||||
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters')
|
||||
.matches(/^[a-zA-Z0-9\s\-'.]+$/)
|
||||
.withMessage('Name contains invalid characters'),
|
||||
.custom((value) => {
|
||||
// Allow empty or whitespace-only strings
|
||||
if (!value || value.trim() === '') return true;
|
||||
// If not empty, check length and pattern
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 100) throw new Error('Name must be less than 100 characters');
|
||||
if (!/^[a-zA-Z0-9\s\-'.]+$/.test(trimmed)) throw new Error('Name contains invalid characters');
|
||||
return true;
|
||||
}),
|
||||
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
.custom((value) => {
|
||||
// Allow empty or whitespace-only strings
|
||||
if (!value || value.trim() === '') return true;
|
||||
// If not empty, validate as email
|
||||
if (!validator.isEmail(value.trim())) throw new Error('Invalid email address');
|
||||
return true;
|
||||
})
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: picpeak-backend-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3001
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||
- DATABASE_CLIENT=pg
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
|
||||
- DB_TYPE=postgresql
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE:-false}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- ./events:/app/events
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./backup:/backup
|
||||
- ./storage:/app/storage
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: picpeak-postgres-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: picpeak-redis-dev
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||
container_name: picpeak-frontend-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3000:3005"
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3005"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
picpeak-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,113 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: picpeak-postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-picpeak}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME:-picpeak}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- picpeak-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: picpeak-redis
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- picpeak-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
container_name: picpeak-backend
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=postgres
|
||||
- REDIS_HOST=redis
|
||||
- PHOTOS_DIR=/app/storage/events
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3000"
|
||||
networks:
|
||||
- picpeak-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
container_name: picpeak-frontend
|
||||
environment:
|
||||
- VITE_API_URL=${FRONTEND_API_URL:-http://localhost:3001}
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
networks:
|
||||
- picpeak-network
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Optional: Nginx reverse proxy for production with SSL
|
||||
# Uncomment and configure if you want built-in HTTPS support
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# container_name: picpeak-nginx
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
# - ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
# - ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
# networks:
|
||||
# - picpeak-network
|
||||
# depends_on:
|
||||
# - frontend
|
||||
# - backend
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
picpeak-network:
|
||||
driver: bridge
|
||||
@@ -1,125 +1,125 @@
|
||||
# docker-compose.yml - Production configuration
|
||||
#
|
||||
# For development, use: docker-compose -f docker-compose.dev.yml up -d
|
||||
# For local customizations, create docker-compose.override.yml (see docker-compose.override.yml.example)
|
||||
#
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: picpeak-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: picpeak-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- PORT=3001
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_URL=${ADMIN_URL}
|
||||
- FRONTEND_URL=${FRONTEND_URL}
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
# Database
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||
- DATABASE_CLIENT=pg
|
||||
- DB_HOST=db
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
|
||||
- DB_TYPE=postgresql
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-picpeak}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME:-picpeak}
|
||||
# Email
|
||||
- DB_NAME=${DB_NAME}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- SMTP_SECURE=${SMTP_SECURE:-false}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
# Analytics
|
||||
- UMAMI_URL=${UMAMI_URL}
|
||||
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
||||
# Storage paths
|
||||
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||
- TZ=${TZ:-UTC}
|
||||
- STORAGE_PATH=/app/storage
|
||||
- EVENTS_PATH=/app/storage/events
|
||||
- ARCHIVE_PATH=/app/storage/events/archived
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./events:/app/events
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./backup:/backup
|
||||
- ./storage:/app/storage
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- picpeak
|
||||
- picpeak-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: picpeak-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: picpeak-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
frontend:
|
||||
image: picpeak-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- picpeak
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||
container_name: picpeak-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/sites-enabled:/etc/nginx/sites-enabled
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- picpeak
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||
# Allow connections from any host with password authentication
|
||||
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- picpeak
|
||||
# Allow connections without SSL requirement from Docker network
|
||||
command: postgres -c ssl=off
|
||||
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${DB_USER:-picpeak}:${DB_PASSWORD}@db:5432/umami
|
||||
DATABASE_TYPE: postgresql
|
||||
HASH_SALT: ${UMAMI_HASH_SALT}
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- picpeak
|
||||
|
||||
networks:
|
||||
picpeak:
|
||||
driver: bridge
|
||||
- picpeak-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
picpeak-network:
|
||||
driver: bridge
|
||||
@@ -1,6 +1,9 @@
|
||||
# Backend API URL
|
||||
# For local development:
|
||||
VITE_API_URL=http://localhost:3001
|
||||
# For local development with Docker:
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
|
||||
# For local development without Docker:
|
||||
# VITE_API_URL=http://localhost:3001
|
||||
|
||||
# For production behind reverse proxy (Traefik, nginx, etc):
|
||||
# VITE_API_URL=/api
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Allow larger file uploads (up to 100MB)
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -39,7 +43,7 @@ server {
|
||||
|
||||
# API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -49,11 +53,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
# Allow larger uploads for API endpoints
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
}
|
||||
|
||||
# Photo serving proxy
|
||||
location /photos {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -67,7 +75,7 @@ server {
|
||||
|
||||
# Thumbnail serving proxy
|
||||
location /thumbnails {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -81,7 +89,7 @@ server {
|
||||
|
||||
# Uploads serving proxy (logos, favicons, watermarks)
|
||||
location /uploads {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.107",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.107",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.96",
|
||||
"version": "1.0.107",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -80,19 +80,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Date display */}
|
||||
<div className="hidden lg:block">
|
||||
{/* Date display - hidden on small screens */}
|
||||
<div className="hidden xl:block">
|
||||
<p className="text-base text-neutral-700">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Logo and PicPeak text */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
|
||||
{/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */}
|
||||
<div className="hidden lg:flex absolute left-1/2 transform -translate-x-1/2 items-center gap-3">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
|
||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile Logo - shown only on small screens */}
|
||||
<div className="flex lg:hidden items-center gap-2 mx-auto">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
|
||||
<span className="text-xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useSessionTimeout } from '../../hooks/useSessionTimeout';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
import { AdminHeader } from './AdminHeader';
|
||||
import { MaintenanceBanner } from './MaintenanceBanner';
|
||||
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
||||
|
||||
export const AdminLayout: React.FC = () => {
|
||||
const { isAuthenticated, isLoading } = useAdminAuth();
|
||||
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// Handle session timeout
|
||||
@@ -31,6 +32,9 @@ export const AdminLayout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
||||
{/* Mandatory Password Change Modal */}
|
||||
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
||||
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -39,19 +43,23 @@ export const AdminLayout: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
{/* Sidebar - disabled when password change required */}
|
||||
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
{/* Header */}
|
||||
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
||||
{/* Header - disabled when password change required */}
|
||||
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
||||
</div>
|
||||
|
||||
{/* Maintenance mode banner */}
|
||||
<MaintenanceBanner />
|
||||
|
||||
{/* Page content */}
|
||||
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto">
|
||||
{/* Page content - disabled when password change required */}
|
||||
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
@@ -246,6 +246,24 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10" style={{ left: isSelectionMode ? '40px' : '8px' }}>
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
@@ -28,9 +30,21 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
|
||||
const [expandedComments, setExpandedComments] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
// Fetch feedback for current photo
|
||||
const { data: feedbackData } = useQuery({
|
||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||
photoId: currentPhoto?.id.toString(),
|
||||
status: 'all' // Get all comments including unapproved
|
||||
}),
|
||||
enabled: !!currentPhoto
|
||||
});
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
};
|
||||
@@ -88,6 +102,30 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Mutations for feedback moderation
|
||||
const moderateFeedbackMutation = useMutation({
|
||||
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
|
||||
feedbackService.moderateFeedback(feedbackId, action),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] });
|
||||
toast.success('Feedback moderated successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to moderate feedback');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteFeedbackMutation = useMutation({
|
||||
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] });
|
||||
toast.success('Feedback deleted successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete feedback');
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
@@ -256,6 +294,177 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
{feedbackData && (
|
||||
<div className="mt-6 pt-6 border-t border-neutral-700">
|
||||
<h4 className="text-white font-medium mb-4 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Feedback & Comments
|
||||
</h4>
|
||||
|
||||
{/* Feedback Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{currentPhoto.average_rating > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
||||
<Star className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Avg Rating</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.like_count > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-red-400 mb-1">
|
||||
<Heart className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{currentPhoto.like_count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Likes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.favorite_count > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Favorites</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedbackData.feedback && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-green-400 mb-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Comments</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setExpandedComments(!expandedComments)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
||||
>
|
||||
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
|
||||
</button>
|
||||
|
||||
{expandedComments && (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{feedbackData.feedback
|
||||
.filter(f => f.feedback_type === 'comment')
|
||||
.map((comment) => (
|
||||
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{comment.guest_name || 'Anonymous'}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment Status Badge */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!comment.is_approved && !comment.is_hidden && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
{comment.is_approved && !comment.is_hidden && (
|
||||
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-1 rounded flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Approved
|
||||
</span>
|
||||
)}
|
||||
{comment.is_hidden && (
|
||||
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Hidden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-300 mb-3">
|
||||
{comment.comment_text}
|
||||
</p>
|
||||
|
||||
{/* Moderation Actions */}
|
||||
<div className="flex gap-2">
|
||||
{!comment.is_approved && (
|
||||
<button
|
||||
onClick={() => moderateFeedbackMutation.mutate({
|
||||
feedbackId: comment.id.toString(),
|
||||
action: 'approve'
|
||||
})}
|
||||
disabled={moderateFeedbackMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!comment.is_hidden && (
|
||||
<button
|
||||
onClick={() => moderateFeedbackMutation.mutate({
|
||||
feedbackId: comment.id.toString(),
|
||||
action: 'hide'
|
||||
})}
|
||||
disabled={moderateFeedbackMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
|
||||
{comment.is_hidden && (
|
||||
<button
|
||||
onClick={() => moderateFeedbackMutation.mutate({
|
||||
feedbackId: comment.id.toString(),
|
||||
action: 'approve'
|
||||
})}
|
||||
disabled={moderateFeedbackMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
|
||||
>
|
||||
Unhide
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this comment?')) {
|
||||
deleteFeedbackMutation.mutate(comment.id.toString());
|
||||
}
|
||||
}}
|
||||
disabled={deleteFeedbackMutation.isPending}
|
||||
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No feedback message */}
|
||||
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
|
||||
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation info */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-700">
|
||||
<p className="text-neutral-400 text-sm text-center">
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
|
||||
export const CategoryManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
@@ -24,12 +26,12 @@ export const CategoryManager: React.FC = () => {
|
||||
categoriesService.createCategory({ name, is_global: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category created successfully');
|
||||
toast.success(t('categories.categoryCreatedSuccess'));
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,12 +41,12 @@ export const CategoryManager: React.FC = () => {
|
||||
categoriesService.updateCategory(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category updated successfully');
|
||||
toast.success(t('toast.categoryUpdated'));
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to update category');
|
||||
toast.error(error.response?.data?.error || t('toast.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,10 +55,10 @@ export const CategoryManager: React.FC = () => {
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||
toast.success('Category deleted successfully');
|
||||
toast.success(t('categories.categoryDeletedSuccess'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,7 +75,7 @@ export const CategoryManager: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
|
||||
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
};
|
||||
@@ -99,7 +101,7 @@ export const CategoryManager: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">{t('categories.title')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -107,7 +109,7 @@ export const CategoryManager: React.FC = () => {
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Add Category
|
||||
{t('categories.addCategory')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -120,7 +122,7 @@ export const CategoryManager: React.FC = () => {
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder="Category name"
|
||||
placeholder={t('categories.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -133,7 +135,7 @@ export const CategoryManager: React.FC = () => {
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Create'
|
||||
t('common.save')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -144,7 +146,7 @@ export const CategoryManager: React.FC = () => {
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -153,7 +155,7 @@ export const CategoryManager: React.FC = () => {
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-neutral-500 text-center py-8">
|
||||
No categories yet. Create your first category to organize photos.
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
@@ -183,7 +185,7 @@ export const CategoryManager: React.FC = () => {
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Save'
|
||||
t('common.save')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -191,7 +193,7 @@ export const CategoryManager: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -204,14 +206,14 @@ export const CategoryManager: React.FC = () => {
|
||||
<button
|
||||
onClick={() => startEdit(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||
title="Edit category"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Delete category"
|
||||
title={t('common.delete')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
|
||||
@@ -118,7 +118,12 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
</span>
|
||||
<span className="text-neutral-500">•</span>
|
||||
<span className="text-neutral-500">
|
||||
{format(parseISO(item.created_at), 'MMM d, h:mm a')}
|
||||
{format(
|
||||
typeof item.created_at === 'string'
|
||||
? parseISO(item.created_at)
|
||||
: new Date(item.created_at),
|
||||
'MMM d, h:mm a'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-700">{item.comment}</p>
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ProtectionSettings {
|
||||
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
default_image_quality: number;
|
||||
enable_devtools_protection: boolean;
|
||||
max_image_requests_per_minute: number;
|
||||
suspicious_activity_threshold: number;
|
||||
enable_canvas_rendering: boolean;
|
||||
default_fragmentation_level: number;
|
||||
enable_overlay_protection: boolean;
|
||||
protection_warning_message: string;
|
||||
}
|
||||
|
||||
export const ImageProtectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<ProtectionSettings>({
|
||||
default_protection_level: 'standard',
|
||||
default_image_quality: 85,
|
||||
enable_devtools_protection: true,
|
||||
max_image_requests_per_minute: 30,
|
||||
suspicious_activity_threshold: 10,
|
||||
enable_canvas_rendering: false,
|
||||
default_fragmentation_level: 3,
|
||||
enable_overlay_protection: true,
|
||||
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await settingsService.getSettings();
|
||||
|
||||
// Map settings from API response
|
||||
const protectionSettings: ProtectionSettings = {
|
||||
default_protection_level: response.default_protection_level || 'standard',
|
||||
default_image_quality: parseInt(response.default_image_quality) || 85,
|
||||
enable_devtools_protection: response.enable_devtools_protection !== false,
|
||||
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
|
||||
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
|
||||
enable_canvas_rendering: response.enable_canvas_rendering === true,
|
||||
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
|
||||
enable_overlay_protection: response.enable_overlay_protection !== false,
|
||||
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
|
||||
};
|
||||
|
||||
setSettings(protectionSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to load protection settings:', error);
|
||||
toast.error('Failed to load protection settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Convert settings to API format
|
||||
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
|
||||
acc[key] = typeof value === 'boolean' ? value : value.toString();
|
||||
return acc;
|
||||
}, {} as Record<string, string | boolean>);
|
||||
|
||||
await settingsService.updateSettings(apiSettings);
|
||||
toast.success('Protection settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save protection settings:', error);
|
||||
toast.error('Failed to save protection settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const protectionLevels = [
|
||||
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
|
||||
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
|
||||
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
|
||||
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
|
||||
];
|
||||
|
||||
const getProtectionLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
|
||||
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
|
||||
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default: return <Shield className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getProtectionLevelDescription = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic':
|
||||
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
|
||||
case 'standard':
|
||||
return 'Adds keyboard shortcut blocking and user selection prevention.';
|
||||
case 'enhanced':
|
||||
return 'Includes DevTools detection, rate limiting, and overlay protection.';
|
||||
case 'maximum':
|
||||
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
|
||||
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Protection Level */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Protection Level
|
||||
</label>
|
||||
<Select
|
||||
value={settings.default_protection_level}
|
||||
onChange={(value) => updateSetting('default_protection_level', value as any)}
|
||||
options={protectionLevels}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getProtectionLevelIcon(settings.default_protection_level)}
|
||||
<span className="font-medium text-sm capitalize">
|
||||
{settings.default_protection_level} Protection
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{getProtectionLevelDescription(settings.default_protection_level)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Image Quality ({settings.default_image_quality}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="30"
|
||||
max="100"
|
||||
step="5"
|
||||
value={settings.default_image_quality}
|
||||
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Lower quality = Better protection</span>
|
||||
<span>Higher quality = Better image</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DevTools Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
DevTools Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Detect and respond to browser developer tools
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_devtools_protection}
|
||||
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Canvas Rendering */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Canvas Rendering
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Render images on canvas instead of img tags (stronger protection)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_canvas_rendering}
|
||||
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fragmentation Level */}
|
||||
{settings.enable_canvas_rendering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Image Fragmentation Level ({settings.default_fragmentation_level})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={settings.default_fragmentation_level}
|
||||
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Low fragmentation</span>
|
||||
<span>High fragmentation</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Requests Per Minute
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={settings.max_image_requests_per_minute}
|
||||
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Suspicious Activity Threshold
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="50"
|
||||
value={settings.suspicious_activity_threshold}
|
||||
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Overlay Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add transparent overlays to prevent easy screenshot extraction
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_overlay_protection}
|
||||
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Protection Warning Message
|
||||
</label>
|
||||
<Textarea
|
||||
value={settings.protection_warning_message}
|
||||
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
|
||||
placeholder="Message shown when protection is triggered"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
|
||||
<ul className="text-sm text-amber-700 space-y-1">
|
||||
<li>• Higher protection levels may impact page performance</li>
|
||||
<li>• Canvas rendering disables browser image caching</li>
|
||||
<li>• Maximum protection may cause accessibility issues</li>
|
||||
<li>• Test thoroughly with your target browsers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadSettings}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={saveSettings}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card } from '../common';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updatePasswordChanged } = useAdminAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [showPasswords, setShowPasswords] = useState({
|
||||
current: false,
|
||||
new: false,
|
||||
confirm: false
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: adminService.changePassword,
|
||||
onSuccess: () => {
|
||||
toast.success(t('mandatoryPasswordChange.success'));
|
||||
updatePasswordChanged();
|
||||
// Reset form
|
||||
setFormData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setErrors({});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.error) {
|
||||
toast.error(error.response.data.error);
|
||||
} else {
|
||||
toast.error(t('passwordChange.failed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.currentPassword) {
|
||||
newErrors.currentPassword = t('passwordChange.currentRequired');
|
||||
}
|
||||
|
||||
if (!formData.newPassword) {
|
||||
newErrors.newPassword = t('passwordChange.newRequired');
|
||||
} else if (formData.newPassword.length < 12) {
|
||||
newErrors.newPassword = t('mandatoryPasswordChange.minLengthError');
|
||||
} else {
|
||||
// Check for character types
|
||||
if (!/[a-z]/.test(formData.newPassword)) {
|
||||
newErrors.newPassword = t('mandatoryPasswordChange.mustContainLowercase');
|
||||
} else if (!/[A-Z]/.test(formData.newPassword)) {
|
||||
newErrors.newPassword = t('mandatoryPasswordChange.mustContainUppercase');
|
||||
} else if (!/[0-9]/.test(formData.newPassword)) {
|
||||
newErrors.newPassword = t('mandatoryPasswordChange.mustContainNumbersError');
|
||||
} else if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(formData.newPassword)) {
|
||||
newErrors.newPassword = t('mandatoryPasswordChange.mustContainSpecialError');
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('passwordChange.confirmRequired');
|
||||
} else if (formData.newPassword !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('passwordChange.noMatch');
|
||||
}
|
||||
|
||||
if (formData.currentPassword === formData.newPassword) {
|
||||
newErrors.newPassword = t('passwordChange.mustBeDifferent');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordMutation.mutate({
|
||||
currentPassword: formData.currentPassword,
|
||||
newPassword: formData.newPassword
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
// Clear error when user types
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 mb-2">{t('mandatoryPasswordChange.title')}</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('mandatoryPasswordChange.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('passwordChange.currentPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showPasswords.current ? 'text' : 'password'}
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange('currentPassword')}
|
||||
error={errors.currentPassword}
|
||||
placeholder={t('passwordChange.currentPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.current ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('passwordChange.newPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showPasswords.new ? 'text' : 'password'}
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange('newPassword')}
|
||||
error={errors.newPassword}
|
||||
placeholder={t('passwordChange.newPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.new ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('passwordChange.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPasswords.confirm ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
{showPasswords.confirm ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
<Eye className="w-4 h-4 text-neutral-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>{t('mandatoryPasswordChange.minLength')}</li>
|
||||
<li>{t('mandatoryPasswordChange.mustContainUpperLower')}</li>
|
||||
<li>{t('mandatoryPasswordChange.mustContainNumbers')}</li>
|
||||
<li>{t('mandatoryPasswordChange.mustContainSpecial')}</li>
|
||||
<li>{t('passwordChange.mustDiffer')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isLoading={changePasswordMutation.isPending}
|
||||
>
|
||||
{t('passwordChange.title')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card } from '../common';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
@@ -12,6 +13,7 @@ interface PasswordChangeModalProps {
|
||||
}
|
||||
|
||||
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
@@ -27,7 +29,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: adminService.changePassword,
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully');
|
||||
toast.success(t('passwordChange.success'));
|
||||
onClose();
|
||||
// Reset form
|
||||
setFormData({
|
||||
@@ -41,7 +43,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
if (error.response?.data?.error) {
|
||||
toast.error(error.response.data.error);
|
||||
} else {
|
||||
toast.error('Failed to change password');
|
||||
toast.error(t('passwordChange.failed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -50,23 +52,23 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.currentPassword) {
|
||||
newErrors.currentPassword = 'Current password is required';
|
||||
newErrors.currentPassword = t('passwordChange.currentRequired');
|
||||
}
|
||||
|
||||
if (!formData.newPassword) {
|
||||
newErrors.newPassword = 'New password is required';
|
||||
newErrors.newPassword = t('passwordChange.newRequired');
|
||||
} else if (formData.newPassword.length < 6) {
|
||||
newErrors.newPassword = 'Password must be at least 6 characters';
|
||||
newErrors.newPassword = t('passwordChange.minLengthError');
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Please confirm your new password';
|
||||
newErrors.confirmPassword = t('passwordChange.confirmRequired');
|
||||
} else if (formData.newPassword !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
newErrors.confirmPassword = t('passwordChange.noMatch');
|
||||
}
|
||||
|
||||
if (formData.currentPassword === formData.newPassword) {
|
||||
newErrors.newPassword = 'New password must be different from current password';
|
||||
newErrors.newPassword = t('passwordChange.mustBeDifferent');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -101,7 +103,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
@@ -114,7 +116,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
{/* Current Password */}
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Current Password
|
||||
{t('passwordChange.currentPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -123,7 +125,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange('currentPassword')}
|
||||
error={errors.currentPassword}
|
||||
placeholder="Enter current password"
|
||||
placeholder={t('passwordChange.currentPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
@@ -142,7 +144,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
New Password
|
||||
{t('passwordChange.newPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -151,7 +153,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange('newPassword')}
|
||||
error={errors.newPassword}
|
||||
placeholder="Enter new password"
|
||||
placeholder={t('passwordChange.newPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
@@ -170,7 +172,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
Confirm New Password
|
||||
{t('passwordChange.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -179,7 +181,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button
|
||||
@@ -200,10 +202,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">Password Requirements:</p>
|
||||
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>At least 6 characters long</li>
|
||||
<li>Must be different from current password</li>
|
||||
<li>{t('passwordChange.minLength')}</li>
|
||||
<li>{t('passwordChange.mustDiffer')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,14 +218,14 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
{t('passwordChange.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={changePasswordMutation.isPending}
|
||||
>
|
||||
Change Password
|
||||
{t('passwordChange.title')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,11 +6,11 @@ interface PhotoFiltersProps {
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
selectedCategory: number | null | undefined;
|
||||
searchTerm: string;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onCategoryChange: (categoryId: number | null | undefined) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
|
||||
}
|
||||
|
||||
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
@@ -63,12 +63,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
|
||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="size">Sort by Size</option>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
|
||||
@@ -106,7 +106,6 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
});
|
||||
|
||||
totalUploaded += chunk.length;
|
||||
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
|
||||
} catch (error: any) {
|
||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||
failedFiles.push(...chunk.map(f => f.name));
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Card, Button, Input, Loading } from '../common';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
|
||||
interface WordFilter {
|
||||
id: number;
|
||||
word: string;
|
||||
severity: 'low' | 'moderate' | 'high' | 'block';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const WordFilterManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [newWord, setNewWord] = useState('');
|
||||
const [newSeverity, setNewSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editWord, setEditWord] = useState('');
|
||||
const [editSeverity, setEditSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
|
||||
|
||||
// Fetch word filters
|
||||
const { data: filters = [], isLoading } = useQuery({
|
||||
queryKey: ['word-filters'],
|
||||
queryFn: () => feedbackService.getWordFilters()
|
||||
});
|
||||
|
||||
// Add word filter mutation
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: { word: string; severity: string }) =>
|
||||
feedbackService.addWordFilter(data.word, data.severity),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
|
||||
toast.success(t('settings.moderation.filterAdded', 'Word filter added successfully'));
|
||||
setNewWord('');
|
||||
setNewSeverity('moderate');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(t('settings.moderation.filterExists', 'This word filter already exists'));
|
||||
} else {
|
||||
toast.error(t('settings.moderation.addError', 'Failed to add word filter'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update word filter mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) =>
|
||||
feedbackService.updateWordFilter(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
|
||||
toast.success(t('settings.moderation.filterUpdated', 'Word filter updated successfully'));
|
||||
setEditingId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.moderation.updateError', 'Failed to update word filter'));
|
||||
}
|
||||
});
|
||||
|
||||
// Delete word filter mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => feedbackService.deleteWordFilter(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
|
||||
toast.success(t('settings.moderation.filterDeleted', 'Word filter deleted successfully'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.moderation.deleteError', 'Failed to delete word filter'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newWord.trim()) {
|
||||
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
|
||||
return;
|
||||
}
|
||||
addMutation.mutate({ word: newWord.trim(), severity: newSeverity });
|
||||
};
|
||||
|
||||
const handleEdit = (filter: WordFilter) => {
|
||||
setEditingId(filter.id);
|
||||
setEditWord(filter.word);
|
||||
setEditSeverity(filter.severity);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editWord.trim()) {
|
||||
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
|
||||
return;
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
updates: { word: editWord.trim(), severity: editSeverity }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditWord('');
|
||||
setEditSeverity('moderate');
|
||||
};
|
||||
|
||||
const handleToggleActive = (filter: WordFilter) => {
|
||||
updateMutation.mutate({
|
||||
id: filter.id,
|
||||
updates: { is_active: !filter.is_active }
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t('settings.moderation.confirmDelete', 'Are you sure you want to delete this word filter?'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'low':
|
||||
return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
case 'moderate':
|
||||
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
case 'high':
|
||||
return <XCircle className="w-4 h-4 text-orange-500" />;
|
||||
case 'block':
|
||||
return <XCircle className="w-4 h-4 text-red-600" />;
|
||||
default:
|
||||
return <Shield className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadgeClass = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'low':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'moderate':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'block':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFilters = filters.filter((filter: WordFilter) =>
|
||||
filter.word.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<Loading text={t('settings.moderation.loading', 'Loading word filters...')} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
||||
{t('settings.moderation.wordFilters', 'Word Filters')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add new filter */}
|
||||
<div className="mb-6 p-4 bg-neutral-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-3">
|
||||
{t('settings.moderation.addFilter', 'Add New Filter')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={newWord}
|
||||
onChange={(e) => setNewWord(e.target.value)}
|
||||
placeholder={t('settings.moderation.enterWord', 'Enter word to filter')}
|
||||
className="flex-1"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
<select
|
||||
value={newSeverity}
|
||||
onChange={(e) => setNewSeverity(e.target.value as any)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
|
||||
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
onClick={handleAdd}
|
||||
isLoading={addMutation.isPending}
|
||||
>
|
||||
{t('common.add', 'Add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('settings.moderation.searchFilters', 'Search filters...')}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters list */}
|
||||
<div className="space-y-2">
|
||||
{filteredFilters.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-500">
|
||||
{searchTerm ?
|
||||
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
|
||||
t('settings.moderation.noFilters', 'No word filters configured yet')
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
filteredFilters.map((filter: WordFilter) => (
|
||||
<div
|
||||
key={filter.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
filter.is_active ? 'border-neutral-200 bg-white' : 'border-neutral-100 bg-neutral-50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{editingId === filter.id ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={editWord}
|
||||
onChange={(e) => setEditWord(e.target.value)}
|
||||
className="flex-1 max-w-xs"
|
||||
/>
|
||||
<select
|
||||
value={editSeverity}
|
||||
onChange={(e) => setEditSeverity(e.target.value as any)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
|
||||
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSaveEdit}
|
||||
isLoading={updateMutation.isPending}
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filter.is_active}
|
||||
onChange={() => handleToggleActive(filter)}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="font-medium text-neutral-900">{filter.word}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
|
||||
{getSeverityIcon(filter.severity)}
|
||||
{filter.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={() => handleEdit(filter)}
|
||||
>
|
||||
{t('common.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => handleDelete(filter.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Severity explanation */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
|
||||
{t('settings.moderation.severityLevels', 'Severity Levels')}
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('low')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityLow', 'Low')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('moderate')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('high')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityHigh', 'High')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('block')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityBlock', 'Block')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -28,4 +28,5 @@ export { BackupConfiguration } from './BackupConfiguration';
|
||||
export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||
export { WordFilterManager } from './WordFilterManager';
|
||||
@@ -25,10 +25,12 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Component stack:', errorInfo.componentStack);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
console.error('Component stack:', errorInfo.componentStack);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
@@ -79,7 +81,9 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Page error:', error, errorInfo);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Page error:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WifiOff, Wifi } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const OfflineIndicator: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
@@ -56,12 +58,12 @@ export const OfflineIndicator: React.FC = () => {
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Back online</span>
|
||||
<span className="text-sm font-medium">{t('offline.backOnline')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">No internet connection</span>
|
||||
<span className="text-sm font-medium">{t('offline.noConnection')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||