Compare commits

..

22 Commits

Author SHA1 Message Date
Gitea Actions Bot 1761ebd531 chore: bump version to 1.0.20
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 10:08:49 +00:00
paul 5e5e98601f fix: improve email test endpoint error handling
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add detailed validation for SMTP configuration
- Add specific error messages for common SMTP issues (connection, auth, network)
- Add logging to help debug email configuration issues
- Check for masked passwords that shouldn't be used for sending
- Parse smtp_port as integer and handle boolean smtp_secure properly

This helps identify the exact cause of email sending failures instead of
generic 500 errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 12:04:57 +02:00
Gitea Actions Bot 1cda80792b chore: bump version to 1.0.19
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 09:02:17 +00:00
paul 8740d5e618 fix: dashboard activity endpoint JSON parsing error
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m26s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Successful in 3s
- Add safe JSON parsing for activity metadata in dashboard endpoint
- Handle cases where PostgreSQL returns JSON columns as objects
- Prevent 500 errors when metadata contains invalid JSON

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:58:12 +02:00
Gitea Actions Bot a619d52d17 chore: bump version to 1.0.18
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:46:37 +00:00
paul dd8cc14d30 fix: production JSON parsing errors and trust proxy issue
Test and Lint / backend-test (push) Successful in 1m3s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Set Express to trust proxy headers for proper IP detection with Traefik
- Add safe JSON parsing for email template variables and activity log metadata
- Create migration to fix invalid JSON data in database
- Add error handling to prevent JSON.parse crashes

This fixes the 500 errors caused by invalid JSON data and the trust proxy
warning from express-rate-limit when running behind Traefik.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:42:41 +02:00
Gitea Actions Bot 3b7d723c2a chore: bump version to 1.0.17
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:18:38 +00:00
paul dc6252ff56 fix: complete email template and CMS page fixes for production
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 4s
- Fix email templates list endpoint to handle both schema variations
- Add migration for default CMS pages with language-specific columns
- Create debug script to identify 500 error causes
- Update CMS migration to use correct column names (title_en/de, content_en/de)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:14:44 +02:00
Gitea Actions Bot 1d4e79a4f9 chore: bump version to 1.0.16
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:02:15 +00:00
paul 0b0e3e22d2 fix: handle email templates schema variations in production
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Update migration to detect and handle both old and new email template schemas
- Fix migration to insert into correct columns based on existing schema
- Update adminEmail routes to handle both schema formats gracefully
- Add proper fallbacks for German language columns

This ensures the application works whether the language migration has been
applied or not, preventing null constraint violations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:58:06 +02:00
Gitea Actions Bot f22e3c133f chore: bump version to 1.0.15
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:53:34 +00:00
paul 64c0a58f78 fix: database migration and routing issues for production
Test and Lint / backend-test (push) Successful in 1m19s
Test and Lint / frontend-test (push) Successful in 2m20s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Add migration to fix email_templates column structure after language migration
- Add migration to ensure default email templates exist
- Create diagnostic script to check database issues
- Fix docker-compose configuration for proper routing without path stripping

The backend expects routes with /api prefix, so removing the stripprefix
middleware allows proper routing to work.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:49:11 +02:00
Gitea Actions Bot 4182089c17 chore: bump version to 1.0.14
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:24:51 +00:00
paul cecf773fb7 fix: database connection stability issues in production
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add robust connection pool configuration with timeouts and retry settings
- Implement retry logic in maintenance middleware for connection errors
- Increase connection stability with keepAlive and proper timeout values
- Handle "Connection terminated unexpectedly" errors gracefully

This prevents 503 errors when the database connection is temporarily interrupted
and ensures the application can recover from transient connection issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:20:51 +02:00
Gitea Actions Bot 973af17b85 chore: bump version to 1.0.13
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 06:45:13 +00:00
paul 6c3e88a588 fix: production deployment issues with Traefik and database migrations
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 2s
- Add missing created_at column to email_queue table
- Fix 502 Bad Gateway errors with proper Traefik routing configuration
- Create docker-compose.traefik.yml for external Traefik deployment
- Fix health check endpoint path for API path stripping
- Add PostgreSQL init script for Umami database creation
- Add comprehensive deployment guide for Traefik setup

The backend now properly handles /api prefix stripping by Traefik and
migrations run safely in production environments with existing schemas.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 08:41:11 +02:00
Gitea Actions Bot 97bbb3c8e1 chore: bump version to 1.0.12
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2025-07-13 21:22:28 +00:00
paul ac1cd96ecd fix: resolve frontend API routing issues for Traefik deployment
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 4s
Major fixes for production deployment with Traefik:

1. API Path Fixes:
   - Remove double /api prefix from all frontend service calls
   - Fix auth.service.ts to use correct paths (/auth/admin/login)
   - Update all services to use single /api prefix from base URL
   - Fix template literal paths in photo services

2. Docker Configuration:
   - Add build args for VITE_API_URL in docker-compose.prod.yml
   - Create Dockerfile.prod with proper API URL configuration
   - Ensure frontend is built with correct API base path

3. Documentation:
   - Add comprehensive TRAEFIK_DEPLOYMENT.md guide
   - Document proper Traefik labels and routing configuration
   - Include troubleshooting steps for common issues
   - Explain network configuration and SSL handling

This resolves:
- 502 Bad Gateway errors
- Double /api/api paths in requests
- Frontend unable to communicate with backend
- Login functionality not working

