Compare commits

...

8 Commits

Author SHA1 Message Date
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
24 changed files with 3878 additions and 42 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"`
+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
}
}
}
File diff suppressed because it is too large Load Diff
+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
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.12",
"version": "1.0.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.12",
"version": "1.0.16",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.12",
"version": "1.0.16",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+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();
+2 -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');
@@ -152,7 +152,7 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
// Health check endpoint
app.get('/api/health', async (req, res) => {
app.get('/health', async (req, res) => {
try {
// Check database connectivity
await db.raw('SELECT 1');
+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();
+57 -17
View File
@@ -193,20 +193,34 @@ 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) : [],
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 +251,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)
+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
+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}";
+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.12",
"version": "1.0.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.12",
"version": "1.0.16",
"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.12",
"version": "1.0.16",
"type": "module",
"scripts": {
"dev": "vite",