Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 973af17b85 | |||
| 6c3e88a588 | |||
| 97bbb3c8e1 | |||
| ac1cd96ecd | |||
| 689861f671 | |||
| 41fb575e80 |
@@ -0,0 +1,286 @@
|
||||
# Security Scan Report - Wedding Photo Sharing Application
|
||||
**Date**: July 13, 2025
|
||||
**Scanner**: Claude Security Audit with --security --validate flags
|
||||
**Overall Risk Level**: MEDIUM-HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration.
|
||||
|
||||
### Security Score: 6.5/10
|
||||
|
||||
**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting
|
||||
**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL FINDINGS (Immediate Action Required)
|
||||
|
||||
### 1. Hardcoded JWT Secret in Development
|
||||
- **Location**: Backend `.env` file
|
||||
- **Risk**: Token forgery, authentication bypass
|
||||
- **Impact**: Complete authentication compromise
|
||||
- **Remediation**:
|
||||
```bash
|
||||
# Generate secure secret
|
||||
openssl rand -base64 32
|
||||
# Never commit to repository
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 2. Gallery Tokens in localStorage
|
||||
- **Location**: Frontend `api.ts` and auth contexts
|
||||
- **Risk**: XSS token theft
|
||||
- **Impact**: Gallery access compromise
|
||||
- **Remediation**: Move to httpOnly cookies:
|
||||
```typescript
|
||||
Cookies.set(`gallery_token_${slug}`, token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Weak Content Security Policy
|
||||
- **Location**: Frontend `nginx.conf`
|
||||
- **Risk**: XSS, code injection
|
||||
- **Current**: `unsafe-inline` and `unsafe-eval` allowed
|
||||
- **Remediation**: Implement strict CSP (see detailed recommendations below)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH SEVERITY FINDINGS
|
||||
|
||||
### 1. Console Logging in Production
|
||||
- **Locations**: 61 instances across frontend
|
||||
- **Risk**: Information disclosure
|
||||
- **Impact**: Leaking sensitive data, debugging info
|
||||
- **Remediation**: Implement environment-aware logging
|
||||
|
||||
### 2. Token Revocation Vulnerability
|
||||
- **Location**: Backend `tokenRevocation.js`
|
||||
- **Risk**: Token manipulation
|
||||
- **Impact**: Bypass revocation checks
|
||||
- **Remediation**: Verify token signature before decoding
|
||||
|
||||
### 3. Source Maps in Production
|
||||
- **Location**: Frontend build configuration
|
||||
- **Risk**: Source code exposure
|
||||
- **Impact**: Reveals application structure
|
||||
- **Remediation**: Disable in production builds
|
||||
|
||||
### 4. Missing Security Headers
|
||||
- **Location**: nginx configuration
|
||||
- **Missing**: HSTS, Permissions-Policy
|
||||
- **Impact**: Various client-side attacks
|
||||
- **Remediation**: Add comprehensive security headers
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM SEVERITY FINDINGS
|
||||
|
||||
### 1. Rate Limiting Bypass Potential
|
||||
- **Location**: Backend rate limiter
|
||||
- **Risk**: DoS attacks
|
||||
- **Current**: JWT validation in rate limiter
|
||||
- **Remediation**: Use IP-based limiting only
|
||||
|
||||
### 2. Incomplete SQL Injection Protection
|
||||
- **Location**: Complex dashboard queries
|
||||
- **Risk**: Potential injection in edge cases
|
||||
- **Current**: Mostly parameterized
|
||||
- **Remediation**: Use query builder exclusively
|
||||
|
||||
### 3. Session Management
|
||||
- **Issue**: No gallery token invalidation on password change
|
||||
- **Risk**: Persistent access after compromise
|
||||
- **Remediation**: Implement token revocation
|
||||
|
||||
### 4. Path Traversal in Gallery Slugs
|
||||
- **Location**: Frontend gallery routes
|
||||
- **Risk**: Directory traversal attempts
|
||||
- **Remediation**: Validate and sanitize slugs
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW SEVERITY FINDINGS
|
||||
|
||||
### 1. Verbose Error Messages
|
||||
- **Location**: Multiple API endpoints
|
||||
- **Risk**: Information disclosure
|
||||
- **Remediation**: Generic client errors, detailed server logs
|
||||
|
||||
### 2. Weak Gallery Passwords
|
||||
- **Current**: zxcvbn score 2/4 allowed
|
||||
- **Risk**: Brute force attacks
|
||||
- **Remediation**: Increase to score 3/4
|
||||
|
||||
### 3. Missing File Size Validation
|
||||
- **Location**: Frontend upload components
|
||||
- **Risk**: DoS via large uploads
|
||||
- **Remediation**: Add client-side size checks
|
||||
|
||||
---
|
||||
|
||||
## ✅ SECURITY STRENGTHS
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT with proper expiration (24h/7d)
|
||||
- Token type validation
|
||||
- IP tracking and validation
|
||||
- Password change detection
|
||||
- Token revocation system
|
||||
- Bcrypt with 12 rounds
|
||||
- zxcvbn password strength checking
|
||||
|
||||
### Input Validation & SQL Security
|
||||
- express-validator on all endpoints
|
||||
- Parameterized queries via Knex
|
||||
- SQL injection protection utilities
|
||||
- Path traversal prevention
|
||||
- Comprehensive input sanitization
|
||||
|
||||
### File Security
|
||||
- Magic number verification
|
||||
- MIME type validation
|
||||
- Safe filename generation
|
||||
- Directory traversal protection
|
||||
- File extension whitelist
|
||||
|
||||
### Rate Limiting & DoS Protection
|
||||
- General: 100 req/15min
|
||||
- Auth endpoints: 5 req/15min
|
||||
- Account lockout after failed attempts
|
||||
- Suspicious activity detection
|
||||
|
||||
### Frontend Security
|
||||
- React's built-in XSS protection
|
||||
- DOMPurify for HTML content
|
||||
- No eval() or innerHTML usage
|
||||
- Proper error boundaries
|
||||
- ReCAPTCHA integration
|
||||
|
||||
---
|
||||
|
||||
## 📊 DEPENDENCY ANALYSIS
|
||||
|
||||
### Current Status
|
||||
- **Backend**: 0 vulnerabilities (691 packages)
|
||||
- **Frontend**: 0 vulnerabilities (434 packages)
|
||||
|
||||
### Recommended Updates
|
||||
1. **bcrypt** 5.1.1 → 6.0.0 (performance, compatibility)
|
||||
2. **helmet** 7.2.0 → 8.1.0 (new security features)
|
||||
3. **@tiptap** 2.x → 3.x (security improvements)
|
||||
|
||||
### Supply Chain Assessment
|
||||
- All major dependencies from trusted sources
|
||||
- No typosquatting detected
|
||||
- Regular maintenance observed
|
||||
- MIT/ISC/Apache licenses only
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ REMEDIATION PLAN
|
||||
|
||||
### Phase 1: Critical (Within 24 hours)
|
||||
1. Replace hardcoded JWT secret with secure random value
|
||||
2. Move gallery tokens from localStorage to httpOnly cookies
|
||||
3. Implement strict CSP without unsafe-eval
|
||||
4. Remove or wrap console.log statements
|
||||
|
||||
### Phase 2: High Priority (Within 1 week)
|
||||
1. Disable source maps in production
|
||||
2. Add missing security headers (HSTS, Permissions-Policy)
|
||||
3. Fix token revocation vulnerability
|
||||
4. Update critical dependencies (bcrypt, helmet)
|
||||
|
||||
### Phase 3: Medium Priority (Within 1 month)
|
||||
1. Implement comprehensive logging strategy
|
||||
2. Add gallery slug validation
|
||||
3. Enhance rate limiting logic
|
||||
4. Implement session invalidation on password change
|
||||
|
||||
### Phase 4: Ongoing
|
||||
1. Weekly dependency scanning
|
||||
2. Implement security testing in CI/CD
|
||||
3. Regular penetration testing
|
||||
4. Security awareness training
|
||||
|
||||
---
|
||||
|
||||
## 🔒 RECOMMENDED CSP CONFIGURATION
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://analytics.domain.com;
|
||||
frame-src https://www.google.com/recaptcha/;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
upgrade-insecure-requests;
|
||||
" always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 SECURITY IMPROVEMENTS ROADMAP
|
||||
|
||||
### Immediate Implementation
|
||||
```bash
|
||||
# 1. Generate secure secrets
|
||||
openssl rand -base64 32 > jwt-secret.txt
|
||||
|
||||
# 2. Update dependencies
|
||||
cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0
|
||||
cd ../frontend && npm update
|
||||
|
||||
# 3. Add security scanning
|
||||
npm install -D npm-audit-resolver
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
```yaml
|
||||
# Add to CI pipeline
|
||||
- name: Security Scan
|
||||
run: |
|
||||
npm audit --audit-level=moderate
|
||||
npm run test:security
|
||||
```
|
||||
|
||||
### Monitoring & Alerting
|
||||
1. Implement fail2ban for repeated auth failures
|
||||
2. Set up log analysis for suspicious patterns
|
||||
3. Configure alerts for security events
|
||||
4. Regular vulnerability scanning
|
||||
|
||||
---
|
||||
|
||||
## 📋 COMPLIANCE CHECKLIST
|
||||
|
||||
- [ ] OWASP Top 10 addressed
|
||||
- [ ] GDPR compliance (data minimization, right to erasure)
|
||||
- [ ] Security headers implemented
|
||||
- [ ] Dependency scanning automated
|
||||
- [ ] Incident response plan documented
|
||||
- [ ] Security documentation maintained
|
||||
- [ ] Regular security reviews scheduled
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONCLUSION
|
||||
|
||||
The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment.
|
||||
|
||||
Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing.
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Security Scanner v1.0*
|
||||
*Next scan recommended: After Phase 1 remediation completion*
|
||||
@@ -0,0 +1,92 @@
|
||||
# Deployment Guide - Traefik Production Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to deploy PicPeak with an external Traefik reverse proxy for production use.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
1. **Database Migration**: Added missing `created_at` column to `email_queue` table
|
||||
2. **502 Bad Gateway**: Properly configured Traefik routing and backend accessibility
|
||||
3. **Health Checks**: Fixed health check endpoint imports and paths
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Update Environment Variables
|
||||
|
||||
Ensure your `.env` file has the correct URLs:
|
||||
```bash
|
||||
ADMIN_URL=https://picpeak.nothaft.cloud
|
||||
FRONTEND_URL=https://picpeak.nothaft.cloud
|
||||
```
|
||||
|
||||
### 2. Build Images
|
||||
|
||||
```bash
|
||||
# Build backend image
|
||||
docker build -t picpeak-backend:latest ./backend
|
||||
|
||||
# Build frontend image
|
||||
docker build -t picpeak-frontend:latest ./frontend \
|
||||
--build-arg VITE_API_URL=/api \
|
||||
--build-arg VITE_UMAMI_URL=${VITE_UMAMI_URL} \
|
||||
--build-arg VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID}
|
||||
```
|
||||
|
||||
### 3. Deploy with Traefik
|
||||
|
||||
Use the new Traefik-specific compose file:
|
||||
```bash
|
||||
docker-compose -f docker-compose.traefik.yml up -d
|
||||
```
|
||||
|
||||
### 4. Verify Deployment
|
||||
|
||||
Check that all services are healthy:
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose -f docker-compose.traefik.yml ps
|
||||
|
||||
# Check backend health
|
||||
curl https://picpeak.nothaft.cloud/api/health
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.traefik.yml logs -f backend
|
||||
```
|
||||
|
||||
## Key Differences from Standard Deployment
|
||||
|
||||
1. **No Internal Nginx**: Traefik handles all routing externally
|
||||
2. **API Path Stripping**: Traefik strips `/api` prefix when forwarding to backend
|
||||
3. **Network Configuration**: Services join external `traefik` network
|
||||
4. **Health Checks**: Backend exposes `/health` endpoint (not `/api/health`)
|
||||
|
||||
## Why CI/CD Tests Pass But Production Fails
|
||||
|
||||
CI/CD tests typically:
|
||||
- Use in-memory or temporary databases with fresh migrations
|
||||
- Don't test through reverse proxy (direct API calls)
|
||||
- Don't run background services (email processor, etc.)
|
||||
- Have different network configurations
|
||||
|
||||
Production environment has:
|
||||
- Persistent database that may have migration state issues
|
||||
- Reverse proxy routing complexity
|
||||
- All background services running
|
||||
- Different security and network constraints
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 502 Bad Gateway
|
||||
- Check Traefik network connectivity: `docker network ls`
|
||||
- Verify backend is in traefik network: `docker inspect picpeak-backend`
|
||||
- Check Traefik logs: `docker logs traefik`
|
||||
|
||||
### Database Issues
|
||||
- Connect to database: `docker exec -it picpeak-db psql -U picpeak`
|
||||
- Check migration status: `SELECT * FROM migrations;`
|
||||
- Run migrations manually: `docker exec -it picpeak-backend npm run migrate:safe`
|
||||
|
||||
### Email Service Errors
|
||||
- Check email queue: `SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;`
|
||||
- Monitor email processor: `docker logs picpeak-backend | grep "email"`
|
||||
@@ -0,0 +1,280 @@
|
||||
# Traefik Deployment Guide
|
||||
|
||||
This guide explains how to deploy the PicPeak application with Traefik as the reverse proxy.
|
||||
|
||||
## Overview
|
||||
|
||||
The application consists of:
|
||||
- **Frontend**: React app served by nginx (port 80)
|
||||
- **Backend**: Node.js API (port 3000)
|
||||
- **Database**: PostgreSQL (port 5432, internal only)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
### 1. Docker Labels for Traefik
|
||||
|
||||
Add these labels to your `docker-compose.prod.yml` services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||
# Priority for catch-all route
|
||||
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||
# Higher priority for API routes
|
||||
- "traefik.http.routers.picpeak-api.priority=10"
|
||||
|
||||
# Additional routes for backend static files
|
||||
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-images.priority=10"
|
||||
```
|
||||
|
||||
### 2. Network Configuration
|
||||
|
||||
Ensure your services are on the Traefik network:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
picpeak:
|
||||
external: false
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
frontend:
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
|
||||
backend:
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
|
||||
db:
|
||||
networks:
|
||||
- picpeak # Don't expose to traefik
|
||||
```
|
||||
|
||||
### 3. Remove Nginx Service
|
||||
|
||||
Since you're using Traefik, remove the nginx service from `docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
# Remove this entire service:
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# ...
|
||||
```
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
The frontend is built with the API URL set to `/api`. This is important because:
|
||||
|
||||
1. All API calls will be relative to the same domain
|
||||
2. Traefik will route `/api/*` to the backend service
|
||||
3. No CORS issues since everything is on the same domain
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Ensure these are set correctly:
|
||||
|
||||
```bash
|
||||
# Backend needs to know the public URLs
|
||||
ADMIN_URL=https://picpeak.yourdomain.com
|
||||
FRONTEND_URL=https://picpeak.yourdomain.com
|
||||
|
||||
# Backend API is accessed via /api path
|
||||
API_URL=https://picpeak.yourdomain.com/api
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete `docker-compose.prod.yml` for Traefik:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
picpeak:
|
||||
external: false
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: picpeak-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_URL=https://picpeak.yourdomain.com
|
||||
- FRONTEND_URL=https://picpeak.yourdomain.com
|
||||
- DATABASE_CLIENT=pg
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-picpeak}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME:-picpeak}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- STORAGE_PATH=/app/storage
|
||||
- EVENTS_PATH=/app/storage/events
|
||||
- ARCHIVE_PATH=/app/storage/events/archived
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.picpeak-api.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-images.priority=10"
|
||||
|
||||
frontend:
|
||||
image: picpeak-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- picpeak
|
||||
command: postgres -c ssl=off
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 502 Bad Gateway Errors
|
||||
|
||||
1. **Check if backend is running**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
docker-compose -f docker-compose.prod.yml logs backend
|
||||
```
|
||||
|
||||
2. **Verify Traefik can reach the backend**:
|
||||
- Ensure both services are on the same Docker network
|
||||
- Check Traefik logs: `docker logs traefik`
|
||||
|
||||
3. **Check backend health**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### Frontend Can't Reach API
|
||||
|
||||
1. **Verify API paths don't have double `/api`**:
|
||||
- Frontend should call `/auth/admin/login`, not `/api/auth/admin/login`
|
||||
- The base URL in axios should be `/api`
|
||||
|
||||
2. **Check browser console for actual URLs being called**
|
||||
|
||||
3. **Ensure Traefik routing rules are correct**:
|
||||
- API routes should have higher priority than frontend catch-all
|
||||
|
||||
### CORS Issues
|
||||
|
||||
Should not occur since everything is on the same domain. If you see CORS errors:
|
||||
1. Check that `FRONTEND_URL` and `ADMIN_URL` match your actual domain
|
||||
2. Ensure you're not mixing HTTP and HTTPS
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
1. **Test API directly**:
|
||||
```bash
|
||||
curl https://picpeak.yourdomain.com/api/health
|
||||
```
|
||||
|
||||
2. **Test frontend**:
|
||||
```bash
|
||||
curl https://picpeak.yourdomain.com/
|
||||
```
|
||||
|
||||
3. **Test admin login**:
|
||||
- Navigate to https://picpeak.yourdomain.com/admin/login
|
||||
- Check browser console for any errors
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **SSL/TLS**: Traefik handles SSL termination, so the backend doesn't need SSL
|
||||
2. **Port Exposure**: Don't expose backend ports directly - let Traefik handle routing
|
||||
3. **Health Checks**: Configure Traefik health checks for better reliability
|
||||
4. **Rate Limiting**: Consider adding Traefik rate limiting middleware for API routes
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"auditReportVersion": 2,
|
||||
"vulnerabilities": {},
|
||||
"metadata": {
|
||||
"vulnerabilities": {
|
||||
"info": 0,
|
||||
"low": 0,
|
||||
"moderate": 0,
|
||||
"high": 0,
|
||||
"critical": 0,
|
||||
"total": 0
|
||||
},
|
||||
"dependencies": {
|
||||
"prod": 329,
|
||||
"dev": 307,
|
||||
"optional": 54,
|
||||
"peer": 1,
|
||||
"peerOptional": 0,
|
||||
"total": 690
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
exports.up = async function(knex) {
|
||||
// Check if created_at column already exists
|
||||
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
|
||||
|
||||
if (!hasCreatedAt) {
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Update existing rows to have a created_at value based on scheduled_at
|
||||
await knex('email_queue')
|
||||
.whereNull('created_at')
|
||||
.update({
|
||||
created_at: knex.ref('scheduled_at')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('created_at');
|
||||
});
|
||||
};
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db } = require('../src/database/db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const path = require('path');
|
||||
const { initializeDatabase } = require('./src/database/db');
|
||||
const { initializeDatabase, db } = require('./src/database/db');
|
||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||
@@ -152,7 +152,7 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
// Check database connectivity
|
||||
await db.raw('SELECT 1');
|
||||
|
||||
@@ -49,6 +49,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# docker-compose.traefik.yml - Production configuration for external Traefik
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: picpeak-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_URL=https://picpeak.nothaft.cloud
|
||||
- FRONTEND_URL=https://picpeak.nothaft.cloud
|
||||
# Database
|
||||
- DATABASE_CLIENT=pg
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-picpeak}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME:-picpeak}
|
||||
# Email
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- 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
|
||||
- STORAGE_PATH=/app/storage
|
||||
- EVENTS_PATH=/app/storage/events
|
||||
- ARCHIVE_PATH=/app/storage/events/archived
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
# Backend API routing
|
||||
- "traefik.http.routers.picpeak-backend.rule=Host(`picpeak.nothaft.cloud`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-backend.tls=true"
|
||||
- "traefik.http.routers.picpeak-backend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-backend.loadbalancer.server.port=3000"
|
||||
# Strip /api prefix when forwarding to backend
|
||||
- "traefik.http.middlewares.picpeak-stripprefix.stripprefix.prefixes=/api"
|
||||
- "traefik.http.routers.picpeak-backend.middlewares=picpeak-stripprefix"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: picpeak-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
# Frontend routing (catch-all for non-API routes)
|
||||
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.nothaft.cloud`)"
|
||||
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-frontend.tls=true"
|
||||
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||
# Lower priority than backend to ensure /api routes go to backend
|
||||
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||
# 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
|
||||
# Mount init script to create umami database
|
||||
- ./docker/postgres-init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- picpeak
|
||||
# Allow connections without SSL requirement from Docker network
|
||||
command: postgres -c ssl=off
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
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:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
# Umami analytics routing
|
||||
- "traefik.http.routers.picpeak-umami.rule=Host(`analytics.picpeak.nothaft.cloud`)"
|
||||
- "traefik.http.routers.picpeak-umami.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-umami.tls=true"
|
||||
- "traefik.http.routers.picpeak-umami.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-umami.loadbalancer.server.port=3000"
|
||||
|
||||
networks:
|
||||
picpeak:
|
||||
driver: bridge
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Create umami database if it doesn't exist
|
||||
-- This runs as the postgres superuser during initialization
|
||||
|
||||
SELECT 'CREATE DATABASE umami'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec
|
||||
|
||||
-- Grant all privileges on umami database to the application user
|
||||
GRANT ALL PRIVILEGES ON DATABASE umami TO "${POSTGRES_USER}";
|
||||
@@ -0,0 +1,65 @@
|
||||
# Build stage with dynamic API URL
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Accept build args for API URL
|
||||
ARG VITE_API_URL=/api
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Set environment variable for build
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm -rf /etc/nginx/conf.d/*
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create a script to inject runtime config
|
||||
RUN cat > /usr/share/nginx/html/config.js << 'EOF'
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
API_URL: '/api'
|
||||
};
|
||||
EOF
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chown -R nginx:nginx /var/cache/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nginx:nginx /var/run/nginx.pid
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER nginx
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"auditReportVersion": 2,
|
||||
"vulnerabilities": {},
|
||||
"metadata": {
|
||||
"vulnerabilities": {
|
||||
"info": 0,
|
||||
"low": 0,
|
||||
"moderate": 0,
|
||||
"high": 0,
|
||||
"critical": 0,
|
||||
"total": 0
|
||||
},
|
||||
"dependencies": {
|
||||
"prod": 136,
|
||||
"dev": 298,
|
||||
"optional": 47,
|
||||
"peer": 0,
|
||||
"peerOptional": 0,
|
||||
"total": 433
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-link": "^2.25.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['global-theme-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
|
||||
@@ -23,7 +23,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
queryKey: ['public-settings-maintenance'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Return empty object if settings can't be fetched
|
||||
|
||||
@@ -30,7 +30,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Make a lightweight request to check maintenance status
|
||||
await api.get('/api/public/settings');
|
||||
await api.get('/public/settings');
|
||||
// If successful, maintenance mode is off
|
||||
setMaintenanceMode(false);
|
||||
return { maintenance: false };
|
||||
|
||||
@@ -62,7 +62,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
|
||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
// Don't set Content-Type header - axios will set it with the boundary
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface SystemVersion {
|
||||
}
|
||||
|
||||
async function fetchSystemVersion(): Promise<SystemVersion> {
|
||||
const response = await api.get<SystemVersion>('/api/admin/system/version');
|
||||
const response = await api.get<SystemVersion>('/admin/system/version');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
@@ -195,8 +195,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
if (watermarkEnabled) {
|
||||
photos = photos.map(photo => ({
|
||||
...photo,
|
||||
url: `/api/gallery/${slug}/photo/${photo.id}`,
|
||||
thumbnail_url: `/api/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||
url: `/gallery/${slug}/photo/${photo.id}`,
|
||||
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/api/gallery/${eventId}/upload`, formData, {
|
||||
await api.post(`/gallery/${eventId}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ export const GalleryPage: React.FC = () => {
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['admin-login-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
|
||||
@@ -21,7 +21,7 @@ export const LegalPage: React.FC = () => {
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/api/public/settings');
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
|
||||
@@ -66,13 +66,13 @@ export interface AnalyticsData {
|
||||
export const adminService = {
|
||||
// Dashboard statistics
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
const response = await api.get<DashboardStats>('/api/admin/dashboard/stats');
|
||||
const response = await api.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Recent activity
|
||||
async getRecentActivity(limit: number = 10): Promise<Activity[]> {
|
||||
const response = await api.get<Activity[]>('/api/admin/dashboard/activity', {
|
||||
const response = await api.get<Activity[]>('/admin/dashboard/activity', {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
@@ -80,7 +80,7 @@ export const adminService = {
|
||||
|
||||
// Analytics data
|
||||
async getAnalytics(days: number = 7): Promise<AnalyticsData> {
|
||||
const response = await api.get<AnalyticsData>('/api/admin/dashboard/analytics', {
|
||||
const response = await api.get<AnalyticsData>('/admin/dashboard/analytics', {
|
||||
params: { days }
|
||||
});
|
||||
return response.data;
|
||||
@@ -88,7 +88,7 @@ export const adminService = {
|
||||
|
||||
// System health check
|
||||
async getSystemHealth(): Promise<SystemHealth> {
|
||||
const response = await api.get<SystemHealth>('/api/admin/dashboard/health');
|
||||
const response = await api.get<SystemHealth>('/admin/dashboard/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -124,6 +124,6 @@ export const adminService = {
|
||||
|
||||
// Change password
|
||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||
await api.post('/api/admin/auth/change-password', data);
|
||||
await api.post('/admin/auth/change-password', data);
|
||||
}
|
||||
};
|
||||
@@ -46,7 +46,7 @@ export interface ArchivesResponse {
|
||||
export const archiveService = {
|
||||
// Get all archives with pagination
|
||||
async getArchives(page: number = 1, limit: number = 20): Promise<ArchivesResponse> {
|
||||
const response = await api.get<ArchivesResponse>('/api/admin/archives', {
|
||||
const response = await api.get<ArchivesResponse>('/admin/archives', {
|
||||
params: { page, limit }
|
||||
});
|
||||
return response.data;
|
||||
@@ -54,18 +54,18 @@ export const archiveService = {
|
||||
|
||||
// Get single archive details
|
||||
async getArchiveDetails(id: number): Promise<ArchiveDetails> {
|
||||
const response = await api.get<ArchiveDetails>(`/api/admin/archives/${id}`);
|
||||
const response = await api.get<ArchiveDetails>(`/admin/archives/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Restore archive
|
||||
async restoreArchive(id: number): Promise<void> {
|
||||
await api.post(`/api/admin/archives/${id}/restore`);
|
||||
await api.post(`/admin/archives/${id}/restore`);
|
||||
},
|
||||
|
||||
// Download archive
|
||||
async downloadArchive(id: number, filename: string): Promise<void> {
|
||||
const response = await api.get(`/api/admin/archives/${id}/download`, {
|
||||
const response = await api.get(`/admin/archives/${id}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export const archiveService = {
|
||||
|
||||
// Delete archive permanently
|
||||
async deleteArchive(id: number): Promise<void> {
|
||||
await api.delete(`/api/admin/archives/${id}`);
|
||||
await api.delete(`/admin/archives/${id}`);
|
||||
},
|
||||
|
||||
// Format bytes to human readable
|
||||
|
||||
@@ -5,7 +5,7 @@ export const authService = {
|
||||
// Admin authentication
|
||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
||||
// Backend expects 'username' field, but we accept email
|
||||
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
|
||||
const response = await api.post<LoginResponse>('/auth/admin/login', {
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
recaptchaToken: credentials.recaptchaToken
|
||||
@@ -22,7 +22,7 @@ export const authService = {
|
||||
|
||||
// Gallery authentication
|
||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
|
||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||
slug,
|
||||
password,
|
||||
recaptchaToken
|
||||
|
||||
@@ -19,30 +19,30 @@ export interface CreateCategoryData {
|
||||
export const categoriesService = {
|
||||
// Get all global categories
|
||||
async getGlobalCategories(): Promise<PhotoCategory[]> {
|
||||
const response = await api.get<PhotoCategory[]>('/api/admin/categories/global');
|
||||
const response = await api.get<PhotoCategory[]>('/admin/categories/global');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
async getEventCategories(eventId: number): Promise<PhotoCategory[]> {
|
||||
const response = await api.get<PhotoCategory[]>(`/api/admin/categories/event/${eventId}`);
|
||||
const response = await api.get<PhotoCategory[]>(`/admin/categories/event/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create a new category
|
||||
async createCategory(data: CreateCategoryData): Promise<PhotoCategory> {
|
||||
const response = await api.post<PhotoCategory>('/api/admin/categories', data);
|
||||
const response = await api.post<PhotoCategory>('/admin/categories', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a category
|
||||
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
||||
const response = await api.put<PhotoCategory>(`/api/admin/categories/${id}`, { name });
|
||||
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/api/admin/categories/${id}`);
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -13,25 +13,25 @@ export interface CMSPage {
|
||||
export const cmsService = {
|
||||
// Get all CMS pages
|
||||
async getPages(): Promise<CMSPage[]> {
|
||||
const response = await api.get<CMSPage[]>('/api/admin/cms/pages');
|
||||
const response = await api.get<CMSPage[]>('/admin/cms/pages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single CMS page
|
||||
async getPage(slug: string): Promise<CMSPage> {
|
||||
const response = await api.get<CMSPage>(`/api/admin/cms/pages/${slug}`);
|
||||
const response = await api.get<CMSPage>(`/admin/cms/pages/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a CMS page
|
||||
async updatePage(slug: string, data: Partial<CMSPage>): Promise<CMSPage> {
|
||||
const response = await api.put<CMSPage>(`/api/admin/cms/pages/${slug}`, data);
|
||||
const response = await api.put<CMSPage>(`/admin/cms/pages/${slug}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get public CMS page (no auth required)
|
||||
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
|
||||
const response = await api.get<{ title: string; content: string }>(`/api/public/pages/${slug}`, {
|
||||
const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, {
|
||||
params: { lang }
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -35,41 +35,41 @@ export interface EmailPreview {
|
||||
export const emailService = {
|
||||
// Get email configuration
|
||||
async getConfig(): Promise<EmailConfig> {
|
||||
const response = await api.get<EmailConfig>('/api/admin/email/config');
|
||||
const response = await api.get<EmailConfig>('/admin/email/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update email configuration
|
||||
async updateConfig(config: EmailConfig): Promise<void> {
|
||||
await api.post('/api/admin/email/config', config);
|
||||
await api.post('/admin/email/config', config);
|
||||
},
|
||||
|
||||
// Test email configuration
|
||||
async testEmail(testEmail: string): Promise<void> {
|
||||
await api.post('/api/admin/email/test', { test_email: testEmail });
|
||||
await api.post('/admin/email/test', { test_email: testEmail });
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/api/admin/email/templates');
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single template
|
||||
async getTemplate(key: string): Promise<EmailTemplate> {
|
||||
const response = await api.get<EmailTemplate>(`/api/admin/email/templates/${key}`);
|
||||
const response = await api.get<EmailTemplate>(`/admin/email/templates/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update email template
|
||||
async updateTemplate(key: string, template: Partial<EmailTemplate>): Promise<void> {
|
||||
await api.put(`/api/admin/email/templates/${key}`, template);
|
||||
await api.put(`/admin/email/templates/${key}`, template);
|
||||
},
|
||||
|
||||
// Preview email template
|
||||
async previewTemplate(key: string, previewData: Record<string, string>, language: 'en' | 'de' = 'en'): Promise<EmailPreview> {
|
||||
const response = await api.post<EmailPreview>(
|
||||
`/api/admin/email/templates/${key}/preview`,
|
||||
`/admin/email/templates/${key}/preview`,
|
||||
{ preview_data: previewData, language }
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -53,36 +53,36 @@ export const eventsService = {
|
||||
params.append('status', status);
|
||||
}
|
||||
|
||||
const response = await api.get<EventsListResponse>(`/api/admin/events?${params}`);
|
||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single event details (admin)
|
||||
async getEvent(id: number): Promise<Event> {
|
||||
const response = await api.get<Event>(`/api/admin/events/${id}`);
|
||||
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new event (admin)
|
||||
async createEvent(data: CreateEventData): Promise<Event> {
|
||||
const response = await api.post<Event>('/api/admin/events', data);
|
||||
const response = await api.post<Event>('/admin/events', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update event (admin)
|
||||
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||
const response = await api.put<Event>(`/api/admin/events/${id}`, data);
|
||||
const response = await api.put<Event>(`/admin/events/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete/deactivate event (admin)
|
||||
async deleteEvent(id: number): Promise<void> {
|
||||
await api.delete(`/api/admin/events/${id}`);
|
||||
await api.delete(`/admin/events/${id}`);
|
||||
},
|
||||
|
||||
// Force archive event (admin)
|
||||
async archiveEvent(id: number): Promise<void> {
|
||||
await api.post(`/api/admin/events/${id}/archive`);
|
||||
await api.post(`/admin/events/${id}/archive`);
|
||||
},
|
||||
|
||||
// Bulk archive events (admin)
|
||||
@@ -93,7 +93,7 @@ export const eventsService = {
|
||||
failed: Array<{ id: number; name: string; error: string }>;
|
||||
};
|
||||
}> {
|
||||
const response = await api.post('/api/admin/events/bulk-archive', {
|
||||
const response = await api.post('/admin/events/bulk-archive', {
|
||||
eventIds,
|
||||
});
|
||||
return response.data;
|
||||
@@ -101,7 +101,7 @@ export const eventsService = {
|
||||
|
||||
// Extend event expiration (admin)
|
||||
async extendExpiration(id: number, days: number): Promise<Event> {
|
||||
const response = await api.post<Event>(`/api/events/${id}/extend`, {
|
||||
const response = await api.post<Event>(`/events/${id}/extend`, {
|
||||
days,
|
||||
});
|
||||
return response.data;
|
||||
@@ -109,13 +109,13 @@ export const eventsService = {
|
||||
|
||||
// Get event categories
|
||||
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
|
||||
const response = await api.get(`/api/admin/categories/event/${eventId}`);
|
||||
const response = await api.get(`/admin/categories/event/${eventId}`);
|
||||
return response.data || [];
|
||||
},
|
||||
|
||||
// Reset event password
|
||||
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
||||
const response = await api.post(`/api/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||
const response = await api.post(`/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -4,26 +4,26 @@ import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
export const galleryService = {
|
||||
// Verify share token
|
||||
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
||||
const response = await api.get<{ valid: boolean }>(`/api/gallery/${slug}/verify-token/${token}`);
|
||||
const response = await api.get<{ valid: boolean }>(`/gallery/${slug}/verify-token/${token}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get basic gallery info (no auth required)
|
||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||
const params = token ? { token } : {};
|
||||
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`, { params });
|
||||
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get gallery photos (requires auth)
|
||||
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
||||
const response = await api.get<GalleryData>(`/api/gallery/${slug}/photos`);
|
||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
const response = await api.get(`/api/gallery/${slug}/download/${photoId}`, {
|
||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export const galleryService = {
|
||||
|
||||
// Download all photos as ZIP
|
||||
async downloadAllPhotos(slug: string): Promise<void> {
|
||||
const response = await api.get(`/api/gallery/${slug}/download-all`, {
|
||||
const response = await api.get(`/gallery/${slug}/download-all`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export const galleryService = {
|
||||
|
||||
// Get gallery statistics
|
||||
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
||||
const response = await api.get<GalleryStats>(`/api/gallery/${slug}/stats`);
|
||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export interface NotificationsResponse {
|
||||
export const notificationsService = {
|
||||
// Get notifications
|
||||
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
|
||||
const response = await api.get('/api/admin/notifications', {
|
||||
const response = await api.get('/admin/notifications', {
|
||||
params: { includeRead, limit }
|
||||
});
|
||||
return response.data;
|
||||
@@ -30,17 +30,17 @@ export const notificationsService = {
|
||||
|
||||
// Mark single notification as read
|
||||
async markAsRead(notificationId: number): Promise<void> {
|
||||
await api.put(`/api/admin/notifications/${notificationId}/read`);
|
||||
await api.put(`/admin/notifications/${notificationId}/read`);
|
||||
},
|
||||
|
||||
// Mark all notifications as read
|
||||
async markAllAsRead(): Promise<void> {
|
||||
await api.put('/api/admin/notifications/read-all');
|
||||
await api.put('/admin/notifications/read-all');
|
||||
},
|
||||
|
||||
// Clear old notifications
|
||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
||||
const response = await api.delete('/api/admin/notifications/clear-old');
|
||||
const response = await api.delete('/admin/notifications/clear-old');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class PhotosService {
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/api/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
|
||||
const url = `/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await api.get(url);
|
||||
|
||||
@@ -48,26 +48,26 @@ class PhotosService {
|
||||
}
|
||||
|
||||
async deletePhoto(eventId: number, photoId: number): Promise<void> {
|
||||
await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
|
||||
await api.delete(`/admin/events/${eventId}/photos/${photoId}`);
|
||||
}
|
||||
|
||||
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
|
||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
|
||||
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
|
||||
}
|
||||
|
||||
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
|
||||
await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
|
||||
await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
|
||||
}
|
||||
|
||||
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
|
||||
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
|
||||
photoIds,
|
||||
updates: { category_id: categoryId }
|
||||
});
|
||||
}
|
||||
|
||||
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
||||
const response = await api.get(`/api/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
|
||||
@@ -79,19 +79,19 @@ export interface SystemStatus {
|
||||
export const settingsService = {
|
||||
// Get all settings
|
||||
async getAllSettings(): Promise<Record<string, any>> {
|
||||
const response = await api.get<Record<string, any>>('/api/admin/settings');
|
||||
const response = await api.get<Record<string, any>>('/admin/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get settings by type
|
||||
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
|
||||
const response = await api.get<Record<string, any>>(`/api/admin/settings/${type}`);
|
||||
const response = await api.get<Record<string, any>>(`/admin/settings/${type}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update branding settings
|
||||
async updateBranding(settings: BrandingSettings): Promise<void> {
|
||||
await api.put('/api/admin/settings/branding', settings);
|
||||
await api.put('/admin/settings/branding', settings);
|
||||
},
|
||||
|
||||
// Upload logo
|
||||
@@ -150,23 +150,23 @@ export const settingsService = {
|
||||
|
||||
// Update theme settings
|
||||
async updateTheme(settings: ThemeSettings): Promise<void> {
|
||||
await api.put('/api/admin/settings/theme', settings);
|
||||
await api.put('/admin/settings/theme', settings);
|
||||
},
|
||||
|
||||
// Update multiple settings at once
|
||||
async updateSettings(settings: Record<string, any>): Promise<void> {
|
||||
await api.put('/api/admin/settings/general', settings);
|
||||
await api.put('/admin/settings/general', settings);
|
||||
},
|
||||
|
||||
// Get storage information
|
||||
async getStorageInfo(): Promise<StorageInfo> {
|
||||
const response = await api.get<StorageInfo>('/api/admin/settings/storage/info');
|
||||
const response = await api.get<StorageInfo>('/admin/settings/storage/info');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get system status
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
const response = await api.get<SystemStatus>('/api/admin/system/status');
|
||||
const response = await api.get<SystemStatus>('/admin/system/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PhotoUrlOptions {
|
||||
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
||||
if (watermarkEnabled && token) {
|
||||
// Use the watermarked photo endpoint
|
||||
return `/api/gallery/${slug}/photo/${photo.id}`;
|
||||
return `/gallery/${slug}/photo/${photo.id}`;
|
||||
}
|
||||
|
||||
// Use the static photo URL
|
||||
@@ -24,5 +24,5 @@ export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: Ph
|
||||
* Get the download URL for a photo
|
||||
*/
|
||||
export function getPhotoDownloadUrl(slug: string, photoId: number): string {
|
||||
return `/api/gallery/${slug}/download/${photoId}`;
|
||||
return `/gallery/${slug}/download/${photoId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user