The frontend now correctly calls the backend API through Traefik's
routing, with all requests going to /api/* being forwarded to the
backend service on port 3000.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 23:18:00 +02:00
Gitea Actions Bot 689861f671 chore: bump version to 1.0.11
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 21:12:57 +00:00
paul 41fb575e80 fix: use correct bcrypt package in create-admin script
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Change from bcryptjs to bcrypt to match installed dependency
- Fixes "Cannot find module 'bcryptjs'" error when creating admin user

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 23:09:02 +02:00
Gitea Actions Bot 6ebc4f3fc4 chore: bump version to 1.0.10
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 21:01:24 +00:00
paul de973f5613 fix: comprehensive production deployment fixes and migration safety
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 4s
Major fixes for production deployment issues:

1. Migration System:
   - Add safe migration runner that handles existing schema
   - Create migration helper functions for idempotent operations
   - Auto-detect existing tables and mark migrations as applied
   - Handle "relation already exists" errors gracefully

2. Production Initialization:
   - Create init-production.sh script for proper startup sequence
   - Fix directory creation and permissions
   - Add admin user creation from environment variables
   - Ensure proper service initialization order

3. Documentation:
   - Add comprehensive PRODUCTION_DEPLOYMENT_GUIDE.md
   - Add MIGRATION_ERROR_FIX.md for immediate issue resolution
   - Document all known production issues and solutions
   - Include backup/restore procedures

4. Safety Improvements:
   - Add migrate:safe npm script for production use
   - Update wait-for-db.sh to use safe migrations in production
   - Add proper error handling and logging

This resolves the "relation already exists" error and prevents similar
issues in future deployments. The safe migration system can handle both
fresh installations and existing databases.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:57:22 +02:00
63 changed files with 5584 additions and 145 deletions
+286
View File
@@ -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*
+92
View File
@@ -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"`
+111
View File
@@ -0,0 +1,111 @@
# Quick Fix for Migration Error
## Immediate Fix
The error "relation photo_categories already exists" occurs because the database already has tables but the migration tracking doesn't know they were applied.
### Option 1: Use Safe Migration Runner (Recommended)
Update your `docker-compose.prod.yml` to use the safe migration command:
```yaml
backend:
environment:
- NODE_ENV=production
# ... other config ...
```
Then update the `wait-for-db.sh` (already done) to use `npm run migrate:safe` in production.
### Option 2: Quick Manual Fix
If you need to fix the running system immediately:
```bash
# 1. Enter the backend container
docker-compose -f docker-compose.prod.yml exec backend sh
# 2. Run the safe migration script
npm run migrate:safe
# 3. If that fails, manually mark migrations as applied:
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
# In PostgreSQL:
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Mark existing migrations as applied
INSERT INTO migrations (filename) VALUES
('init.js'),
('004_add_categories_and_cms.js'),
('006_add_photo_counter_to_categories.js'),
('007_add_read_at_to_activity_logs.js'),
('008_add_language_support_to_email_templates.js'),
('009_update_german_email_templates.js'),
('010_add_missing_email_templates.js'),
('011_add_user_upload_settings.js'),
('012_add_hero_photo_id.js'),
('013_fix_email_links_and_date_format.js'),
('014_add_default_welcome_message.js'),
('014_add_host_name_to_events.js'),
('015_add_login_attempts_table.js'),
('016_add_auth_security_columns.js'),
('017_add_token_revocation_tables.js')
ON CONFLICT (filename) DO NOTHING;
\q
```
### Option 3: Fresh Start (Nuclear Option)
If you don't have important data yet:
```bash
# Stop everything
docker-compose -f docker-compose.prod.yml down
# Remove database volume
docker volume rm wedding-photo-sharing_postgres_data
# Start fresh
docker-compose -f docker-compose.prod.yml up -d
```
## Root Cause
The issue happens when:
1. Database volume persists between deployments
2. Migration tracking table gets out of sync
3. The original migration runner doesn't check for existing tables
## Permanent Solution
The new safe migration runner (`migrate:safe`) handles this by:
1. Checking if tables exist before creating them
2. Catching "already exists" errors gracefully
3. Auto-detecting existing schema and marking migrations as applied
## Next Steps
After fixing the migration issue:
1. Create admin user:
```bash
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--username admin \
--email admin@yourdomain.com
```
2. Check health:
```bash
curl http://yourdomain.com/api/health
```
3. Monitor logs:
```bash
docker-compose -f docker-compose.prod.yml logs -f backend
```
+312
View File
@@ -0,0 +1,312 @@
# Production Deployment Guide
This guide addresses all known production deployment issues and provides solutions.
## Pre-Deployment Checklist
### 1. Environment Variables
Create a `.env` file with ALL required variables:
```bash
# Required
JWT_SECRET=<generate-with-openssl-rand-base64-32>
DB_PASSWORD=<strong-password>
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Database
DB_USER=picpeak
DB_NAME=picpeak
# Email (Optional but recommended)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
# Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=<generate-random-string>
```
### 2. Generate Secrets
```bash
# Generate JWT Secret
openssl rand -base64 32
# Generate Database Password
openssl rand -base64 24
# Generate Umami Hash Salt
openssl rand -hex 32
```
## Deployment Steps
### 1. Initial Setup
```bash
# Clone repository
git clone https://github.com/yourusername/wedding-photo-sharing.git
cd wedding-photo-sharing
# Create required directories
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
mkdir -p data logs
mkdir -p certbot/conf certbot/www
# Set permissions (important!)
chmod -R 755 storage data logs
```
### 2. Fix Docker Volume Permissions
Create `docker-compose.override.yml` for local volume configuration:
```yaml
version: '3.8'
services:
backend:
volumes:
- ./storage:/app/storage:delegated
- ./data:/app/data:delegated
- ./logs:/app/logs:delegated
user: "1001:1001" # nodejs user
db:
volumes:
- ./postgres-data:/var/lib/postgresql/data
```
### 3. Build and Deploy
```bash
# Build images
docker-compose -f docker-compose.prod.yml build
# Start services
docker-compose -f docker-compose.prod.yml up -d
# Check logs
docker-compose -f docker-compose.prod.yml logs -f backend
```
### 4. Create Admin User
After deployment, create the first admin user:
```bash
# Enter backend container
docker-compose -f docker-compose.prod.yml exec backend sh
# Create admin
node scripts/create-admin.js \
--username admin \
--email admin@yourdomain.com \
--password <your-secure-password>
# Exit container
exit
```
### 5. Configure Email (if using database config)
1. Login to admin panel: https://yourdomain.com/admin
2. Go to Settings > Email Configuration
3. Enter SMTP details
4. Test email sending
## Common Issues and Solutions
### Issue 1: Migration Failures
**Error**: "relation already exists"
**Solution**: The safe migration runner handles this automatically. If issues persist:
```bash
# Reset migrations tracking
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
# In PostgreSQL:
DROP TABLE IF EXISTS migrations;
\q
# Re-run migrations
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
```
### Issue 2: Permission Denied Errors
**Error**: "EACCES: permission denied"
**Solution**: Fix container permissions:
```bash
# Stop containers
docker-compose -f docker-compose.prod.yml down
# Fix permissions on host
sudo chown -R 1001:1001 storage data logs
# Restart
docker-compose -f docker-compose.prod.yml up -d
```
### Issue 3: Database Connection Failed
**Error**: "no pg_hba.conf entry"
**Solution**: Already fixed in docker-compose.prod.yml with:
- SSL disabled for internal Docker network
- Proper authentication method (scram-sha-256)
### Issue 4: Frontend Can't Connect to Backend
**Error**: CORS errors or connection refused
**Solution**: Ensure environment variables match:
- Backend: `FRONTEND_URL` must match your frontend URL
- Frontend: `VITE_API_URL` must be set during build
### Issue 5: Email Not Sending
**Solution**: Check email configuration:
```bash
# Check backend logs
docker-compose -f docker-compose.prod.yml logs backend | grep email
# Verify SMTP settings
# Gmail users: Use app password, not regular password
# Enable "Less secure app access" or use OAuth2
```
## SSL/HTTPS Setup
1. Update `nginx/sites-enabled/default` with your domain
2. Run certbot:
```bash
# Initial certificate
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot --webroot-path=/var/www/certbot \
-d yourdomain.com -d www.yourdomain.com
# Auto-renewal is handled by the certbot container
```
## Monitoring
### Health Checks
```bash
# Backend health
curl http://localhost/api/health
# Database connection
docker-compose -f docker-compose.prod.yml exec backend \
psql -U picpeak -d picpeak -c "SELECT 1"
```
### Logs
```bash
# All services
docker-compose -f docker-compose.prod.yml logs -f
# Specific service
docker-compose -f docker-compose.prod.yml logs -f backend
```
## Backup and Restore
### Backup
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="./backups/$DATE"
mkdir -p $BACKUP_DIR
# Database
docker-compose -f docker-compose.prod.yml exec -T db \
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
# Files
tar -czf $BACKUP_DIR/storage.tar.gz storage/
echo "Backup completed: $BACKUP_DIR"
```
### Restore
```bash
# Database
docker-compose -f docker-compose.prod.yml exec -T db \
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
# Files
tar -xzf ./backups/20240713_120000/storage.tar.gz
```
## Production Best Practices
1. **Always use named volumes** in production for better data persistence
2. **Set up monitoring** with Prometheus/Grafana
3. **Enable backups** with automated scripts
4. **Use a reverse proxy** (Nginx) for SSL termination
5. **Implement rate limiting** at the Nginx level
6. **Regular updates** - Keep Docker images updated
7. **Log rotation** - Configure log rotation for application logs
## Troubleshooting Commands
```bash
# Check running containers
docker-compose -f docker-compose.prod.yml ps
# Restart a service
docker-compose -f docker-compose.prod.yml restart backend
# View real-time logs
docker-compose -f docker-compose.prod.yml logs -f --tail=100
# Execute commands in container
docker-compose -f docker-compose.prod.yml exec backend sh
# Database shell
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
# Clean restart
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
## Security Checklist
- [ ] Strong JWT_SECRET (min 32 chars)
- [ ] Strong database password
- [ ] SSL/HTTPS enabled
- [ ] Firewall configured (only 80/443 open)
- [ ] Regular security updates
- [ ] Backup encryption
- [ ] Access logs monitored
- [ ] Rate limiting enabled
- [ ] File upload restrictions configured
## Support
For issues not covered here:
1. Check application logs
2. Review error messages carefully
3. Ensure all environment variables are set
4. Verify file permissions
5. Check Docker daemon logs
+280
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
# Traefik Troubleshooting Guide
## Common Issues and Solutions
### 1. 404 Errors on API Routes
**Problem**: Getting 404 errors when accessing `/api/*` routes
**Causes**:
- Traefik routing rules not properly configured
- Backend container not healthy
- Path stripping not working correctly
**Solutions**:
1. **Check container health**:
```bash
docker ps # Check if backend is running
docker logs picpeak-backend # Check for startup errors
```
2. **Test backend directly**:
```bash
# Access backend container
docker exec -it picpeak-backend sh
# Test health endpoint
wget -O- http://localhost:3000/health
# Test public settings endpoint
wget -O- http://localhost:3000/public/settings
```
3. **Check Traefik routing**:
```bash
# Check if routes are registered in Traefik
curl https://traefik.yourdomain.com/api/http/routers | jq '.[] | select(.rule | contains("picpeak"))'
```
### 2. Backend Not Accessible Through Traefik
**Key Configuration Points**:
1. **Traefik Labels** (in deploy section):
- `traefik.enable=true` - Enable Traefik for this container
- `traefik.docker.network=proxy` - Specify which network Traefik should use
- `traefik.http.routers.picpeak-backend.priority=100` - Higher priority for API routes
2. **Path Stripping**:
- Frontend expects `/api/*` but backend serves routes without `/api` prefix
- Middleware strips `/api` before forwarding to backend
3. **Network Configuration**:
- Backend must be in both `picpeak` (internal) and `proxy` (Traefik) networks
### 3. Environment Variable Issues
**Critical Variables**:
- `ADMIN_URL` and `FRONTEND_URL` must match your actual domain
- These affect CORS configuration
**Example .env**:
```env
# URLs
ADMIN_URL=https://picpeak.local.nothaft.cloud
FRONTEND_URL=https://picpeak.local.nothaft.cloud
# Database
DB_USER=picpeak
DB_PASSWORD=your_secure_password
DB_NAME=picpeak
# JWT
JWT_SECRET=your_secure_jwt_secret
# Email (optional)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=noreply@example.com
SMTP_PASS=smtp_password
EMAIL_FROM=noreply@example.com
```
### 4. Debugging Steps
1. **Check if backend is receiving requests**:
```bash
# Watch backend logs
docker logs -f picpeak-backend
# Look for incoming requests when you try to access the admin page
```
2. **Test API routes directly**:
```bash
# From outside
curl -v https://picpeak.local.nothaft.cloud/api/public/settings
# Should see backend logs if request reaches container
```
3. **Verify Traefik middleware**:
```bash
# Check if stripprefix middleware exists
curl https://traefik.yourdomain.com/api/http/middlewares | jq '.[] | select(.name | contains("picpeak"))'
```
### 5. Quick Fix Checklist
- [ ] Backend container is healthy (`docker ps`)
- [ ] Backend is in both networks (`docker inspect picpeak-backend | grep -A 20 Networks`)
- [ ] Traefik labels use correct network (`traefik.docker.network=proxy`)
- [ ] Priority is set correctly (backend: 100, frontend: 10)
- [ ] ADMIN_URL and FRONTEND_URL match your domain
- [ ] Database is accessible from backend
- [ ] Migrations have run successfully
### 6. Alternative Testing
If Traefik routing is problematic, test backend directly:
```bash
# Port forward to test backend directly
docker run --rm -it --network picpeak alpine/curl curl http://backend:3000/health
# Or expose backend port temporarily
docker run -d --name picpeak-backend-test \
--network picpeak \
-p 3001:3000 \
registry.local.nothaft.cloud/picpeak-backend:latest
```
Then access http://localhost:3001/health to verify backend is working.
+22
View File
@@ -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
}
}
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# init-production.sh - Production initialization script
set -e
echo "🚀 Initializing PicPeak Production Environment..."
# Wait for services to be ready
echo "⏳ Waiting for database to be fully ready..."
sleep 3
# Fix permissions if running as root (shouldn't happen with proper Dockerfile)
if [ "$(id -u)" = "0" ]; then
echo "🔧 Fixing file permissions..."
chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true
fi
# Create required directories
echo "📁 Creating required directories..."
mkdir -p /app/storage/events/active \
/app/storage/events/archived \
/app/storage/thumbnails \
/app/storage/uploads/logos \
/app/storage/uploads/favicons \
/app/data \
/app/logs
# Run migrations with safe runner
echo "🗄️ Running database migrations (safe mode)..."
NODE_ENV=production npm run migrate:safe
# Create admin user if environment variables are set
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
echo "👤 Creating admin user..."
node scripts/create-admin.js \
--email "$ADMIN_EMAIL" \
--username "${ADMIN_USERNAME:-admin}" \
--password "$ADMIN_PASSWORD" || echo "Admin user might already exist"
fi
# Initialize email configuration if variables are set
if [ -n "$SMTP_HOST" ]; then
echo "📧 Email configuration detected via environment variables"
fi
echo "✅ Production initialization complete!"
echo "🌐 Starting application server..."
# Start the application
exec node server.js
+15 -3
View File
@@ -32,15 +32,27 @@ const config = {
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
},
pool: {
min: 2,
max: 10
max: 10,
acquireTimeoutMillis: 30000,
createTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
},
migrations: {
directory: './migrations'
}
},
acquireConnectionTimeout: 60000
}
};
@@ -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');
});
};
@@ -0,0 +1,35 @@
exports.up = async function(knex) {
// Check current column structure
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
if (hasSubjectEn && !hasSubject) {
// The language migration was applied, need to add back basic columns
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject');
table.text('body_html');
table.text('body_text');
});
// Copy English values to the basic columns
await knex('email_templates').update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
// Check if we have the basic columns
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
if (hasSubject && hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.dropColumn('subject');
table.dropColumn('body_html');
table.dropColumn('body_text');
});
}
};
@@ -0,0 +1,91 @@
exports.up = async function(knex) {
// Check if we have the default email templates
const templates = await knex('email_templates').select('template_key');
const existingKeys = templates.map(t => t.template_key);
// Check which columns exist in the table
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
// Determine which columns to use based on schema
const subjectCol = hasSubjectEn ? 'subject_en' : 'subject';
const bodyHtmlCol = hasSubjectEn ? 'body_html_en' : 'body_html';
const bodyTextCol = hasSubjectEn ? 'body_text_en' : 'body_text';
const defaultTemplates = [
{
template_key: 'gallery_created',
[subjectCol]: 'Your Photo Gallery is Ready!',
[bodyHtmlCol]: `<h2>Gallery Created Successfully</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has been created successfully!</p>
<p><strong>Gallery Details:</strong></p>
<ul>
<li>Event Date: {{event_date}}</li>
<li>Gallery Link: {{gallery_link}}</li>
<li>Password: {{gallery_password}}</li>
<li>Expires: {{expiry_date}}</li>
</ul>
<p>Share this link and password with your guests to allow them to view and download photos.</p>`,
[bodyTextCol]: 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!',
variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date'])
},
{
template_key: 'expiration_warning',
[subjectCol]: 'Your Photo Gallery Expires Soon',
[bodyHtmlCol]: `<h2>Gallery Expiring Soon</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p>
<p>After expiration, the gallery will be archived and no longer accessible to guests.</p>
<p><a href="{{gallery_link}}">Visit Gallery</a></p>`,
[bodyTextCol]: 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.',
variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link'])
},
{
template_key: 'gallery_expired',
[subjectCol]: 'Your Photo Gallery Has Expired',
[bodyHtmlCol]: `<h2>Gallery Expired</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has expired and been archived.</p>
<p>The photos are safely stored in our archive system. If you need access to the archived photos, please contact support.</p>`,
[bodyTextCol]: 'Gallery Expired\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has expired and been archived.',
variables: JSON.stringify(['host_name', 'event_name'])
},
{
template_key: 'archive_complete',
[subjectCol]: 'Gallery Archive Complete',
[bodyHtmlCol]: `<h2>Archive Complete</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has been successfully archived.</p>
<p>Archive size: {{archive_size}}</p>
<p>The archive is stored securely and can be retrieved if needed.</p>`,
[bodyTextCol]: 'Archive Complete\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been successfully archived.',
variables: JSON.stringify(['host_name', 'event_name', 'archive_size'])
}
];
// Insert missing templates
for (const template of defaultTemplates) {
if (!existingKeys.includes(template.template_key)) {
// If we have language columns, also set German versions with same content
if (hasSubjectEn) {
template.subject_de = template[subjectCol];
template.body_html_de = template[bodyHtmlCol];
template.body_text_de = template[bodyTextCol];
// Also ensure we have the basic columns if they exist
if (hasSubject) {
template.subject = template[subjectCol];
template.body_html = template[bodyHtmlCol];
template.body_text = template[bodyTextCol];
}
}
await knex('email_templates').insert(template);
}
}
};
exports.down = async function(knex) {
// Don't remove templates on rollback as they might have been customized
};
@@ -0,0 +1,121 @@
exports.up = async function(knex) {
// Check if CMS pages already exist
const impressumExists = await knex('cms_pages')
.where('slug', 'impressum')
.first();
const datenschutzExists = await knex('cms_pages')
.where('slug', 'datenschutz')
.first();
const pagesToInsert = [];
// Add Impressum page if it doesn't exist
if (!impressumExists) {
pagesToInsert.push({
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: `<h1>Legal Notice</h1>
<p>Information according to § 5 TMG</p>
<h2>Responsible for content</h2>
<p>[Your Name]<br>
[Your Address]<br>
[Postal Code City]</p>
<h2>Contact</h2>
<p>Email: [Your Email Address]<br>
Phone: [Your Phone Number]</p>
<h2>Disclaimer</h2>
<h3>Liability for content</h3>
<p>The contents of our pages were created with great care. However, we cannot guarantee the accuracy, completeness and timeliness of the content.</p>
<h3>Liability for links</h3>
<p>Our website contains links to external third-party websites over whose content we have no influence. Therefore, we cannot accept any liability for this third-party content.</p>`,
content_de: `<h1>Impressum</h1>
<p>Angaben gemäß § 5 TMG</p>
<h2>Verantwortlich für den Inhalt</h2>
<p>[Ihr Name]<br>
[Ihre Adresse]<br>
[PLZ Ort]</p>
<h2>Kontakt</h2>
<p>E-Mail: [Ihre E-Mail-Adresse]<br>
Telefon: [Ihre Telefonnummer]</p>
<h2>Haftungsausschluss</h2>
<h3>Haftung für Inhalte</h3>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.</p>
<h3>Haftung für Links</h3>
<p>Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.</p>`,
updated_at: new Date()
});
}
// Add Datenschutz page if it doesn't exist
if (!datenschutzExists) {
pagesToInsert.push({
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: `<h1>Privacy Policy</h1>
<h2>1. Privacy at a Glance</h2>
<h3>General Information</h3>
<p>The following information provides a simple overview of what happens to your personal data when you visit this website.</p>
<h3>Data Collection on This Website</h3>
<p><strong>Who is responsible for data collection on this website?</strong></p>
<p>Data processing on this website is carried out by the website operator. Their contact details can be found in the legal notice of this website.</p>
<p><strong>How do we collect your data?</strong></p>
<p>Your data is collected when you provide it to us. This could be data that you enter into a contact form, for example.</p>
<p><strong>What do we use your data for?</strong></p>
<p>Some of the data is collected to ensure error-free provision of the website. Other data may be used to analyze your user behavior.</p>
<h2>2. Hosting</h2>
<p>This website is hosted externally. The personal data collected on this website is stored on the servers of the host.</p>
<h2>3. General Information and Mandatory Information</h2>
<h3>Data Protection</h3>
<p>The operators of these pages take the protection of your personal data very seriously. We treat your personal data confidentially and in accordance with the statutory data protection regulations and this privacy policy.</p>`,
content_de: `<h1>Datenschutzerklärung</h1>
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.</p>
<h3>Datenerfassung auf dieser Website</h3>
<p><strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong></p>
<p>Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.</p>
<p><strong>Wie erfassen wir Ihre Daten?</strong></p>
<p>Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.</p>
<p><strong>Wofür nutzen wir Ihre Daten?</strong></p>
<p>Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.</p>
<h2>2. Hosting</h2>
<p>Diese Website wird extern gehostet. Die personenbezogenen Daten, die auf dieser Website erfasst werden, werden auf den Servern des Hosters gespeichert.</p>
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.</p>`,
updated_at: new Date()
});
}
// Insert pages if any need to be added
if (pagesToInsert.length > 0) {
await knex('cms_pages').insert(pagesToInsert);
}
};
exports.down = async function(knex) {
// Don't remove CMS pages on rollback as they might have been customized
};
@@ -0,0 +1,68 @@
exports.up = async function(knex) {
console.log('Fixing JSON columns in database...');
// Fix email_templates variables column
const templates = await knex('email_templates').select('id', 'template_key', 'variables');
for (const template of templates) {
if (template.variables && typeof template.variables === 'string') {
try {
// Check if it's already valid JSON
JSON.parse(template.variables);
} catch (e) {
console.log(`Fixing invalid JSON in email template ${template.template_key}`);
// Attempt to fix common issues
let fixed = template.variables;
// If it looks like an array but isn't valid JSON, try to fix it
if (fixed.startsWith('[') && fixed.endsWith(']')) {
// Extract the content and properly format it
const content = fixed.slice(1, -1);
const items = content.split(',').map(item => item.trim().replace(/['"]/g, ''));
fixed = JSON.stringify(items);
} else {
// Default to empty array if we can't fix it
fixed = JSON.stringify([]);
}
await knex('email_templates')
.where('id', template.id)
.update({ variables: fixed });
}
} else if (!template.variables) {
// Set default empty array for null values
await knex('email_templates')
.where('id', template.id)
.update({ variables: JSON.stringify([]) });
}
}
// Fix activity_logs metadata column
const activities = await knex('activity_logs').select('id', 'metadata');
for (const activity of activities) {
if (activity.metadata && typeof activity.metadata === 'string') {
try {
// Check if it's already valid JSON
JSON.parse(activity.metadata);
} catch (e) {
console.log(`Fixing invalid JSON in activity log ${activity.id}`);
// Default to empty object if we can't parse it
await knex('activity_logs')
.where('id', activity.id)
.update({ metadata: JSON.stringify({}) });
}
} else if (!activity.metadata) {
// Set default empty object for null values
await knex('activity_logs')
.where('id', activity.id)
.update({ metadata: JSON.stringify({}) });
}
}
console.log('JSON columns fixed successfully');
};
exports.down = async function(knex) {
// No rollback needed - data fixes only
};
+83
View File
@@ -0,0 +1,83 @@
/**
* Migration helper functions for production-safe migrations
*/
/**
* Create a table only if it doesn't already exist
*/
async function createTableIfNotExists(knex, tableName, callback) {
const exists = await knex.schema.hasTable(tableName);
if (!exists) {
console.log(`Creating table: ${tableName}`);
return knex.schema.createTable(tableName, callback);
} else {
console.log(`Table ${tableName} already exists, skipping...`);
}
}
/**
* Add column to table only if it doesn't exist
*/
async function addColumnIfNotExists(knex, tableName, columnName, callback) {
const hasColumn = await knex.schema.hasColumn(tableName, columnName);
if (!hasColumn) {
console.log(`Adding column ${columnName} to table ${tableName}`);
return knex.schema.alterTable(tableName, (table) => {
callback(table);
});
} else {
console.log(`Column ${columnName} already exists in table ${tableName}, skipping...`);
}
}
/**
* Insert data only if it doesn't already exist
*/
async function insertIfNotExists(knex, tableName, data, uniqueField) {
const exists = await knex(tableName)
.where(uniqueField, data[uniqueField])
.first();
if (!exists) {
console.log(`Inserting ${uniqueField}: ${data[uniqueField]} into ${tableName}`);
return knex(tableName).insert(data);
} else {
console.log(`${uniqueField}: ${data[uniqueField]} already exists in ${tableName}, skipping...`);
}
}
/**
* Create index only if it doesn't exist
*/
async function createIndexIfNotExists(knex, tableName, columns, indexName) {
// This is database-specific, works for PostgreSQL
if (knex.client.config.client === 'pg') {
const result = await knex.raw(`
SELECT 1 FROM pg_indexes
WHERE tablename = ? AND indexname = ?
`, [tableName, indexName]);
if (result.rows.length === 0) {
console.log(`Creating index ${indexName} on ${tableName}`);
return knex.schema.alterTable(tableName, (table) => {
table.index(columns, indexName);
});
}
} else {
// For SQLite, just try to create and ignore errors
try {
await knex.schema.alterTable(tableName, (table) => {
table.index(columns, indexName);
});
} catch (error) {
// Index probably already exists
}
}
}
module.exports = {
createTableIfNotExists,
addColumnIfNotExists,
insertIfNotExists,
createIndexIfNotExists
};
+170
View File
@@ -0,0 +1,170 @@
const fs = require('fs').promises;
const path = require('path');
const { db } = require('../src/database/db');
/**
* Production-safe migration runner that handles existing schema
*/
// Create or verify migrations tracking table
async function ensureMigrationsTable() {
const tableExists = await db.schema.hasTable('migrations');
if (!tableExists) {
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 if a migration has been applied
async function isMigrationApplied(filename) {
const result = await db('migrations').where('filename', filename).first();
return !!result;
}
// Mark migration as applied without running it (for existing schema)
async function markMigrationAsApplied(filename) {
await db('migrations').insert({ filename });
console.log(`Marked migration ${filename} as applied`);
}
// Detect existing schema and mark migrations as applied
async function detectExistingSchema() {
console.log('Detecting existing schema...');
const tableChecks = [
{ table: 'events', migration: 'init.js' },
{ table: 'photos', migration: 'init.js' },
{ table: 'photo_categories', migration: '004_add_categories_and_cms.js' },
{ table: 'cms_pages', migration: '004_add_categories_and_cms.js' },
{ table: 'login_attempts', migration: '015_add_login_attempts_table.js' },
{ table: 'token_blacklist', migration: '017_add_token_revocation_tables.js' },
];
for (const check of tableChecks) {
const exists = await db.schema.hasTable(check.table);
if (exists) {
const isApplied = await isMigrationApplied(check.migration);
if (!isApplied) {
await markMigrationAsApplied(check.migration);
}
}
}
}
// Run a single migration safely
async function runMigrationSafely(filename) {
try {
const migrationPath = path.join(__dirname, filename);
const migration = require(migrationPath);
if (migration.up) {
console.log(`Running migration: ${filename}`);
// Run migration in a transaction if possible
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
});
} else {
await migration.up(db);
}
await db('migrations').insert({ filename });
console.log(`Migration ${filename} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
console.log(`Migration ${filename} - schema already exists, marking as applied`);
await markMigrationAsApplied(filename);
} else {
throw error;
}
}
}
// Main migration runner
async function runMigrations() {
let connection;
try {
console.log('Starting production-safe database migrations...');
// Ensure database connection is ready
await db.raw('SELECT 1');
console.log('Database connection verified');
// Create migrations tracking table
await ensureMigrationsTable();
// Detect and mark existing schema
await detectExistingSchema();
// Get all migration files
const files = await fs.readdir(__dirname);
const migrationFiles = files
.filter(f => f.match(/^\d{3}_.*\.js$/) || f === 'init.js')
.sort((a, b) => {
// Ensure init.js runs first
if (a === 'init.js') return -1;
if (b === 'init.js') return 1;
return a.localeCompare(b);
});
// Run pending migrations
let pendingCount = 0;
let skippedCount = 0;
for (const file of migrationFiles) {
const isApplied = await isMigrationApplied(file);
if (!isApplied) {
await runMigrationSafely(file);
pendingCount++;
} else {
skippedCount++;
}
}
console.log(`\nMigration Summary:`);
console.log(`- Applied: ${pendingCount} migration(s)`);
console.log(`- Skipped: ${skippedCount} migration(s) (already applied)`);
console.log(`- Total: ${migrationFiles.length} migration(s)`);
console.log('\nAll migrations completed successfully');
// Close database connection
await db.destroy();
process.exit(0);
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
console.error('Error details:', error);
// Close database connection on error
try {
await db.destroy();
} catch (e) {
// Ignore
}
process.exit(1);
}
}
// Add delay for database readiness in production
async function waitAndRun() {
if (process.env.NODE_ENV === 'production') {
console.log('Waiting 2 seconds for database readiness...');
await new Promise(resolve => setTimeout(resolve, 2000));
}
await runMigrations();
}
// Only run if called directly
if (require.main === module) {
waitAndRun();
}
module.exports = { runMigrations };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.9",
"version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.9",
"version": "1.0.20",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+2 -1
View File
@@ -1,12 +1,13 @@
{
"name": "picpeak-backend",
"version": "1.0.9",
"version": "1.0.20",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"test": "jest",
"lint": "eslint src/"
},
+60
View File
@@ -0,0 +1,60 @@
require('dotenv').config();
const { db } = require('../src/database/db');
async function checkDatabaseIssues() {
console.log('Checking database issues...\n');
try {
// Check email_templates table structure
console.log('1. Checking email_templates table structure:');
const emailTemplateColumns = await db('email_templates').columnInfo();
console.log('Columns:', Object.keys(emailTemplateColumns));
// Check if any templates exist
const templateCount = await db('email_templates').count('* as count');
console.log('Template count:', templateCount[0].count);
// Check for specific template
const galleryCreatedTemplate = await db('email_templates')
.where('template_key', 'gallery_created')
.first();
console.log('gallery_created template exists:', !!galleryCreatedTemplate);
// Check activity_logs table
console.log('\n2. Checking activity_logs table:');
const activityLogColumns = await db('activity_logs').columnInfo();
console.log('Columns:', Object.keys(activityLogColumns));
// Check migrations table
console.log('\n3. Checking migrations status:');
const migrations = await db('migrations')
.orderBy('id', 'desc')
.limit(10);
console.log('Latest migrations:');
migrations.forEach(m => console.log(` - ${m.filename}`));
// Test a simple query from notifications route
console.log('\n4. Testing notifications query:');
try {
const notifications = await db('activity_logs')
.select(
'activity_logs.*',
'events.event_name'
)
.leftJoin('events', 'activity_logs.event_id', 'events.id')
.orderBy('activity_logs.created_at', 'desc')
.limit(5);
console.log(`Found ${notifications.length} notifications`);
} catch (error) {
console.error('Notifications query failed:', error.message);
}
} catch (error) {
console.error('Error:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
checkDatabaseIssues();
+1 -1
View File
@@ -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');
+92
View File
@@ -0,0 +1,92 @@
require('dotenv').config();
const { db } = require('../src/database/db');
async function debugEndpoints() {
console.log('Debugging 500 errors...\n');
try {
// Test email templates query
console.log('1. Testing email templates query:');
try {
const templates = await db('email_templates')
.select('*')
.orderBy('template_key');
console.log(`Found ${templates.length} templates`);
if (templates.length > 0) {
console.log('First template columns:', Object.keys(templates[0]));
console.log('Template keys:', templates.map(t => t.template_key));
}
} catch (error) {
console.error('Email templates query failed:', error.message);
console.error('Error code:', error.code);
}
// Test notifications query
console.log('\n2. Testing notifications query:');
try {
const notifications = await db('activity_logs')
.select(
'activity_logs.*',
'events.event_name'
)
.leftJoin('events', 'activity_logs.event_id', 'events.id')
.whereNull('activity_logs.read_at')
.orderBy('activity_logs.created_at', 'desc')
.limit(5);
console.log(`Found ${notifications.length} unread notifications`);
} catch (error) {
console.error('Notifications query failed:', error.message);
console.error('Error code:', error.code);
// Check if it's a column issue
if (error.message.includes('column')) {
console.log('\nChecking activity_logs columns:');
const columns = await db('activity_logs').columnInfo();
console.log('Columns:', Object.keys(columns));
}
}
// Test specific template query
console.log('\n3. Testing specific template query (gallery_created):');
try {
const template = await db('email_templates')
.where('template_key', 'gallery_created')
.first();
if (template) {
console.log('Template found:', template.template_key);
console.log('Has subject_en?', template.subject_en !== undefined);
console.log('Has subject?', template.subject !== undefined);
} else {
console.log('Template not found');
}
} catch (error) {
console.error('Template query failed:', error.message);
}
// Check CMS pages
console.log('\n4. Checking CMS pages:');
try {
const pages = await db('cms_pages')
.select('slug', 'title', 'is_published')
.orderBy('slug');
console.log(`Found ${pages.length} CMS pages:`);
pages.forEach(page => {
console.log(` - ${page.slug}: ${page.title} (published: ${page.is_published})`);
});
} catch (error) {
console.error('CMS pages query failed:', error.message);
}
} catch (error) {
console.error('General error:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
debugEndpoints();
+145
View File
@@ -0,0 +1,145 @@
require('dotenv').config();
const { db } = require('../src/database/db');
async function fixProductionIssues() {
console.log('Fixing production database issues...\n');
try {
// 1. Check and fix email_templates structure
console.log('1. Checking email_templates structure:');
const emailColumns = await db('email_templates').columnInfo();
console.log('Current columns:', Object.keys(emailColumns));
// Check if we need to add basic columns back
const hasSubject = 'subject' in emailColumns;
const hasSubjectEn = 'subject_en' in emailColumns;
if (hasSubjectEn && !hasSubject) {
console.log('Adding basic columns back to email_templates...');
await db.schema.alterTable('email_templates', (table) => {
table.string('subject');
table.text('body_html');
table.text('body_text');
});
// Copy values from _en columns
await db('email_templates').update({
subject: db.raw('subject_en'),
body_html: db.raw('body_html_en'),
body_text: db.raw('body_text_en')
});
console.log('Basic columns added successfully');
}
// 2. Ensure default templates exist
console.log('\n2. Checking email templates:');
const templateCount = await db('email_templates').count('* as count');
console.log('Template count:', templateCount[0].count);
if (templateCount[0].count === 0) {
console.log('No templates found, inserting defaults...');
const defaultTemplates = [
{
template_key: 'gallery_created',
subject: 'Your Photo Gallery is Ready!',
body_html: '<h2>Gallery Created Successfully</h2>...',
body_text: 'Gallery Created Successfully...',
variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date'])
},
{
template_key: 'expiration_warning',
subject: 'Your Photo Gallery Expires Soon',
body_html: '<h2>Gallery Expiring Soon</h2>...',
body_text: 'Gallery Expiring Soon...',
variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link'])
},
{
template_key: 'gallery_expired',
subject: 'Your Photo Gallery Has Expired',
body_html: '<h2>Gallery Expired</h2>...',
body_text: 'Gallery Expired...',
variables: JSON.stringify(['host_name', 'event_name'])
},
{
template_key: 'archive_complete',
subject: 'Gallery Archive Complete',
body_html: '<h2>Archive Complete</h2>...',
body_text: 'Archive Complete...',
variables: JSON.stringify(['host_name', 'event_name', 'archive_size'])
}
];
for (const template of defaultTemplates) {
// Add language columns if they exist
if (hasSubjectEn) {
template.subject_en = template.subject;
template.body_html_en = template.body_html;
template.body_text_en = template.body_text;
template.subject_de = template.subject;
template.body_html_de = template.body_html;
template.body_text_de = template.body_text;
}
await db('email_templates').insert(template);
}
console.log('Default templates inserted');
}
// 3. Check activity_logs structure
console.log('\n3. Checking activity_logs structure:');
const activityColumns = await db('activity_logs').columnInfo();
console.log('Columns:', Object.keys(activityColumns));
// Check if read_at exists
if (!('read_at' in activityColumns)) {
console.log('Adding read_at column to activity_logs...');
await db.schema.alterTable('activity_logs', (table) => {
table.datetime('read_at').nullable();
});
console.log('read_at column added');
}
// 4. Check and add CMS pages
console.log('\n4. Checking CMS pages:');
const cmsColumns = await db('cms_pages').columnInfo();
console.log('CMS columns:', Object.keys(cmsColumns));
const impressum = await db('cms_pages').where('slug', 'impressum').first();
const datenschutz = await db('cms_pages').where('slug', 'datenschutz').first();
if (!impressum) {
console.log('Adding Impressum page...');
await db('cms_pages').insert({
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h1>Legal Notice</h1><p>Your legal information here...</p>',
content_de: '<h1>Impressum</h1><p>Ihre rechtlichen Informationen hier...</p>',
updated_at: new Date()
});
}
if (!datenschutz) {
console.log('Adding Datenschutz page...');
await db('cms_pages').insert({
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h1>Privacy Policy</h1><p>Your privacy policy here...</p>',
content_de: '<h1>Datenschutzerklärung</h1><p>Ihre Datenschutzerklärung hier...</p>',
updated_at: new Date()
});
}
console.log('\n✅ All fixes applied successfully!');
} catch (error) {
console.error('Error fixing issues:', error);
console.error('Stack:', error.stack);
} finally {
await db.destroy();
process.exit(0);
}
}
fixProductionIssues();
+5 -2
View File
@@ -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');
@@ -28,6 +28,9 @@ const adminAuthRoutes = require('./src/routes/adminAuth');
const app = express();
const PORT = process.env.PORT || 3000;
// Trust proxy headers (required for Traefik/nginx)
app.set('trust proxy', true);
// Security middleware with custom CSP
app.use(helmet({
contentSecurityPolicy: {
@@ -152,7 +155,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');
+132
View File
@@ -0,0 +1,132 @@
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
class ConnectionManager {
constructor() {
this.db = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 5000; // 5 seconds
this.isReconnecting = false;
}
async initialize() {
try {
this.db = knex(knexConfig);
// Test the connection
await this.db.raw('SELECT 1');
logger.info('Database connection established successfully');
// Set up connection error handling
this.setupErrorHandling();
this.reconnectAttempts = 0;
return this.db;
} catch (error) {
logger.error('Failed to initialize database connection:', error);
throw error;
}
}
setupErrorHandling() {
if (!this.db) return;
// Handle connection errors
this.db.on('error', async (error) => {
logger.error('Database connection error:', error);
if (this.shouldReconnect(error)) {
await this.reconnect();
}
});
}
shouldReconnect(error) {
const reconnectableErrors = [
'ECONNREFUSED',
'ETIMEDOUT',
'ECONNRESET',
'Connection terminated unexpectedly',
'Connection terminated'
];
return reconnectableErrors.some(msg =>
error.code === msg || error.message?.includes(msg)
);
}
async reconnect() {
if (this.isReconnecting) {
logger.info('Already attempting to reconnect...');
return;
}
this.isReconnecting = true;
while (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
logger.info(`Attempting to reconnect to database (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
try {
// Destroy the old connection pool
if (this.db) {
await this.db.destroy();
}
// Create new connection
await this.initialize();
logger.info('Successfully reconnected to database');
this.isReconnecting = false;
return;
} catch (error) {
logger.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error.message);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
await new Promise(resolve => setTimeout(resolve, this.reconnectDelay));
}
}
}
this.isReconnecting = false;
logger.error('Failed to reconnect to database after maximum attempts');
// In production, you might want to alert monitoring systems or restart the process
if (process.env.NODE_ENV === 'production') {
logger.error('Exiting process due to database connection failure');
process.exit(1);
}
}
getConnection() {
if (!this.db) {
throw new Error('Database connection not initialized');
}
return this.db;
}
async healthCheck() {
try {
await this.db.raw('SELECT 1');
return { healthy: true };
} catch (error) {
logger.error('Database health check failed:', error);
return { healthy: false, error: error.message };
}
}
async destroy() {
if (this.db) {
await this.db.destroy();
this.db = null;
}
}
}
// Create singleton instance
const connectionManager = new ConnectionManager();
module.exports = connectionManager;
+1
View File
@@ -1,6 +1,7 @@
const knex = require('knex');
const knexConfig = require('../../knexfile');
// Create database connection with built-in retry logic
const db = knex(knexConfig);
async function initializeDatabase() {
+52 -14
View File
@@ -5,6 +5,36 @@ let maintenanceMode = false;
let lastCheck = 0;
const CACHE_DURATION = 60000; // 1 minute
// Retry configuration for database queries
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
async function queryWithRetry(queryFn, retries = MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
if (i === retries - 1) {
throw error;
}
// Check if it's a connection error that might benefit from retry
const isConnectionError =
error.message?.includes('Connection terminated') ||
error.message?.includes('ECONNREFUSED') ||
error.message?.includes('ETIMEDOUT') ||
error.code === 'ECONNRESET';
if (isConnectionError) {
console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
} else {
throw error; // Don't retry non-connection errors
}
}
}
}
async function checkMaintenanceMode() {
const now = Date.now();
@@ -14,18 +44,21 @@ async function checkMaintenanceMode() {
}
try {
const setting = await db('app_settings')
.where('setting_key', 'general_maintenance_mode')
.where('setting_type', 'general')
.first();
const setting = await queryWithRetry(async () => {
return await db('app_settings')
.where('setting_key', 'general_maintenance_mode')
.where('setting_type', 'general')
.first();
});
maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false;
lastCheck = now;
return maintenanceMode;
} catch (error) {
console.error('Error checking maintenance mode:', error);
return false;
console.error('Error checking maintenance mode after retries:', error.message);
// Return cached value or false if no cache
return maintenanceMode;
}
}
@@ -52,14 +85,19 @@ async function maintenanceMiddleware(req, res, next) {
return next();
}
const inMaintenance = await checkMaintenanceMode();
if (inMaintenance && !isAdminRoute) {
return res.status(503).json({
error: 'Service Unavailable',
message: 'The system is currently undergoing maintenance. Please try again later.',
maintenance: true
});
try {
const inMaintenance = await checkMaintenanceMode();
if (inMaintenance && !isAdminRoute) {
return res.status(503).json({
error: 'Service Unavailable',
message: 'The system is currently undergoing maintenance. Please try again later.',
maintenance: true
});
}
} catch (error) {
// If we can't check maintenance mode, allow the request to proceed
console.error('Failed to check maintenance mode, allowing request:', error.message);
}
next();
+10 -1
View File
@@ -122,7 +122,16 @@ router.get('/activity', adminAuth, async (req, res) => {
actorType: activity.actor_type,
actorName: activity.actor_name,
eventName: activity.event_name,
metadata: activity.metadata ? JSON.parse(activity.metadata) : {},
metadata: (() => {
try {
if (!activity.metadata) return {};
if (typeof activity.metadata === 'object') return activity.metadata;
return JSON.parse(activity.metadata);
} catch (e) {
console.warn('Failed to parse metadata for activity:', activity.id, e.message);
return {};
}
})(),
createdAt: activity.created_at
}));
+158 -40
View File
@@ -112,17 +112,44 @@ router.post('/test', adminAuth, async (req, res) => {
return res.status(400).json({ error: 'Email configuration not found. Please configure SMTP settings first.' });
}
// Create transporter
const transporter = nodemailer.createTransport({
// Validate SMTP configuration
if (!config.smtp_host || !config.smtp_port) {
return res.status(400).json({
error: 'Incomplete email configuration',
details: 'SMTP host and port are required'
});
}
// Check if password might be masked (this shouldn't happen when fetching from DB)
if (config.smtp_pass === '********') {
return res.status(400).json({
error: 'Invalid email configuration',
details: 'SMTP password appears to be masked. Please reconfigure your email settings.'
});
}
// Create transporter with detailed logging
const transportConfig = {
host: config.smtp_host,
port: config.smtp_port,
secure: config.smtp_secure,
auth: config.smtp_user ? {
port: parseInt(config.smtp_port),
secure: config.smtp_secure === true || config.smtp_secure === 1,
auth: config.smtp_user && config.smtp_pass ? {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined
} : undefined,
logger: process.env.NODE_ENV === 'development',
debug: process.env.NODE_ENV === 'development'
};
console.log('Creating email transporter with config:', {
host: transportConfig.host,
port: transportConfig.port,
secure: transportConfig.secure,
auth: transportConfig.auth ? 'configured' : 'none'
});
const transporter = nodemailer.createTransport(transportConfig);
// Send test email
await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
@@ -145,9 +172,27 @@ router.post('/test', adminAuth, async (req, res) => {
res.json({ message: 'Test email sent successfully' });
} catch (error) {
console.error('Test email error:', error);
console.error('Error stack:', error.stack);
// Provide more specific error messages
let errorMessage = 'Failed to send test email';
let details = error.message;
if (error.code === 'ECONNREFUSED') {
errorMessage = 'Failed to connect to SMTP server';
details = 'Please check your SMTP host and port settings';
} else if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed';
details = 'Please check your SMTP username and password';
} else if (error.code === 'ESOCKET') {
errorMessage = 'Network error';
details = 'Could not establish connection to SMTP server';
}
res.status(500).json({
error: 'Failed to send test email',
details: error.message
error: errorMessage,
details: details,
code: error.code
});
}
});
@@ -160,20 +205,44 @@ router.get('/templates', adminAuth, async (req, res) => {
.orderBy('template_key');
// Parse variables JSON and format for multi-language support
const formattedTemplates = templates.map(template => ({
id: template.id,
template_key: template.template_key,
// English versions
subject_en: template.subject_en || template.subject,
body_html_en: template.body_html_en || template.body_html,
body_text_en: template.body_text_en || template.body_text,
// German versions
subject_de: template.subject_de || template.subject_en || template.subject,
body_html_de: template.body_html_de || template.body_html_en || template.body_html,
body_text_de: template.body_text_de || template.body_text_en || template.body_text,
variables: template.variables ? JSON.parse(template.variables) : [],
updated_at: template.updated_at
}));
const formattedTemplates = templates.map(template => {
const result = {
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Handle both old and new schema formats
if (template.subject_en !== undefined) {
// New schema with language columns
result.subject_en = template.subject_en;
result.body_html_en = template.body_html_en;
result.body_text_en = template.body_text_en;
result.subject_de = template.subject_de;
result.body_html_de = template.body_html_de;
result.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
result.subject_en = template.subject;
result.body_html_en = template.body_html;
result.body_text_en = template.body_text;
result.subject_de = template.subject;
result.body_html_de = template.body_html;
result.body_text_de = template.body_text;
}
return result;
});
res.json(formattedTemplates);
} catch (error) {
@@ -193,20 +262,43 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Template not found' });
}
res.json({
// Handle both old and new schema formats
const response = {
id: template.id,
template_key: template.template_key,
// English versions
subject_en: template.subject_en || template.subject,
body_html_en: template.body_html_en || template.body_html,
body_text_en: template.body_text_en || template.body_text,
// German versions
subject_de: template.subject_de || template.subject_en || template.subject,
body_html_de: template.body_html_de || template.body_html_en || template.body_html,
body_text_de: template.body_text_de || template.body_text_en || template.body_text,
variables: template.variables ? JSON.parse(template.variables) : [],
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
});
};
// Check which columns exist and use them appropriately
if (template.subject_en !== undefined) {
// New schema with language columns
response.subject_en = template.subject_en;
response.body_html_en = template.body_html_en;
response.body_text_en = template.body_text_en;
response.subject_de = template.subject_de;
response.body_html_de = template.body_html_de;
response.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
response.subject_en = template.subject;
response.body_html_en = template.body_html;
response.body_text_en = template.body_text;
response.subject_de = template.subject;
response.body_html_de = template.body_html;
response.body_text_de = template.body_text;
}
res.json(response);
} catch (error) {
console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' });
@@ -237,13 +329,39 @@ router.put('/templates/:key', [
updated_at: new Date()
};
// Only update provided fields
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Check which columns exist in the database
const template = await db('email_templates')
.where('template_key', req.params.key)
.first();
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Determine schema type and update accordingly
if (template.subject_en !== undefined) {
// New schema with language columns
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Also update basic columns if they exist
if (template.subject !== undefined) {
updateData.subject = subject_en || updateData.subject_en;
updateData.body_html = body_html_en || updateData.body_html_en;
updateData.body_text = body_text_en || updateData.body_text_en || '';
}
} else {
// Old schema - only update basic columns
if (subject_en !== undefined) {
updateData.subject = subject_en;
updateData.body_html = body_html_en;
updateData.body_text = body_text_en || '';
}
}
const updated = await db('email_templates')
.where('template_key', req.params.key)
+10 -1
View File
@@ -32,7 +32,16 @@ router.get('/', adminAuth, async (req, res) => {
actorName: notification.actor_name,
eventName: notification.event_name,
eventId: notification.event_id,
metadata: notification.metadata ? JSON.parse(notification.metadata) : {},
metadata: (() => {
try {
if (!notification.metadata) return {};
if (typeof notification.metadata === 'object') return notification.metadata;
return JSON.parse(notification.metadata);
} catch (e) {
console.warn('Failed to parse metadata for notification:', notification.id, e.message);
return {};
}
})(),
createdAt: notification.created_at,
readAt: notification.read_at,
isRead: !!notification.read_at
+6 -2
View File
@@ -17,9 +17,13 @@ done
>&2 echo "PostgreSQL is up - executing command"
# Run migrations
# Run migrations (use safe runner in production)
echo "Running database migrations..."
npm run migrate
if [ "$NODE_ENV" = "production" ]; then
npm run migrate:safe
else
npm run migrate
fi
# Execute the main command
exec "$@"
+110
View File
@@ -0,0 +1,110 @@
version: '3.8'
services:
backend:
image: 'registry.local.nothaft.cloud/picpeak-backend:latest'
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=https://picpeak.local.nothaft.cloud
- FRONTEND_URL=https://picpeak.local.nothaft.cloud
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- STORAGE_PATH=/app/storage
- EVENTS_PATH=/app/storage/events
- ARCHIVE_PATH=/app/storage/events/archived
volumes:
- '/mnt/DockerMount/picpeak/storage:/app/storage'
- '/mnt/DockerMount/picpeak/data:/app/data'
- '/mnt/DockerMount/picpeak/logs:/app/logs'
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Backend API routing WITHOUT path stripping
- 'traefik.http.routers.picpeak-backend.rule=(Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)) && PathPrefix(`/api`)'
- traefik.http.routers.picpeak-backend.entrypoints=https
- traefik.http.routers.picpeak-backend.tls.certresolver=dns
- traefik.http.services.picpeak-backend.loadbalancer.server.port=3000
# Remove the stripprefix middleware - backend expects /api prefix
# - traefik.http.middlewares.picpeak-stripprefix.stripprefix.prefixes=/api
# - traefik.http.routers.picpeak-backend.middlewares=picpeak-stripprefix
- traefik.http.routers.picpeak-backend.priority=100
- homepage.group=Public Services
- homepage.name=PicPeak Backend
- homepage.icon=mdi-api
- 'homepage.href=https://picpeak.local.nothaft.cloud/api/health'
- homepage.description=PicPeak API Backend
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
- db
frontend:
image: 'registry.local.nothaft.cloud/picpeak-frontend:latest'
restart: unless-stopped
depends_on:
- backend
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.picpeak.rule=Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak.entrypoints=https
- traefik.http.routers.picpeak.tls.certresolver=dns
- traefik.http.services.picpeak.loadbalancer.server.port=80
- traefik.http.routers.picpeak.priority=10
- homepage.group=Public Services
- homepage.name=PicPeak
- homepage.icon=mdi-photo
- 'homepage.href=https://picpeak.local.nothaft.cloud/'
- homepage.description=Photo Sharing System
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=${PG_AUTH_METHOD:-scram-sha-256}
- POSTGRES_INITDB_ARGS=${PG_INIT_ARGS:---auth-host=scram-sha-256 --auth-local=trust}
volumes:
- '/mnt/DockerMount/picpeak/db:/var/lib/postgresql/data'
networks:
- picpeak
command: ${PG_COMMANDS:-postgres -c ssl=off}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
networks:
proxy:
external: true
picpeak:
driver: bridge
+2
View File
@@ -49,6 +49,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=/api
restart: unless-stopped
depends_on:
- backend
+131
View File
@@ -0,0 +1,131 @@
version: '3.8'
services:
backend:
image: 'registry.local.nothaft.cloud/picpeak-backend:latest'
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=https://picpeak.local.nothaft.cloud
- FRONTEND_URL=https://picpeak.local.nothaft.cloud
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- STORAGE_PATH=/app/storage
- EVENTS_PATH=/app/storage/events
- ARCHIVE_PATH=/app/storage/events/archived
volumes:
- '/mnt/DockerMount/picpeak/storage:/app/storage'
- '/mnt/DockerMount/picpeak/data:/app/data'
- '/mnt/DockerMount/picpeak/logs:/app/logs'
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Backend API routing
- 'traefik.http.routers.picpeak-backend.rule=(Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)) && PathPrefix(`/api`)'
- traefik.http.routers.picpeak-backend.entrypoints=https
- traefik.http.routers.picpeak-backend.tls=true
- traefik.http.routers.picpeak-backend.tls.certresolver=dns
- 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
# Higher priority for API routes
- traefik.http.routers.picpeak-backend.priority=100
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
- db
frontend:
image: 'registry.local.nothaft.cloud/picpeak-frontend:latest'
restart: unless-stopped
depends_on:
- backend
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Frontend routing (catch-all for non-API routes)
- traefik.http.routers.picpeak.rule=Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak.entrypoints=https
- traefik.http.routers.picpeak.tls=true
- traefik.http.routers.picpeak.tls.certresolver=dns
- traefik.http.services.picpeak.loadbalancer.server.port=80
# Lower priority than backend to ensure /api routes go to backend
- traefik.http.routers.picpeak.priority=10
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:
- '/mnt/DockerMount/picpeak/db:/var/lib/postgresql/data'
# Mount init script to create umami database
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro
networks:
- picpeak
command: postgres -c ssl=off
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
# Optional: Umami analytics
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
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.picpeak-umami.rule=Host(`analytics.picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak-umami.entrypoints=https
- traefik.http.routers.picpeak-umami.tls=true
- traefik.http.routers.picpeak-umami.tls.certresolver=dns
- traefik.http.services.picpeak-umami.loadbalancer.server.port=3000
networks:
proxy:
external: true
picpeak:
driver: bridge
+147
View File
@@ -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}";
+65
View File
@@ -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;"]
+22
View File
@@ -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
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.9",
"version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.9",
"version": "1.0.20",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.9",
"version": "1.0.20",
"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
+1 -1
View File
@@ -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',
},
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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);
}
};
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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}`);
}
};
+4 -4
View File
@@ -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;
+7 -7
View File
@@ -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;
+10 -10
View File
@@ -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;
},
};
+6 -6
View File
@@ -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;
},
+6 -6
View File
@@ -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'
});
+7 -7
View File
@@ -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;
},
+2 -2
View File
@@ -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}`;
}