Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8df2780e | |||
| 419a283c62 | |||
| 1b075a4beb | |||
| 68ff93cf17 | |||
| b22a29e877 | |||
| e601311ca3 | |||
| bb00c3993b | |||
| c9c0de46bf | |||
| 5374299cd5 | |||
| 536e2b2874 | |||
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb | |||
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e | |||
| c844f634c8 | |||
| 1d94398e2d | |||
| a2551dc0ad | |||
| 32821934e6 | |||
| b9c28e52cd | |||
| c94b6268cf | |||
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d | |||
| 0a2b010332 | |||
| 6906c8bcf7 | |||
| ac48bfdd0d | |||
| ec99243b6f | |||
| 9932621e14 | |||
| 0fb17c78fa | |||
| 4bcca58a11 | |||
| 9fa5ba1cf7 | |||
| 88919fa0d3 | |||
| 5e43fc9cd9 | |||
| f053f42b6d | |||
| dc17e7d59d | |||
| f05ad87602 | |||
| 2efc74a687 | |||
| 85e7fbe73f | |||
| 0a21856a8d | |||
| 349e7c7eb1 | |||
| be07438915 | |||
| b31ae72153 | |||
| 1db08b1e9b | |||
| 237a3332cc | |||
| e2d0a83d51 | |||
| c546657285 | |||
| e8d5ee1a7b | |||
| 1761ebd531 | |||
| 5e5e98601f | |||
| 1cda80792b | |||
| 8740d5e618 | |||
| a619d52d17 | |||
| dd8cc14d30 | |||
| 3b7d723c2a | |||
| dc6252ff56 | |||
| 1d4e79a4f9 | |||
| 0b0e3e22d2 | |||
| f22e3c133f | |||
| 64c0a58f78 | |||
| 4182089c17 | |||
| cecf773fb7 | |||
| 973af17b85 | |||
| 6c3e88a588 | |||
| 97bbb3c8e1 | |||
| ac1cd96ecd | |||
| 689861f671 | |||
| 41fb575e80 | |||
| 6ebc4f3fc4 | |||
| de973f5613 | |||
| 279c70b3d6 | |||
| 5a73f6963f | |||
| 5101a05bca | |||
| c82caf6539 |
@@ -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*
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
- feature/*
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- tag
|
||||
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
steps:
|
||||
# Frontend Tests
|
||||
- name: frontend-test
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm ci --legacy-peer-deps
|
||||
- npm run lint
|
||||
- npm run build
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
# Backend Tests
|
||||
- name: backend-test
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- cd backend
|
||||
- npm ci
|
||||
- npm run lint
|
||||
- npm test
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_TAG}
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend
|
||||
registry: registry.local.nothaft.cloud
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_TAG}
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend
|
||||
registry: registry.local.nothaft.cloud
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Security Scan
|
||||
- name: security-scan
|
||||
image: aquasec/trivy:latest
|
||||
commands:
|
||||
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-frontend:${DRONE_COMMIT_SHA:0:8}
|
||||
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-backend:${DRONE_COMMIT_SHA:0:8}
|
||||
environment:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
|
||||
# Deploy to Staging
|
||||
- name: deploy-staging
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: staging_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: staging_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: staging_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
VERSION: ${DRONE_COMMIT_SHA:0:8}
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=$VERSION
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing
|
||||
EOF
|
||||
when:
|
||||
branch:
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
|
||||
# Deploy to Production
|
||||
- name: deploy-production
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: prod_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: prod_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: prod_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
VERSION: ${DRONE_TAG:-latest}
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=$VERSION
|
||||
|
||||
# Backup database before deployment
|
||||
docker exec \$(docker ps -q -f name=wedding-photo-sharing_db) pg_dump -U postgres wedding_photo_sharing > /backup/db-backup-\$(date +%Y%m%d-%H%M%S).sql
|
||||
|
||||
# Deploy stack
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
|
||||
|
||||
# Wait for services to be ready
|
||||
sleep 30
|
||||
|
||||
# Run migrations if needed
|
||||
docker exec \$(docker ps -q -f name=wedding-photo-sharing_backend) npm run migrate
|
||||
EOF
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
# Health Check
|
||||
- name: health-check
|
||||
image: alpine:latest
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sleep 30
|
||||
- curl -f https://${FRONTEND_HOST}/health || exit 1
|
||||
- curl -f https://${BACKEND_HOST}/api/health || exit 1
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Notification - Success
|
||||
- name: notify-success
|
||||
image: plugins/slack
|
||||
settings:
|
||||
webhook:
|
||||
from_secret: slack_webhook
|
||||
channel: deployments
|
||||
template: |
|
||||
✅ *Build {{build.number}} succeeded* for {{repo.name}}
|
||||
|
||||
Branch: {{build.branch}}
|
||||
Commit: {{build.commit}}
|
||||
Author: {{build.author}}
|
||||
|
||||
{{#if build.tag}}
|
||||
🏷️ Tag: {{build.tag}}
|
||||
🚀 Deployed to *PRODUCTION*
|
||||
{{else}}
|
||||
📦 Deployed to *{{build.branch}}*
|
||||
{{/if}}
|
||||
|
||||
🔗 {{build.link}}
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
|
||||
# Notification - Failure
|
||||
- name: notify-failure
|
||||
image: plugins/slack
|
||||
settings:
|
||||
webhook:
|
||||
from_secret: slack_webhook
|
||||
channel: deployments
|
||||
template: |
|
||||
❌ *Build {{build.number}} failed* for {{repo.name}}
|
||||
|
||||
Branch: {{build.branch}}
|
||||
Commit: {{build.commit}}
|
||||
Author: {{build.author}}
|
||||
|
||||
🔗 {{build.link}}
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: rollback
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- rollback
|
||||
|
||||
steps:
|
||||
- name: rollback-production
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: prod_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: prod_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: prod_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=${DRONE_ROLLBACK_TO}
|
||||
|
||||
# Deploy previous version
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
|
||||
EOF
|
||||
|
||||
---
|
||||
kind: secret
|
||||
name: slack_webhook
|
||||
get:
|
||||
path: drone/slack
|
||||
name: webhook
|
||||
+35
-23
@@ -1,32 +1,44 @@
|
||||
# Production Environment Configuration Template
|
||||
# Copy this file to .env and fill in your values
|
||||
# PicPeak Production Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# Application URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
# Required: Security
|
||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
|
||||
|
||||
# Security - CRITICAL: Generate a secure random JWT secret
|
||||
# You can generate one with: openssl rand -base64 32
|
||||
JWT_SECRET=your-secure-random-jwt-secret-here
|
||||
# Required: URLs (update with your domain)
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
ADMIN_URL=https://your-domain.com
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration
|
||||
# Required: Email Settings
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=your-random-hash-salt
|
||||
# Required: Initial Admin Account
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
|
||||
# First Admin User (for initial setup)
|
||||
# Run: docker-compose exec backend node scripts/create-admin.js --email admin@yourdomain.com
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
# Database (PostgreSQL recommended for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-database-password
|
||||
|
||||
# Optional: Customization
|
||||
SITE_NAME=PicPeak
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
SESSION_TIMEOUT_MINUTES=60
|
||||
|
||||
# Optional: Analytics (Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
# Advanced: Performance Tuning
|
||||
NODE_ENV=production
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
@@ -0,0 +1,12 @@
|
||||
# Files to exclude from GitHub mirror
|
||||
.env* export-ignore
|
||||
docker-compose.prod.yml export-ignore
|
||||
.claudedocs/ export-ignore
|
||||
backend/data/ export-ignore
|
||||
backend/storage/ export-ignore
|
||||
backend/.env* export-ignore
|
||||
frontend/.env* export-ignore
|
||||
secrets/ export-ignore
|
||||
*.key export-ignore
|
||||
*.pem export-ignore
|
||||
.gitea/ export-ignore
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for mirroring
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Debug - Show current branch and status
|
||||
run: |
|
||||
echo "Current branch:"
|
||||
git branch -a
|
||||
echo "Git status:"
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
|
||||
- name: Create filtered branch
|
||||
run: |
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a new branch for GitHub
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Remove sensitive files/directories
|
||||
# Example: Remove .env files, private configs, etc.
|
||||
git rm -r --cached .env* || true
|
||||
git rm -r --cached backend/.env* || true
|
||||
git rm -r --cached frontend/.env* || true
|
||||
git rm -r --cached docker-compose.prod.yml || true
|
||||
git rm -r --cached .claudedocs/ || true
|
||||
git rm -r --cached backend/data/ || true
|
||||
git rm -r --cached backend/storage/ || true
|
||||
git rm -r --cached .gitea/ || true
|
||||
git rm -r --cached scripts/install-gitea-runner.sh || true
|
||||
git rm -r --cached .drone* || true
|
||||
git rm -r --cached .github-mirror-exclude || true
|
||||
git rm -r --cached .gitattributes-github || true
|
||||
git rm -r --cached photo-sharing-prd.md || true
|
||||
git rm -r --cached CLAUDE.md || true
|
||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
git rm -r --cached logs/ || true
|
||||
git rm -r --cached frontend/.claudedocs/ || true
|
||||
git rm -r --cached test-maintenance.sh || true
|
||||
git rm -r --cached storage/ || true
|
||||
|
||||
|
||||
# Commit the changes
|
||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the filtered branch to GitHub main
|
||||
echo "Pushing to GitHub..."
|
||||
git push github github-mirror:main --force
|
||||
echo "Push completed successfully!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -30,15 +31,104 @@ jobs:
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Bump version
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
# Get current version from backend package.json
|
||||
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
set -e # Exit on error
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
@@ -49,14 +139,23 @@ jobs:
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update version in package.json files
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
@@ -65,15 +164,36 @@ jobs:
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
git push
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
@@ -84,5 +204,6 @@ jobs:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -0,0 +1,24 @@
|
||||
# Exclude patterns for GitHub mirror
|
||||
.env
|
||||
.env.*
|
||||
.env*
|
||||
docker-compose.prod.yml
|
||||
docker-compose.traefik.yml
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/storage/
|
||||
backend/.env*
|
||||
frontend/.env*
|
||||
secrets/
|
||||
*.key
|
||||
*.pem
|
||||
.gitea/
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.DS_Store
|
||||
deploy/
|
||||
certbot/
|
||||
nginx/
|
||||
photo-sharing-prd.md
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve PicPeak
|
||||
title: '[BUG] '
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. Ubuntu 22.04]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- PicPeak Version: [e.g. 1.0.22]
|
||||
- Deployment Method: [e.g. Docker Compose, Manual]
|
||||
- Database: [e.g. PostgreSQL 15, SQLite]
|
||||
|
||||
**Logs**
|
||||
Please include relevant logs:
|
||||
```
|
||||
# Backend logs
|
||||
docker-compose logs backend | tail -50
|
||||
|
||||
# Frontend console errors
|
||||
[paste any browser console errors]
|
||||
```
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible Solution**
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
|
||||
about: Please read the documentation before opening an issue
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/the-luap/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Documentation
|
||||
about: Report issues or improvements needed in documentation
|
||||
title: '[DOCS] '
|
||||
labels: 'documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What documentation needs improvement?**
|
||||
Please specify which document or section needs attention:
|
||||
- [ ] README.md
|
||||
- [ ] DEPLOYMENT.md
|
||||
- [ ] CONTRIBUTING.md
|
||||
- [ ] API Documentation
|
||||
- [ ] Code Comments
|
||||
- [ ] Other: ___________
|
||||
|
||||
**Describe the issue**
|
||||
What's wrong or missing in the documentation?
|
||||
|
||||
**Suggested improvement**
|
||||
How would you improve this documentation?
|
||||
|
||||
**Target audience**
|
||||
Who is this documentation for?
|
||||
- [ ] New users setting up PicPeak
|
||||
- [ ] Developers contributing to the project
|
||||
- [ ] System administrators
|
||||
- [ ] End users (photographers/clients)
|
||||
|
||||
**Additional context**
|
||||
Add any other context, examples, or references here.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for PicPeak
|
||||
title: '[FEATURE] '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use Case**
|
||||
Please describe how this feature would be used:
|
||||
- Who would use it? (photographers, clients, admins)
|
||||
- When would they use it?
|
||||
- Why is it important?
|
||||
|
||||
**Similar Features**
|
||||
Are there similar features in:
|
||||
- PicDrop
|
||||
- Scrapbook.de
|
||||
- Other photo sharing platforms
|
||||
|
||||
**Mockups or Examples**
|
||||
If applicable, add mockups, diagrams, or links to similar implementations.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Implementation Ideas**
|
||||
If you have technical ideas about how this could be implemented, please share them.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question about PicPeak
|
||||
title: '[QUESTION] '
|
||||
labels: 'question'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Question**
|
||||
What would you like to know about PicPeak?
|
||||
|
||||
**Context**
|
||||
Please provide context to help us answer your question better:
|
||||
- What are you trying to achieve?
|
||||
- What have you already tried?
|
||||
- Which documentation have you consulted?
|
||||
|
||||
**Environment**
|
||||
If relevant to your question:
|
||||
- PicPeak Version:
|
||||
- Deployment Method:
|
||||
- Operating System:
|
||||
|
||||
**Related Issues or Discussions**
|
||||
Link to any related issues, discussions, or documentation.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Security Vulnerability
|
||||
about: Report security issues privately
|
||||
title: '[SECURITY] '
|
||||
labels: 'security'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please email security@example.com with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
**Type of Security Issue**
|
||||
- [ ] Authentication/Authorization
|
||||
- [ ] Data Exposure
|
||||
- [ ] Input Validation
|
||||
- [ ] Configuration Issue
|
||||
- [ ] Dependency Vulnerability
|
||||
- [ ] Other: ___________
|
||||
|
||||
**Description**
|
||||
Brief description of the security concern.
|
||||
|
||||
**Impact**
|
||||
What could an attacker potentially do?
|
||||
|
||||
**Steps to Reproduce**
|
||||
If applicable, how can this be reproduced?
|
||||
|
||||
**Suggested Fix**
|
||||
If you have ideas on how to fix this issue.
|
||||
|
||||
**References**
|
||||
Any relevant security advisories, CVEs, or documentation.
|
||||
@@ -0,0 +1,49 @@
|
||||
## Description
|
||||
|
||||
Please include a summary of the changes and which issue is fixed. Include relevant motivation and context.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
|
||||
|
||||
- [ ] Unit tests pass (`npm test`)
|
||||
- [ ] Manual testing completed
|
||||
- [ ] Tested on Docker deployment
|
||||
- [ ] Tested on production-like environment
|
||||
|
||||
**Test Configuration**:
|
||||
* PicPeak Version:
|
||||
* Node.js Version:
|
||||
* Database: PostgreSQL / SQLite
|
||||
* Browser:
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
- [ ] I have updated the CHANGELOG.md file
|
||||
|
||||
## Screenshots (if appropriate):
|
||||
|
||||
## Additional Notes:
|
||||
|
||||
Add any additional notes, concerns, or discussion points here.
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'frontend/package.json'
|
||||
- 'backend/package.json'
|
||||
|
||||
jobs:
|
||||
check-version-change:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_changed: ${{ steps.check.outputs.changed }}
|
||||
new_version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
# Get current versions
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version")
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
|
||||
# Get previous versions
|
||||
git checkout HEAD~1
|
||||
PREV_FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||
PREV_BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Check if versions changed
|
||||
if [[ "$FRONTEND_VERSION" != "$PREV_FRONTEND_VERSION" ]] || [[ "$BACKEND_VERSION" != "$PREV_BACKEND_VERSION" ]]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "version=$FRONTEND_VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-release:
|
||||
needs: check-version-change
|
||||
if: needs.check-version-change.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get commits since last tag
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
COMMITS=$(git log --oneline)
|
||||
else
|
||||
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
|
||||
fi
|
||||
|
||||
# Format changelog
|
||||
echo "## What's Changed" > changelog.md
|
||||
echo "" >> changelog.md
|
||||
|
||||
# Group commits by type
|
||||
echo "### Features" >> changelog.md
|
||||
echo "$COMMITS" | grep -E "^[a-f0-9]+ feat:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No new features*" >> changelog.md
|
||||
|
||||
echo "" >> changelog.md
|
||||
echo "### Bug Fixes" >> changelog.md
|
||||
echo "$COMMITS" | grep -E "^[a-f0-9]+ fix:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No bug fixes*" >> changelog.md
|
||||
|
||||
echo "" >> changelog.md
|
||||
echo "### Other Changes" >> changelog.md
|
||||
echo "$COMMITS" | grep -vE "^[a-f0-9]+ (feat|fix):" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No other changes*" >> changelog.md
|
||||
|
||||
# Save changelog
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
cat changelog.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ needs.check-version-change.outputs.new_version }}
|
||||
name: Release v${{ needs.check-version-change.outputs.new_version }}
|
||||
body: |
|
||||
## PicPeak v${{ needs.check-version-change.outputs.new_version }}
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
### Docker Images
|
||||
|
||||
To use this release with Docker:
|
||||
```bash
|
||||
docker pull ghcr.io/${{ github.repository }}/frontend:v${{ needs.check-version-change.outputs.new_version }}
|
||||
docker pull ghcr.io/${{ github.repository }}/backend:v${{ needs.check-version-change.outputs.new_version }}
|
||||
```
|
||||
|
||||
Or use the `latest` tag for the most recent version.
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
@@ -1,107 +0,0 @@
|
||||
name: Automatic Version Bump
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions Bot"
|
||||
git config --global user.email "actions@github.com"
|
||||
|
||||
- name: Determine version type
|
||||
id: version_type
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "type=${{ github.event.inputs.version_type }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Auto-detect version type based on commit message
|
||||
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||
if [[ "$COMMIT_MSG" == *"BREAKING CHANGE"* ]] || [[ "$COMMIT_MSG" == *"!"* ]]; then
|
||||
echo "type=major" >> $GITHUB_OUTPUT
|
||||
elif [[ "$COMMIT_MSG" == *"feat:"* ]] || [[ "$COMMIT_MSG" == *"feat("* ]]; then
|
||||
echo "type=minor" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=patch" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Bump Frontend Version
|
||||
id: frontend_version
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bump Backend Version
|
||||
id: backend_version
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update Frontend VersionInfo component
|
||||
run: |
|
||||
VERSION=${{ steps.frontend_version.outputs.version }}
|
||||
sed -i "s/const FRONTEND_VERSION = '[^']*'/const FRONTEND_VERSION = '$VERSION'/" frontend/src/components/admin/VersionInfo.tsx
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||
title: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||
body: |
|
||||
## Version Bump
|
||||
|
||||
This PR automatically bumps the version numbers:
|
||||
- Frontend: `${{ steps.frontend_version.outputs.version }}`
|
||||
- Backend: `${{ steps.backend_version.outputs.version }}`
|
||||
|
||||
### Version Type: ${{ steps.version_type.outputs.type }}
|
||||
|
||||
### Files Changed:
|
||||
- `frontend/package.json`
|
||||
- `backend/package.json`
|
||||
- `frontend/src/components/admin/VersionInfo.tsx`
|
||||
|
||||
---
|
||||
*This PR was automatically created by the version bump workflow.*
|
||||
branch: version-bump-${{ steps.frontend_version.outputs.version }}
|
||||
delete-branch: true
|
||||
labels: |
|
||||
version-bump
|
||||
automated
|
||||
@@ -1,118 +0,0 @@
|
||||
# CI/CD Strategy for PicPeak
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the CI/CD strategy using both Gitea Actions and Drone CI to avoid conflicts and ensure proper versioning.
|
||||
|
||||
## Pipeline Flow
|
||||
|
||||
### 1. Development & Testing (Gitea Actions)
|
||||
- **Trigger**: Every push to `main` or `develop` branches
|
||||
- **File**: `.gitea/workflows/test.yml`
|
||||
- **Purpose**: Run tests, linting, and basic validation
|
||||
- **Actions**:
|
||||
- Backend linting and tests
|
||||
- Frontend linting and build
|
||||
- Does NOT build Docker images
|
||||
|
||||
### 2. Version Management (Gitea Actions)
|
||||
- **Trigger**: Push to `main` branch (excluding markdown files)
|
||||
- **File**: `.gitea/workflows/version-and-release.yml`
|
||||
- **Purpose**: Automatic version incrementing
|
||||
- **Actions**:
|
||||
1. Reads current version from `package.json`
|
||||
2. Increments patch version (e.g., 1.0.0 → 1.0.1)
|
||||
3. Updates both backend and frontend `package.json`
|
||||
4. Commits the version change
|
||||
5. Creates a git tag (e.g., `v1.0.1`)
|
||||
6. Pushes changes and tag
|
||||
|
||||
### 3. Docker Image Building (Drone CI)
|
||||
- **Trigger**:
|
||||
- Push to `main` or `develop` (builds with commit SHA)
|
||||
- New git tags (builds release versions)
|
||||
- **File**: `.drone.yml`
|
||||
- **Purpose**: Build and push Docker images
|
||||
- **Tags Created**:
|
||||
- `latest` - Always points to newest build
|
||||
- `{commit-sha}` - Specific commit version
|
||||
- `{branch}-latest` - Latest for specific branch
|
||||
- `v1.0.1` - Specific version (on tag trigger)
|
||||
|
||||
## Why This Strategy?
|
||||
|
||||
1. **Separation of Concerns**:
|
||||
- Gitea Actions handles code quality and versioning
|
||||
- Drone CI handles Docker image building
|
||||
- No overlap or race conditions
|
||||
|
||||
2. **Sequential Execution**:
|
||||
- Version bump happens first
|
||||
- Tag creation triggers Drone
|
||||
- Docker images are built with correct version
|
||||
|
||||
3. **Version Consistency**:
|
||||
- Version in `package.json` matches git tag
|
||||
- Docker images are tagged with same version
|
||||
- No manual version management needed
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
1. **Gitea Actions Runner**: Must be configured and running
|
||||
2. **Drone CI**: Must be connected to your Gitea instance
|
||||
3. **Secrets**:
|
||||
- `GITEA_TOKEN` (optional, for pushing version commits)
|
||||
- Docker registry credentials in Drone
|
||||
|
||||
## Version Numbering
|
||||
|
||||
- Format: `MAJOR.MINOR.PATCH` (e.g., 1.0.0)
|
||||
- Automatic increments: PATCH version only
|
||||
- Manual increments: Edit `package.json` for MAJOR/MINOR changes
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Regular Development**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
- Tests run automatically
|
||||
- Version bumps to 1.0.1
|
||||
- Docker images built with v1.0.1 tag
|
||||
|
||||
2. **Major/Minor Version Change**:
|
||||
```bash
|
||||
# Manually edit package.json files to 2.0.0
|
||||
git add .
|
||||
git commit -m "feat!: major release"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **Skip Version Bump**:
|
||||
- Add `[skip ci]` to commit message
|
||||
- Or only change markdown files
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Gitea Actions**: Check Actions tab in Gitea
|
||||
- **Drone CI**: Check Drone dashboard
|
||||
- **Docker Registry**: Verify images are pushed with correct tags
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Version not incrementing**:
|
||||
- Check Gitea Actions logs
|
||||
- Ensure runner has push permissions
|
||||
- Verify no `[skip ci]` in commit message
|
||||
|
||||
2. **Docker images not building**:
|
||||
- Check Drone CI webhook configuration
|
||||
- Verify Drone can see the repository
|
||||
- Check Docker registry credentials
|
||||
|
||||
3. **Conflicts**:
|
||||
- Never run both pipelines for same task
|
||||
- Use branch protection to prevent direct pushes
|
||||
- Always let automation handle versioning
|
||||
@@ -0,0 +1,27 @@
|
||||
# PicPeak Community Guidelines
|
||||
|
||||
## Our Commitment
|
||||
|
||||
We are committed to providing a welcoming and inspiring community for all photographers and developers.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
* Be respectful and considerate
|
||||
* Welcome newcomers and help them get started
|
||||
* Focus on what is best for the community
|
||||
* Show empathy towards other community members
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
* Trolling or insulting comments
|
||||
* Personal attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from contributor-covenant.org, version 2.0.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# Contributing to PicPeak
|
||||
|
||||
First off, thank you for considering contributing to PicPeak! It's people like you that make PicPeak such a great tool for photographers worldwide.
|
||||
|
||||
## 🤝 Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the [PicPeak Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
|
||||
|
||||
## 🎯 How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
|
||||
|
||||
* **Use a clear and descriptive title**
|
||||
* **Describe the exact steps to reproduce the problem**
|
||||
* **Provide specific examples to demonstrate the steps**
|
||||
* **Describe the behavior you observed and what you expected**
|
||||
* **Include screenshots if possible**
|
||||
* **Include your environment details** (OS, browser, Docker version, etc.)
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
|
||||
|
||||
* **Use a clear and descriptive title**
|
||||
* **Provide a detailed description of the suggested enhancement**
|
||||
* **Provide specific examples to demonstrate the enhancement**
|
||||
* **Describe the current behavior and expected behavior**
|
||||
* **Explain why this enhancement would be useful**
|
||||
|
||||
### Your First Code Contribution
|
||||
|
||||
Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
|
||||
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main`
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
```
|
||||
3. **Make your changes** and ensure:
|
||||
- Code follows the existing style
|
||||
- Tests pass: `npm test`
|
||||
- Linting passes: `npm run lint`
|
||||
4. **Write tests** if you've added code
|
||||
5. **Update documentation** if needed
|
||||
6. **Create a Pull Request**
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone https://github.com/your-username/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Install dependencies
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Start development servers
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend && npm test
|
||||
|
||||
# Frontend tests
|
||||
cd frontend && npm test
|
||||
|
||||
# E2E tests
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 📝 Styleguides
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
* Use the present tense ("Add feature" not "Added feature")
|
||||
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
|
||||
* Limit the first line to 72 characters or less
|
||||
* Reference issues and pull requests liberally after the first line
|
||||
* Consider starting the commit message with an applicable emoji:
|
||||
* 🎨 `:art:` when improving the format/structure of the code
|
||||
* 🐛 `:bug:` when fixing a bug
|
||||
* 🔥 `:fire:` when removing code or files
|
||||
* 📝 `:memo:` when writing docs
|
||||
* 🚀 `:rocket:` when improving performance
|
||||
* ✨ `:sparkles:` when adding a new feature
|
||||
|
||||
### JavaScript/TypeScript Styleguide
|
||||
|
||||
* Use ES6+ features
|
||||
* Prefer async/await over promises
|
||||
* Use meaningful variable names
|
||||
* Add JSDoc comments for functions
|
||||
* Follow ESLint rules
|
||||
|
||||
### React Styleguide
|
||||
|
||||
* Use functional components with hooks
|
||||
* Keep components small and focused
|
||||
* Use TypeScript for type safety
|
||||
* Follow the existing folder structure
|
||||
* Write tests for new components
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
picpeak/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ └── utils/ # Utilities
|
||||
│ └── migrations/ # Database migrations
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── services/ # API services
|
||||
│ │ └── hooks/ # Custom hooks
|
||||
│ └── public/ # Static assets
|
||||
```
|
||||
|
||||
## 🔄 Release Process
|
||||
|
||||
1. Update version numbers in package.json files
|
||||
2. Update CHANGELOG.md
|
||||
3. Create a new release on GitHub
|
||||
4. Docker images are automatically built and published
|
||||
|
||||
## 📮 Contact
|
||||
|
||||
- Create an issue for bugs or features
|
||||
- Join discussions for questions
|
||||
- Email: picpeak@example.com for security issues
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
+162
-288
@@ -1,346 +1,220 @@
|
||||
# PicPeak Deployment Guide
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers deploying PicPeak for development and production environments.
|
||||
This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes.
|
||||
|
||||
## Table of Contents
|
||||
- [Quick Start (Development)](#quick-start-development)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [Admin User Setup](#admin-user-setup)
|
||||
- [Configuration Reference](#configuration-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
## 📋 Prerequisites
|
||||
|
||||
## Quick Start (Development)
|
||||
- A server with Docker and Docker Compose installed
|
||||
- A domain name (for SSL certificates)
|
||||
- SMTP credentials for sending emails
|
||||
- Basic command line knowledge
|
||||
|
||||
### 1. Clone and Setup
|
||||
## 🏃 Quick Deploy (Recommended)
|
||||
|
||||
### 1. Clone and Configure
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/picpeak.git
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### 2. Access Services
|
||||
|
||||
- Frontend: http://localhost:3005
|
||||
- Backend API: http://localhost:3001
|
||||
- MailHog (email testing): http://localhost:8025
|
||||
|
||||
### 3. Create Admin User
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@localhost \
|
||||
--username admin \
|
||||
--password admin123
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain with DNS configured
|
||||
- SSL/TLS handled by reverse proxy (Traefik, Nginx, etc.)
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
```bash
|
||||
# Copy production template
|
||||
cp .env.production.example .env
|
||||
|
||||
# Generate secure secrets
|
||||
# Generate a secure JWT secret
|
||||
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
|
||||
# Edit configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
### 2. Required Environment Variables
|
||||
|
||||
Edit your `.env` file with these essential settings:
|
||||
|
||||
```env
|
||||
# Your domain
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
# Application URLs
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
# DB_PASSWORD already generated above
|
||||
|
||||
# Email
|
||||
# Email Configuration (Required for notifications)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
```
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
### 2. Frontend Configuration
|
||||
# Admin Configuration
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
```bash
|
||||
# Configure frontend for production
|
||||
echo "VITE_API_URL=/api" > frontend/.env.production
|
||||
# Database (PostgreSQL for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-db-password
|
||||
```
|
||||
|
||||
### 3. Deploy with Docker Compose
|
||||
|
||||
```bash
|
||||
# Build and start services
|
||||
# Start all services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
# Access your site at https://your-domain.com
|
||||
```
|
||||
|
||||
### 4. Deploy with Traefik
|
||||
## 🔧 Configuration Options
|
||||
|
||||
If using Traefik, create `docker-compose.override.yml`:
|
||||
### Storage Settings
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
```env
|
||||
# Storage paths (default: ./storage)
|
||||
STORAGE_PATH=./storage
|
||||
ARCHIVE_PATH=./storage/archives
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
networks:
|
||||
- traefik
|
||||
- picpeak
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
# Gallery expiration (days)
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
||||
```
|
||||
|
||||
## Admin User Setup
|
||||
### Security Settings
|
||||
|
||||
### Create First Admin
|
||||
```env
|
||||
# Session timeout (minutes)
|
||||
SESSION_TIMEOUT=60
|
||||
|
||||
After deployment, create your admin user:
|
||||
# Rate limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
### Analytics (Optional)
|
||||
|
||||
```env
|
||||
# Umami Analytics
|
||||
VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
## 🔒 SSL/TLS Setup
|
||||
|
||||
The production Docker Compose includes automatic SSL via Let's Encrypt:
|
||||
|
||||
1. **Ensure your domain points to your server**
|
||||
2. **Update nginx configuration**:
|
||||
```bash
|
||||
nano nginx/nginx.conf
|
||||
# Replace your-domain.com with your actual domain
|
||||
```
|
||||
3. **Start services** - Certbot will automatically obtain certificates
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
After deployment, your directory structure will be:
|
||||
|
||||
```
|
||||
picpeak/
|
||||
├── backend/ # API server
|
||||
├── frontend/ # React app
|
||||
├── storage/ # Photo storage
|
||||
│ ├── events/ # Active galleries
|
||||
│ │ ├── active/ # Current photos
|
||||
│ │ └── archived/ # Expired galleries
|
||||
│ ├── thumbnails/ # Generated thumbnails
|
||||
│ └── uploads/ # User uploads
|
||||
├── data/ # Database files
|
||||
└── logs/ # Application logs
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com \
|
||||
--username admin \
|
||||
--password yourSecurePassword
|
||||
# Backup database and photos
|
||||
./scripts/backup.sh
|
||||
|
||||
# Auto-generate password
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com
|
||||
# Backups are stored in ./backups/
|
||||
```
|
||||
|
||||
The script will display:
|
||||
- ✅ Admin user created successfully!
|
||||
- Email: admin@yourdomain.com
|
||||
- Username: admin
|
||||
- Login URL: https://yourdomain.com/admin/login
|
||||
- Password: (save this if auto-generated!)
|
||||
|
||||
### Managing Admin Users
|
||||
|
||||
```bash
|
||||
# List admin users
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT id, username, email, is_active, last_login FROM admin_users;"
|
||||
|
||||
# Deactivate user
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "UPDATE admin_users SET is_active = false WHERE email = 'user@example.com';"
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Database Configuration
|
||||
|
||||
PicPeak automatically detects the environment and uses:
|
||||
- **Development**: SQLite (`./data/photo_sharing.db`)
|
||||
- **Production**: PostgreSQL (configured via environment variables)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Required for Production
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `JWT_SECRET` | JWT signing key | `openssl rand -base64 32` |
|
||||
| `DB_PASSWORD` | PostgreSQL password | `openssl rand -base64 24` |
|
||||
| `ADMIN_URL` | Admin panel URL | `https://yourdomain.com` |
|
||||
| `FRONTEND_URL` | Frontend URL | `https://yourdomain.com` |
|
||||
| `EMAIL_FROM` | Sender email | `noreply@yourdomain.com` |
|
||||
|
||||
#### Email Configuration
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `SMTP_HOST` | SMTP server | `smtp.gmail.com` |
|
||||
| `SMTP_PORT` | SMTP port | `587` |
|
||||
| `SMTP_SECURE` | Use TLS | `true` |
|
||||
| `SMTP_USER` | SMTP username | `your-email@gmail.com` |
|
||||
| `SMTP_PASS` | SMTP password | App-specific password |
|
||||
|
||||
### Storage Paths
|
||||
|
||||
- Photos: `./storage/events/active/`
|
||||
- Archives: `./storage/events/archived/`
|
||||
- Thumbnails: `./storage/thumbnails/`
|
||||
- Uploads: `./storage/uploads/`
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
# PostgreSQL backup
|
||||
docker-compose -f docker-compose.prod.yml exec db \
|
||||
pg_dump -U picpeak picpeak > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Backup storage
|
||||
tar -czf storage-backup-$(date +%Y%m%d).tar.gz ./storage
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
# PostgreSQL restore
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
psql -U picpeak picpeak < backup-20240115.sql
|
||||
|
||||
# Restore storage
|
||||
tar -xzf storage-backup-20240115.tar.gz
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl https://yourdomain.com/api/health
|
||||
|
||||
# Frontend health
|
||||
curl https://yourdomain.com/health
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=100 backend
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Won't Start
|
||||
|
||||
1. Check database connection:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs db
|
||||
```
|
||||
|
||||
2. Verify environment variables:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend env | grep DB_
|
||||
```
|
||||
|
||||
### Can't Login as Admin
|
||||
|
||||
1. Verify admin user exists:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT * FROM admin_users;"
|
||||
```
|
||||
|
||||
2. Reset admin password:
|
||||
```bash
|
||||
# Create new admin with different email
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
node scripts/create-admin.js --email newadmin@yourdomain.com
|
||||
```
|
||||
|
||||
### Photos Not Loading
|
||||
|
||||
1. Check file permissions:
|
||||
```bash
|
||||
ls -la ./storage/events/active/
|
||||
```
|
||||
|
||||
2. Verify nginx proxy configuration:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec frontend \
|
||||
cat /etc/nginx/conf.d/default.conf
|
||||
```
|
||||
|
||||
### Email Not Sending
|
||||
|
||||
1. Check email configuration:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend env | grep SMTP_
|
||||
```
|
||||
|
||||
2. View email queue:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT * FROM email_queue WHERE status = 'failed';"
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Application
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Restart services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
# Rebuild and restart
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Remove unused images
|
||||
docker image prune -a
|
||||
# View all logs
|
||||
docker-compose logs
|
||||
|
||||
# Clean up logs
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=0 -f
|
||||
|
||||
# Remove old archives
|
||||
find ./storage/events/archived -name "*.zip" -mtime +90 -delete
|
||||
# View specific service
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
- [ ] Generated secure `JWT_SECRET`
|
||||
- [ ] Generated secure `DB_PASSWORD`
|
||||
- [ ] HTTPS enabled via reverse proxy
|
||||
- [ ] Changed default admin credentials
|
||||
- [ ] Configured real SMTP server
|
||||
- [ ] Set file permissions: `chmod 600 .env`
|
||||
- [ ] Firewall configured
|
||||
- [ ] Regular backups scheduled
|
||||
- [ ] Monitoring enabled
|
||||
### Common Issues
|
||||
|
||||
**Photos not appearing:**
|
||||
- Check storage permissions: `chmod -R 755 storage/`
|
||||
- Verify file watcher is running: `docker-compose logs backend | grep watcher`
|
||||
|
||||
**Email not sending:**
|
||||
- Test SMTP settings: Admin Panel → Settings → Email → Send Test
|
||||
- Check email queue: Admin Panel → System → Email Queue
|
||||
|
||||
**Can't access admin panel:**
|
||||
- Default login: Use email/password from `.env`
|
||||
- Reset password: `docker exec picpeak-backend npm run reset-admin`
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
|
||||
# Test backend API
|
||||
curl https://your-domain.com/api/health
|
||||
|
||||
# Check disk space
|
||||
df -h storage/
|
||||
```
|
||||
|
||||
## 🐳 Alternative Deployment Methods
|
||||
|
||||
### Using Docker Swarm
|
||||
|
||||
For high availability deployments, see [Docker Swarm Setup](deploy/README.md).
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer not to use Docker:
|
||||
|
||||
1. Install Node.js 18+
|
||||
2. Install PostgreSQL
|
||||
3. Clone repository
|
||||
4. Install dependencies: `npm install` in both `/backend` and `/frontend`
|
||||
5. Build frontend: `cd frontend && npm run build`
|
||||
6. Start services with PM2
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- 📘 [Documentation](https://github.com/the-luap/picpeak)
|
||||
- 🐛 [Report Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- 💬 [Discussions](https://github.com/the-luap/picpeak/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue on GitHub and we'll assist you!
|
||||
@@ -1,84 +0,0 @@
|
||||
# Production Deployment Fixes
|
||||
|
||||
This document describes the fixes applied to resolve production deployment issues in Docker.
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Database Connection Error: "getaddrinfo ENOTFOUND postgres"
|
||||
**Problem**: The backend was trying to connect to hostname "postgres" but the database service is named "db" in docker-compose.
|
||||
**Solution**:
|
||||
- Updated `knexfile.js` to use correct default host "db" instead of "postgres"
|
||||
- Added `depends_on: db` to backend service in docker-compose.prod.yml
|
||||
|
||||
### 2. Backend Starting Before Database Ready
|
||||
**Problem**: Backend service started before PostgreSQL was ready, causing connection failures.
|
||||
**Solution**:
|
||||
- Created `wait-for-db.sh` script that waits for PostgreSQL to be ready
|
||||
- Updated Dockerfile to install postgresql-client and use the wait script
|
||||
- Script also runs migrations automatically on startup
|
||||
|
||||
### 3. Email Processor Initialization Failure
|
||||
**Problem**: Email processor tried to initialize on module load before database was available.
|
||||
**Solution**:
|
||||
- Modified `emailProcessor.js` to export initialization functions
|
||||
- Updated `server.js` to call initialization after database is ready
|
||||
- Added proper error handling for email service initialization
|
||||
|
||||
### 4. Missing Environment Variables
|
||||
**Problem**: Critical storage path environment variables were missing.
|
||||
**Solution**:
|
||||
- Added STORAGE_PATH, EVENTS_PATH, and ARCHIVE_PATH to docker-compose.prod.yml
|
||||
- Created `.env.example` documenting all required environment variables
|
||||
|
||||
### 5. Enhanced Health Check
|
||||
**Problem**: Basic health check didn't verify database connectivity.
|
||||
**Solution**:
|
||||
- Updated `/api/health` endpoint to check database connection
|
||||
- Returns proper HTTP 503 status when unhealthy
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **backend/knexfile.js** - Fixed production database defaults
|
||||
2. **backend/wait-for-db.sh** - Created database wait script
|
||||
3. **backend/Dockerfile** - Added postgresql-client and wait script
|
||||
4. **docker-compose.prod.yml** - Added dependencies and environment variables
|
||||
5. **backend/src/services/emailProcessor.js** - Disabled auto-initialization
|
||||
6. **backend/server.js** - Added email initialization and improved health check
|
||||
7. **backend/.env.example** - Created environment variable documentation
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. Ensure all environment variables are set according to `.env.example`
|
||||
2. Build and deploy with docker-compose:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
3. The backend will now:
|
||||
- Wait for PostgreSQL to be ready
|
||||
- Run migrations automatically
|
||||
- Initialize all services in proper order
|
||||
- Provide health status at `/api/health`
|
||||
|
||||
## Verification
|
||||
|
||||
Check deployment health:
|
||||
```bash
|
||||
curl http://localhost/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"database": "connected",
|
||||
"timestamp": "2025-07-13T20:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Email Configuration
|
||||
|
||||
Email service requires configuration in the database. If email is not configured:
|
||||
- The service will log a warning but continue running
|
||||
- Emails will be queued but not sent
|
||||
- Configure email settings in the admin panel after deployment
|
||||
@@ -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/the-luap/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
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
# 🚀 Quick Local Development Setup
|
||||
|
||||
Get the photo sharing platform running locally in under 2 minutes!
|
||||
|
||||
## Prerequisites
|
||||
- Docker Desktop installed and running
|
||||
- Git
|
||||
- 4GB RAM available
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd picpeak
|
||||
|
||||
# 2. Start everything
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
## What You Get
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| Frontend (Dev) | http://localhost:3002 | React app with hot reload |
|
||||
| Frontend (Prod) | http://localhost:3000 | Production build |
|
||||
| Backend API | http://localhost:3001 | Express API |
|
||||
| Mailhog | http://localhost:8025 | Email testing UI |
|
||||
|
||||
## Default Credentials
|
||||
|
||||
- **Admin Login**: Check `ADMIN_CREDENTIALS.txt` after first setup
|
||||
- **Test Gallery**:
|
||||
- Create via Admin Panel
|
||||
- Set your own secure password
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml logs -f
|
||||
```
|
||||
|
||||
### Stop Everything
|
||||
```bash
|
||||
./stop-local.sh
|
||||
```
|
||||
|
||||
### Reset Database
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml exec backend npm run migrate
|
||||
```
|
||||
|
||||
### Add Test Photos
|
||||
1. Create a gallery in the admin panel
|
||||
2. Get the gallery slug (e.g., `wedding-smith-2024`)
|
||||
3. Add photos to: `./storage/events/active/wedding-smith-2024/`
|
||||
4. Photos appear automatically!
|
||||
|
||||
### Access Backend Shell
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml exec backend sh
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Frontend Development** (Port 3002)
|
||||
- Hot reload enabled
|
||||
- Edit files in `./frontend/src`
|
||||
- Changes appear instantly
|
||||
|
||||
2. **Backend Development** (Port 3001)
|
||||
- Nodemon watches for changes
|
||||
- Edit files in `./backend/src`
|
||||
- Server restarts automatically
|
||||
|
||||
3. **Email Testing**
|
||||
- All emails go to Mailhog
|
||||
- View at http://localhost:8025
|
||||
- No real emails sent!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.local.yml logs backend
|
||||
|
||||
# Rebuild
|
||||
docker-compose -f docker-compose.local.yml build backend
|
||||
```
|
||||
|
||||
### Frontend build issues
|
||||
```bash
|
||||
# Clear cache and rebuild
|
||||
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
|
||||
```
|
||||
|
||||
### Port conflicts
|
||||
Edit `docker-compose.local.yml` and change the port mappings:
|
||||
- Backend: Change `3001:3000` to `XXXX:3000`
|
||||
- Frontend: Change `3002:5173` to `YYYY:5173`
|
||||
|
||||
### Reset everything
|
||||
```bash
|
||||
# Stop and remove all data
|
||||
docker-compose -f docker-compose.local.yml down -v
|
||||
rm -rf data storage logs
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- 📧 Check Mailhog for all emails
|
||||
- 🔄 Frontend auto-refreshes on save
|
||||
- 📁 SQLite DB at `./data/photo_sharing.db`
|
||||
- 🖼️ Photos in `./storage/events/active/`
|
||||
- 📝 Logs in `./logs/`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create your first gallery via Admin Panel
|
||||
2. Upload some test photos
|
||||
3. Test the gallery with password
|
||||
4. Check expiration warnings
|
||||
5. View emails in Mailhog
|
||||
|
||||
Happy coding! 🎨
|
||||
@@ -1,32 +1,185 @@
|
||||
# Photo Sharing Platform
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management.
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
</div>
|
||||
|
||||
## Features
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||
- 🔒 Password Protected Galleries
|
||||
- ⏰ Automatic Expiration
|
||||
- 📧 Email Notifications
|
||||
- 📁 Simple File Management
|
||||
- 📊 Analytics Integration
|
||||
- 🎨 Customizable Themes
|
||||
- 📱 Mobile Responsive
|
||||
- ⚡ Docker Ready
|
||||

|
||||
|
||||
## Quick Start
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `./scripts/install.sh`
|
||||
3. Configure `.env` file
|
||||
4. Setup SSL: `./scripts/setup-ssl.sh`
|
||||
5. Start: `docker-compose -f docker-compose.prod.yml up -d`
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
Default credentials: Check ADMIN_CREDENTIALS.txt after first setup
|
||||
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
|
||||
- **🔒 Complete Data Control** - Your photos stay on your server
|
||||
- **🎨 White-Label Ready** - Full branding customization
|
||||
- **📱 Mobile-First Design** - Beautiful on all devices
|
||||
- **🚀 Lightning Fast** - Optimized performance and caching
|
||||
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
|
||||
|
||||
## Documentation
|
||||
## ✨ Key Features
|
||||
|
||||
See DEPLOYMENT.md for detailed deployment instructions.
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
|
||||
## License
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
- 📱 **Mobile Optimized** - Swipe through photos on any device
|
||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||
- 🔍 **Smart Search** - Find photos quickly
|
||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||
|
||||
MIT License
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:3005
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
- 💒 **Wedding Photographers** - Share ceremony photos securely
|
||||
- 🎂 **Event Photography** - Birthday parties, corporate events
|
||||
- 📸 **Portrait Studios** - Client galleries with download limits
|
||||
- 🏢 **Corporate Events** - Internal photo sharing with branding
|
||||
- 🎓 **School Photography** - Secure parent access with expiration
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: File-based with automatic archiving
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
|
||||
*Limited only by your server storage
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
PicPeak takes security seriously:
|
||||
- 🔐 Password hashing with bcrypt
|
||||
- 🎫 JWT-based authentication
|
||||
- 🚦 Rate limiting on all endpoints
|
||||
- 🛡️ CORS protection
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please email security@example.com
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>👆 Click to see more interface details</summary>
|
||||
|
||||
#### What makes PicPeak's interface special:
|
||||
|
||||
- **🎨 Clean Design**: Modern, photographer-friendly interface
|
||||
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
|
||||
- **⚡ Fast Loading**: Optimized for quick photo browsing
|
||||
- **🔒 Secure Access**: Password-protected galleries with expiration
|
||||
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
|
||||
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
|
||||
|
||||
</details>
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Email us at security@example.com with:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
- Regular updates on our progress
|
||||
- Credit in the fix announcement (unless you prefer to remain anonymous)
|
||||
|
||||
## Security Measures
|
||||
|
||||
PicPeak implements several security measures:
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT-based authentication with secure token storage
|
||||
- bcrypt password hashing with configurable rounds
|
||||
- Role-based access control for admin functions
|
||||
- Session timeout management
|
||||
|
||||
### Input Validation
|
||||
- All user inputs are validated and sanitized
|
||||
- SQL injection prevention through parameterized queries
|
||||
- XSS protection via Content Security Policy
|
||||
- File upload restrictions and validation
|
||||
|
||||
### Rate Limiting
|
||||
- API rate limiting to prevent abuse
|
||||
- Brute force protection on authentication endpoints
|
||||
- Configurable limits per endpoint
|
||||
|
||||
### Data Protection
|
||||
- HTTPS enforcement in production
|
||||
- Secure cookie settings
|
||||
- CORS configuration
|
||||
- Sensitive data encryption
|
||||
|
||||
### Infrastructure
|
||||
- Regular dependency updates
|
||||
- Security headers (HSTS, X-Frame-Options, etc.)
|
||||
- Activity logging for audit trails
|
||||
- Automated backups
|
||||
|
||||
## Best Practices for Deployment
|
||||
|
||||
1. **Always use HTTPS** in production
|
||||
2. **Change default passwords** immediately
|
||||
3. **Keep dependencies updated** regularly
|
||||
4. **Configure firewall rules** appropriately
|
||||
5. **Monitor logs** for suspicious activity
|
||||
6. **Backup regularly** and test restoration
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
1. We'll publish a security advisory
|
||||
2. Credit researchers (with permission)
|
||||
3. Detail the impact and mitigation steps
|
||||
4. Release patches for all supported versions
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: security@example.com
|
||||
- General support: https://github.com/the-luap/picpeak/issues
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
-252
@@ -1,252 +0,0 @@
|
||||
# PicPeak - Complete Setup Guide
|
||||
|
||||
## Repository Created Successfully! 🎉
|
||||
|
||||
Your PicPeak repository has been created at:
|
||||
**https://gitea.nothaft.cloud/paul/picpeak**
|
||||
|
||||
## What's Been Created
|
||||
|
||||
I've uploaded the core files needed to run the application:
|
||||
|
||||
### ✅ Created Files:
|
||||
- `.gitignore` - Git ignore rules
|
||||
- `.dockerignore` - Docker ignore rules
|
||||
- `.env.example` - Environment configuration template
|
||||
- `docker-compose.yml` - Development Docker setup
|
||||
- `docker-compose.prod.yml` - Production Docker setup
|
||||
- `backend/` - Core backend files including:
|
||||
- `package.json` - Dependencies
|
||||
- `server.js` - Main server file
|
||||
- `Dockerfile` - Backend container config
|
||||
- Core routes and services
|
||||
- `setup-remaining-files.sh` - Script to create remaining files
|
||||
|
||||
## Next Steps to Complete Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone https://gitea.local.nothaft.cloud/paul/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
### 2. Run the Setup Script
|
||||
```bash
|
||||
chmod +x setup-remaining-files.sh
|
||||
./setup-remaining-files.sh
|
||||
```
|
||||
|
||||
This will create all remaining directories and files needed.
|
||||
|
||||
### 3. Create Critical Service Files
|
||||
|
||||
Due to the large number of files, I've created the most important ones. You'll need to add these remaining backend services:
|
||||
|
||||
#### backend/src/services/expirationChecker.js
|
||||
```javascript
|
||||
const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { archiveEvent } = require('./archiveService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function startExpirationChecker() {
|
||||
// Check every hour for expired events
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await checkExpirations();
|
||||
});
|
||||
|
||||
logger.info('Expiration checker started');
|
||||
}
|
||||
|
||||
async function checkExpirations() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check for events needing warning emails
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
for (const event of eventsNeedingWarning) {
|
||||
const existingWarning = await db('email_queue')
|
||||
.where('event_id', event.id)
|
||||
.where('email_type', 'warning')
|
||||
.first();
|
||||
|
||||
if (!existingWarning) {
|
||||
await queueExpirationWarning(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expired events
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
await handleExpiredEvent(event);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error checking expirations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: event.id,
|
||||
recipient_email: event.host_email,
|
||||
email_type: 'warning',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name,
|
||||
days_remaining: daysRemaining,
|
||||
share_link: event.share_link
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Queued expiration warning for event ${event.slug}`);
|
||||
}
|
||||
|
||||
async function handleExpiredEvent(event) {
|
||||
try {
|
||||
await db('events').where('id', event.id).update({ is_active: false });
|
||||
|
||||
await db('email_queue').insert([
|
||||
{
|
||||
event_id: event.id,
|
||||
recipient_email: event.host_email,
|
||||
email_type: 'expiration',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name
|
||||
})
|
||||
},
|
||||
{
|
||||
event_id: event.id,
|
||||
recipient_email: event.admin_email,
|
||||
email_type: 'expiration',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name,
|
||||
event_slug: event.slug
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
await archiveEvent(event);
|
||||
|
||||
logger.info(`Handled expiration for event ${event.slug}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error handling expired event ${event.slug}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startExpirationChecker };
|
||||
```
|
||||
|
||||
### 4. Create Frontend Files
|
||||
|
||||
The frontend needs these key files in `frontend/src/`:
|
||||
|
||||
#### App.js
|
||||
```javascript
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
// Pages
|
||||
import Login from './pages/Login';
|
||||
import Gallery from './pages/Gallery';
|
||||
import AdminLogin from './pages/admin/Login';
|
||||
import AdminDashboard from './pages/admin/Dashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/gallery" />} />
|
||||
<Route path="/gallery/:slug/:token?" element={<Gallery />} />
|
||||
<Route path="/login/:slug" element={<Login />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 5. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Configure Environment
|
||||
|
||||
Copy `.env.example` to `.env` and update with your settings:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 7. Start Development Environment
|
||||
|
||||
```bash
|
||||
# From root directory
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
- Backend: http://localhost:3000
|
||||
- Frontend: http://localhost:3001
|
||||
- MailHog: http://localhost:8025
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
- ✅ Password-protected galleries
|
||||
- ✅ Automatic expiration with email warnings
|
||||
- ✅ File-based photo management
|
||||
- ✅ ZIP archiving on expiration
|
||||
- ✅ Separate admin and public interfaces
|
||||
- ✅ Email notifications at all stages
|
||||
- ✅ Mobile-responsive design
|
||||
- ✅ Docker deployment ready
|
||||
|
||||
## Production Deployment
|
||||
|
||||
1. Update `.env` with production values
|
||||
2. Run `./scripts/install.sh` on your server
|
||||
3. Configure SSL with `./scripts/setup-ssl.sh`
|
||||
4. Start with `docker-compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
## Need Help?
|
||||
|
||||
The complete implementation includes:
|
||||
- Backend API with all routes
|
||||
- React frontend with admin panel
|
||||
- Email service with templates
|
||||
- Automatic file watching
|
||||
- Expiration checking
|
||||
- Archive service
|
||||
- Docker configuration
|
||||
- Deployment scripts
|
||||
|
||||
All core functionality from your PRD has been implemented. You may need to create some additional UI components based on your specific design preferences.
|
||||
|
||||
Default admin credentials: **admin / admin123** (change immediately!)
|
||||
@@ -1,47 +0,0 @@
|
||||
# TODO - Open Items Before Release
|
||||
|
||||
## Priority Items
|
||||
|
||||
- [ ] **Gallery Mobile View**
|
||||
- Logout button should only show logo icon (no text)
|
||||
- If photo upload is enabled, move upload button inside menu (not on top bar)
|
||||
- Top bar should show: logo (left), gallery title (center), event date + expiration date
|
||||
|
||||
- [ ] **Gallery Preview**
|
||||
- Preview should correctly reflect the selected grid layout style
|
||||
- Add grid style selector above current top bar
|
||||
- Selector should match the style of event template settings grid selector
|
||||
|
||||
- [ ] **Hero Grid Layout**
|
||||
- Top bar: only menu and logout buttons
|
||||
- Title + logo displayed centered on hero photo
|
||||
- Event date and expiration date also on hero photo
|
||||
- No logo/title in top bar
|
||||
|
||||
- [ ] **Logo Testing** - Test new PicPeak logos across all grid styles
|
||||
|
||||
- [ ] **Welcome Message**
|
||||
- Add welcome message to email template when creating new event
|
||||
- Use as personal message in the email
|
||||
|
||||
- [ ] **Gallery Upload Function**
|
||||
- Fix scrolling in upload popup when multiple images selected
|
||||
- Save/Cancel buttons unreachable due to incorrect scroll formatting
|
||||
|
||||
- [ ] **Watermarks** - Test watermark functionality, styling, and image application
|
||||
|
||||
- [ ] **Dashboard Activities** - Remove "show all" link from latest activities widget
|
||||
|
||||
- [ ] **Security Audit** - Perform security review and code audit
|
||||
|
||||
- [ ] **Drone CI/CD** - Update drone.yaml configuration
|
||||
|
||||
- [ ] **Version Management** - Implement automatic version updates on commits/builds
|
||||
|
||||
## Completed Items
|
||||
|
||||
_(Move completed items here with date)_
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2025-07-10
|
||||
@@ -1,5 +1,8 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
|
||||
@@ -0,0 +1,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
Executable
+50
@@ -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
|
||||
+16
-3
@@ -31,15 +31,28 @@ const config = {
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak'
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ensure PostgreSQL compatibility for all insert operations
|
||||
* This migration doesn't change the schema but ensures all tables
|
||||
* are compatible with .returning() syntax
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// This migration is informational only
|
||||
// All insert operations should use .returning('id') going forward
|
||||
|
||||
console.log('PostgreSQL compatibility check:');
|
||||
console.log('- All INSERT operations should use .returning("id")');
|
||||
console.log('- All date operations should use ISO strings');
|
||||
console.log('- Boolean values are handled automatically by Knex');
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// No rollback needed
|
||||
return Promise.resolve();
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Fix boolean compatibility issues between PostgreSQL and SQLite
|
||||
* This migration updates the database configuration and existing data
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const isPostgres = knex.client.config.client === 'pg';
|
||||
|
||||
if (!isPostgres) {
|
||||
// Enable foreign keys for SQLite
|
||||
await knex.raw('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Note: SQLite stores booleans as 0/1
|
||||
// No data migration needed as Knex handles this automatically
|
||||
// But queries must use formatBoolean() helper
|
||||
|
||||
console.log('SQLite boolean compatibility check:');
|
||||
console.log('- SQLite stores booleans as 0/1');
|
||||
console.log('- All boolean comparisons should use formatBoolean() helper');
|
||||
console.log('- Foreign keys enabled');
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// No rollback needed
|
||||
return Promise.resolve();
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Fix email_queue table by ensuring it doesn't have updated_at column
|
||||
* This migration addresses the PostgreSQL error where queries are trying to update
|
||||
* a non-existent updated_at column
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// First, check if the column exists
|
||||
const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at');
|
||||
|
||||
if (hasUpdatedAt) {
|
||||
console.log('Found updated_at column in email_queue table, removing it...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Also ensure the table has all required columns
|
||||
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
|
||||
if (!hasCreatedAt) {
|
||||
console.log('Adding missing created_at column to email_queue table...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
console.log('email_queue table schema fixed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// In the down migration, we don't add back updated_at since it shouldn't exist
|
||||
// This is intentionally left minimal
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
exports.up = async function(knex) {
|
||||
// Update gallery_created template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie ist bereit!',
|
||||
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||
{{#if welcome_message}}
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Persönliche Nachricht:</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p><strong>Galerie-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Ablaufdatum: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>
|
||||
<p style="background-color: #FEF3C7; padding: 15px; border-radius: 5px; border-left: 4px solid #F59E0B;">
|
||||
<strong>Wichtig:</strong> Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie anzeigen</a>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie erfolgreich erstellt
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!
|
||||
|
||||
{{#if welcome_message}}
|
||||
Persönliche Nachricht:
|
||||
{{welcome_message}}
|
||||
|
||||
{{/if}}
|
||||
Galerie-Details:
|
||||
- Veranstaltungsdatum: {{event_date}}
|
||||
- Galerie-Link: {{gallery_link}}
|
||||
- Passwort: {{gallery_password}}
|
||||
- Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.
|
||||
|
||||
WICHTIG: Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update expiration_warning template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie läuft bald ab',
|
||||
body_html_de: `<h2>Galerie läuft bald ab</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" läuft in <strong>{{days_remaining}} Tagen</strong> ab.</p>
|
||||
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.</p>
|
||||
<p><strong>Ablaufdatum:</strong> {{expiry_date}}</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie jetzt besuchen</a>
|
||||
<p style="background-color: #FEE2E2; padding: 15px; border-radius: 5px; border-left: 4px solid #EF4444;">
|
||||
<strong>Erinnerung:</strong> Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie läuft bald ab
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.
|
||||
|
||||
Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.
|
||||
|
||||
Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Galerie-Link: {{gallery_link}}
|
||||
|
||||
ERINNERUNG: Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update gallery_expired template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen',
|
||||
body_html_de: `<h2>Galerie abgelaufen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.</p>
|
||||
<p>Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.</p>
|
||||
<p>Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:</p>
|
||||
<p style="background-color: #F3F4F6; padding: 15px; border-radius: 5px;">
|
||||
<strong>Kontakt:</strong><br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a><br>
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
</p>
|
||||
<p>Vielen Dank für die Nutzung unseres Foto-Sharing-Services!</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie abgelaufen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.
|
||||
|
||||
Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.
|
||||
|
||||
Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:
|
||||
|
||||
E-Mail: {{admin_email}}
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
|
||||
Vielen Dank für die Nutzung unseres Foto-Sharing-Services!
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update archive_complete template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_de: 'Archivierung abgeschlossen: {{event_name}}',
|
||||
body_html_de: `<h2>Archivierung abgeschlossen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.</p>
|
||||
<p><strong>Archiv-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Archivgröße: {{archive_size}}</li>
|
||||
<li>Archivierungsdatum: {{archive_date}}</li>
|
||||
<li>Anzahl der Fotos: {{photo_count}}</li>
|
||||
</ul>
|
||||
<p>Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.</p>
|
||||
<p style="background-color: #F0FDF4; padding: 15px; border-radius: 5px; border-left: 4px solid #22C55E;">
|
||||
<strong>✓ Erfolgreich archiviert:</strong> Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
</p>
|
||||
<p>Kontakt für Archivzugriff:<br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a></p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Archivierung abgeschlossen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.
|
||||
|
||||
Archiv-Details:
|
||||
- Archivgröße: {{archive_size}}
|
||||
- Archivierungsdatum: {{archive_date}}
|
||||
- Anzahl der Fotos: {{photo_count}}
|
||||
|
||||
Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.
|
||||
|
||||
✓ ERFOLGREICH ARCHIVIERT: Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
|
||||
Kontakt für Archivzugriff:
|
||||
E-Mail: {{admin_email}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Also update the non-language-specific fields to match German for consistency
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to previous German translations
|
||||
// This is a simplified rollback - in production you might want to store the old values
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language column to events table if it doesn't exist
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (!hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.string('language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Add default_language to email_configs if it doesn't exist
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (!hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.string('default_language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Set default language to German for the existing email config
|
||||
await knex('email_configs')
|
||||
.update({
|
||||
default_language: 'de'
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove language column from events table
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('language');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove default_language from email_configs
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.dropColumn('default_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
@@ -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 };
|
||||
Generated
+49
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.53",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.53",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"form-data": "^4.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -3995,6 +3996,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -5985,6 +6007,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.75.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
||||
@@ -7606,7 +7634,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8161,6 +8188,19 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -8412,6 +8452,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.53",
|
||||
"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/"
|
||||
},
|
||||
@@ -22,6 +23,7 @@
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"form-data": "^4.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,131 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkEmailEnvironment() {
|
||||
console.log('=== Email Environment Check ===\n');
|
||||
|
||||
// 1. Check environment variables
|
||||
console.log('1. Environment Variables:');
|
||||
const envVars = [
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASS',
|
||||
'SMTP_FROM',
|
||||
'SMTP_SECURE',
|
||||
'EMAIL_PROCESSOR_ENABLED',
|
||||
'NODE_ENV'
|
||||
];
|
||||
|
||||
envVars.forEach(varName => {
|
||||
const value = process.env[varName];
|
||||
if (varName.includes('PASS')) {
|
||||
console.log(` ${varName}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
console.log(` ${varName}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check database configuration
|
||||
console.log('\n2. Database Email Configuration:');
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig) {
|
||||
console.log(' Email configuration found in database:');
|
||||
console.log(` - SMTP Host: ${emailConfig.smtp_host}`);
|
||||
console.log(` - SMTP Port: ${emailConfig.smtp_port}`);
|
||||
console.log(` - SMTP User: ${emailConfig.smtp_user || 'NOT SET'}`);
|
||||
console.log(` - SMTP Secure: ${emailConfig.smtp_secure}`);
|
||||
console.log(` - From Address: ${emailConfig.smtp_from}`);
|
||||
} else {
|
||||
console.log(' ⚠️ No email configuration found in database!');
|
||||
console.log(' This will prevent the email processor from initializing.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error reading email configuration: ${error.message}`);
|
||||
}
|
||||
|
||||
// 3. Check if the email processor should be disabled
|
||||
console.log('\n3. Email Processor Status:');
|
||||
const isDisabled = process.env.EMAIL_PROCESSOR_ENABLED === 'false';
|
||||
if (isDisabled) {
|
||||
console.log(' ⚠️ Email processor is DISABLED via EMAIL_PROCESSOR_ENABLED=false');
|
||||
} else {
|
||||
console.log(' ✅ Email processor is enabled (default)');
|
||||
}
|
||||
|
||||
// 4. Check pending emails
|
||||
console.log('\n4. Email Queue Status:');
|
||||
try {
|
||||
const pending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const failed = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const sent = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
console.log(` - Pending emails: ${pending.count}`);
|
||||
console.log(` - Failed emails (max retries): ${failed.count}`);
|
||||
console.log(` - Sent emails: ${sent.count}`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying email queue: ${error.message}`);
|
||||
}
|
||||
|
||||
// 5. Test database connection
|
||||
console.log('\n5. Database Connection:');
|
||||
try {
|
||||
await db.raw('SELECT 1');
|
||||
console.log(' ✅ Database connection successful');
|
||||
} catch (error) {
|
||||
console.log(` ❌ Database connection failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// 6. Check for any recent errors
|
||||
console.log('\n6. Recent Email Errors:');
|
||||
try {
|
||||
const recentErrors = await db('email_queue')
|
||||
.whereNotNull('error_message')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(3)
|
||||
.select('id', 'email_type', 'error_message', 'retry_count');
|
||||
|
||||
if (recentErrors.length > 0) {
|
||||
recentErrors.forEach((email, index) => {
|
||||
console.log(` ${index + 1}. Email ID ${email.id} (${email.email_type}):`);
|
||||
console.log(` Retries: ${email.retry_count}`);
|
||||
console.log(` Error: ${email.error_message}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' No recent errors found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying recent errors: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Environment check complete ===');
|
||||
console.log('\nRecommendations:');
|
||||
|
||||
const emailConfig = await db('email_configs').first().catch(() => null);
|
||||
if (!emailConfig) {
|
||||
console.log('❗ Configure email settings in the admin panel or add email_configs record');
|
||||
}
|
||||
|
||||
if (!process.env.SMTP_HOST && !emailConfig) {
|
||||
console.log('❗ Set SMTP environment variables or configure in database');
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
checkEmailEnvironment().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function checkEmailProcessor() {
|
||||
try {
|
||||
logger.info('=== Email Processor Diagnostic Check ===\n');
|
||||
|
||||
// 1. Check pending emails
|
||||
logger.info('1. Checking pending emails in queue...');
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
logger.info(`Found ${pendingEmails.length} pending emails\n`);
|
||||
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('Pending email details:');
|
||||
pendingEmails.forEach((email, index) => {
|
||||
logger.info(`\nEmail ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Scheduled At: ${email.scheduled_at}`);
|
||||
logger.info(` Created At: ${email.created_at}`);
|
||||
logger.info(` Error: ${email.error_message || 'None'}`);
|
||||
|
||||
// Check if email_data needs parsing
|
||||
logger.info(` Email Data Type: ${typeof email.email_data}`);
|
||||
if (email.email_data) {
|
||||
try {
|
||||
const data = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data)
|
||||
: email.email_data;
|
||||
logger.info(` Email Data Keys: ${Object.keys(data).join(', ')}`);
|
||||
} catch (e) {
|
||||
logger.error(` Failed to parse email_data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check failed emails
|
||||
logger.info('\n\n2. Checking failed emails...');
|
||||
const failedEmails = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(5);
|
||||
|
||||
logger.info(`Found ${failedEmails.length} failed emails (showing last 5)\n`);
|
||||
|
||||
if (failedEmails.length > 0) {
|
||||
failedEmails.forEach((email, index) => {
|
||||
logger.info(`\nFailed Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Error: ${email.error_message || 'No error message'}`);
|
||||
logger.info(` Last Attempt: ${email.sent_at || 'Never'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check if email processor should be running
|
||||
logger.info('\n\n3. Checking email processor configuration...');
|
||||
|
||||
// Check environment variables
|
||||
const emailConfig = {
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
EMAIL_PROCESSOR_ENABLED: process.env.EMAIL_PROCESSOR_ENABLED || 'true'
|
||||
};
|
||||
|
||||
logger.info('Email configuration:');
|
||||
Object.entries(emailConfig).forEach(([key, value]) => {
|
||||
if (key === 'SMTP_USER') {
|
||||
logger.info(` ${key}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
logger.info(` ${key}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Test email processor functionality
|
||||
logger.info('\n\n4. Testing email processor functionality...');
|
||||
|
||||
// Import the email processor
|
||||
const { processEmailQueue, testEmailConnection } = require('../src/services/emailProcessor');
|
||||
|
||||
// Test email connection
|
||||
logger.info('Testing email connection...');
|
||||
try {
|
||||
const connectionTest = await testEmailConnection();
|
||||
logger.info(`Email connection test: ${connectionTest ? 'SUCCESS' : 'FAILED'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Email connection test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Try to process queue once manually
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('\n\n5. Attempting to process email queue manually...');
|
||||
try {
|
||||
await processEmailQueue();
|
||||
logger.info('Manual queue processing completed');
|
||||
|
||||
// Check status after processing
|
||||
const stillPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`Emails still pending after processing: ${stillPending.count}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error processing queue: ${error.message}`);
|
||||
logger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check for any recent successful emails
|
||||
logger.info('\n\n6. Checking recent successful emails...');
|
||||
const recentSuccess = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.orderBy('sent_at', 'desc')
|
||||
.limit(3);
|
||||
|
||||
if (recentSuccess.length > 0) {
|
||||
logger.info(`Last ${recentSuccess.length} successful emails:`);
|
||||
recentSuccess.forEach((email, index) => {
|
||||
logger.info(` ${index + 1}. Type: ${email.email_type}, Sent: ${email.sent_at}`);
|
||||
});
|
||||
} else {
|
||||
logger.info('No successfully sent emails found');
|
||||
}
|
||||
|
||||
logger.info('\n\n=== Diagnostic check complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running diagnostic check:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkEmailProcessor();
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to check storage directory structure and verify files
|
||||
* Usage: node scripts/check-storage.js [eventSlug]
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
async function checkDirectory(dirPath, description) {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
const stats = await fs.stat(dirPath);
|
||||
const files = await fs.readdir(dirPath);
|
||||
console.log(`✓ ${description}: ${dirPath}`);
|
||||
console.log(` - Files/Folders: ${files.length}`);
|
||||
console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(`✗ ${description}: ${dirPath} - ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStorageStructure(eventSlug = null) {
|
||||
console.log('Checking storage structure...');
|
||||
console.log(`Storage base path: ${STORAGE_PATH}\n`);
|
||||
|
||||
// Check main directories
|
||||
await checkDirectory(STORAGE_PATH, 'Storage root');
|
||||
await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory');
|
||||
await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events');
|
||||
await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events');
|
||||
await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails');
|
||||
await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads');
|
||||
|
||||
console.log('\n---\n');
|
||||
|
||||
// If event slug provided, check specific event
|
||||
if (eventSlug) {
|
||||
console.log(`Checking specific event: ${eventSlug}`);
|
||||
|
||||
const event = await db('events').where('slug', eventSlug).first();
|
||||
if (!event) {
|
||||
console.log(`✗ Event not found in database: ${eventSlug}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✓ Event found in database:`);
|
||||
console.log(` - ID: ${event.id}`);
|
||||
console.log(` - Name: ${event.event_name}`);
|
||||
console.log(` - Active: ${event.is_active}`);
|
||||
console.log(` - Archived: ${event.is_archived}`);
|
||||
|
||||
// Check event directory
|
||||
const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug);
|
||||
const eventExists = await checkDirectory(eventDir, 'Event directory');
|
||||
|
||||
if (eventExists) {
|
||||
const files = await fs.readdir(eventDir);
|
||||
console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`);
|
||||
}
|
||||
|
||||
// Check photos in database
|
||||
const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path');
|
||||
console.log(`\nDatabase photos: ${photos.length}`);
|
||||
|
||||
// Check if photo files exist
|
||||
let existingPhotos = 0;
|
||||
let missingPhotos = 0;
|
||||
let existingThumbnails = 0;
|
||||
let missingThumbnails = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||
try {
|
||||
await fs.access(photoPath);
|
||||
existingPhotos++;
|
||||
} catch {
|
||||
missingPhotos++;
|
||||
console.log(` ✗ Missing photo: ${photo.path}`);
|
||||
}
|
||||
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, ''));
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
existingThumbnails++;
|
||||
} catch {
|
||||
missingThumbnails++;
|
||||
console.log(` ✗ Missing thumbnail: ${photo.thumbnail_path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nFile check summary:`);
|
||||
console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`);
|
||||
console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`);
|
||||
} else {
|
||||
// List all event directories
|
||||
try {
|
||||
const activeDir = path.join(STORAGE_PATH, 'events/active');
|
||||
const eventDirs = await fs.readdir(activeDir);
|
||||
console.log(`Active event directories: ${eventDirs.length}`);
|
||||
for (const dir of eventDirs.slice(0, 10)) {
|
||||
console.log(` - ${dir}`);
|
||||
}
|
||||
if (eventDirs.length > 10) {
|
||||
console.log(` ... and ${eventDirs.length - 10} more`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not list event directories:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const eventSlug = process.argv[2] || null;
|
||||
|
||||
// Run the script
|
||||
checkStorageStructure(eventSlug).then(async () => {
|
||||
await db.destroy();
|
||||
console.log('\nStorage check complete');
|
||||
}).catch(async error => {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to clean up orphaned and temporary thumbnails
|
||||
* Usage: node scripts/cleanup-thumbnails.js [--dry-run]
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||
|
||||
async function cleanupThumbnails(dryRun = false) {
|
||||
console.log('Starting thumbnail cleanup...');
|
||||
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);
|
||||
|
||||
try {
|
||||
// Get all thumbnail files
|
||||
const files = await fs.readdir(THUMBNAILS_DIR);
|
||||
console.log(`Found ${files.length} files in thumbnails directory`);
|
||||
|
||||
// Get all valid thumbnail paths from database
|
||||
const validThumbnails = await db('photos')
|
||||
.whereNotNull('thumbnail_path')
|
||||
.select('thumbnail_path');
|
||||
|
||||
const validPaths = new Set(
|
||||
validThumbnails.map(t => path.basename(t.thumbnail_path))
|
||||
);
|
||||
|
||||
console.log(`Found ${validPaths.size} valid thumbnails in database\n`);
|
||||
|
||||
let tempCount = 0;
|
||||
let orphanedCount = 0;
|
||||
let validCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
// Skip directories
|
||||
const filePath = path.join(THUMBNAILS_DIR, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.isDirectory()) continue;
|
||||
|
||||
// Check if it's a temporary file
|
||||
if (file.startsWith('thumb_temp_')) {
|
||||
tempCount++;
|
||||
console.log(`Temporary file: ${file}`);
|
||||
|
||||
if (!dryRun) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
console.error(` Failed to delete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if it's an orphaned thumbnail
|
||||
else if (!validPaths.has(file)) {
|
||||
orphanedCount++;
|
||||
console.log(`Orphaned file: ${file}`);
|
||||
|
||||
if (!dryRun) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
console.error(` Failed to delete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
console.log(`Total files: ${files.length}`);
|
||||
console.log(`Valid thumbnails: ${validCount}`);
|
||||
console.log(`Temporary files: ${tempCount}`);
|
||||
console.log(`Orphaned files: ${orphanedCount}`);
|
||||
if (!dryRun) {
|
||||
console.log(`Deleted files: ${deletedCount}`);
|
||||
} else {
|
||||
console.log(`Files to be deleted: ${tempCount + orphanedCount}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during cleanup:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
// Run the cleanup
|
||||
cleanupThumbnails(dryRun).then(async () => {
|
||||
await db.destroy();
|
||||
console.log('\nCleanup complete');
|
||||
}).catch(async error => {
|
||||
console.error('Cleanup failed:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ async function createTestEvent() {
|
||||
await db('events').where('slug', eventData.slug).delete();
|
||||
|
||||
// Insert new event
|
||||
const [eventId] = await db('events').insert(eventData);
|
||||
const insertResult = await db('events').insert(eventData).returning('id');
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
console.log('Event created with ID:', eventId);
|
||||
|
||||
console.log('\nTest event created successfully!');
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,146 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function debugEmailQueue() {
|
||||
try {
|
||||
logger.info('=== Email Queue Debug Report ===\n');
|
||||
|
||||
// 1. Count exactly like the admin dashboard does
|
||||
logger.info('1. Admin Dashboard Query (ALL pending, no retry filter):');
|
||||
const [adminCount] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
logger.info(` Pending emails (admin dashboard view): ${adminCount.count}\n`);
|
||||
|
||||
// 2. Count like the email processor does
|
||||
logger.info('2. Email Processor Query (pending with retry_count < 3):');
|
||||
const [processorCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
logger.info(` Pending emails (processor view): ${processorCount.count}\n`);
|
||||
|
||||
// 3. Show the discrepancy
|
||||
logger.info('3. Discrepancy Analysis:');
|
||||
if (adminCount.count !== processorCount.count) {
|
||||
logger.info(` ⚠️ DISCREPANCY FOUND!`);
|
||||
logger.info(` Admin shows: ${adminCount.count}`);
|
||||
logger.info(` Processor will process: ${processorCount.count}`);
|
||||
logger.info(` Difference: ${adminCount.count - processorCount.count} email(s)\n`);
|
||||
|
||||
// Find the problematic emails
|
||||
logger.info('4. Emails with retry_count >= 3 (still pending):');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length > 0) {
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s):\n`);
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(` Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count} ⚠️`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}\n`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info(` ✅ No discrepancy - counts match\n`);
|
||||
}
|
||||
|
||||
// 5. Show ALL pending emails with details
|
||||
logger.info('5. ALL Pending Emails (regardless of retry count):');
|
||||
const allPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.orderBy('retry_count', 'desc')
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
if (allPending.length > 0) {
|
||||
allPending.forEach((email, index) => {
|
||||
const willProcess = email.retry_count < 3;
|
||||
logger.info(`\n Email ${index + 1}: ${willProcess ? '✅ WILL PROCESS' : '❌ STUCK (max retries)'}`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}/3`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Scheduled: ${email.scheduled_at}`);
|
||||
if (email.error_message) {
|
||||
logger.info(` Last Error: ${email.error_message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(' No pending emails found');
|
||||
}
|
||||
|
||||
// 6. Show counts by status
|
||||
logger.info('\n\n6. Email Queue Summary by Status:');
|
||||
const statusCounts = await db('email_queue')
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status')
|
||||
.orderBy('status');
|
||||
|
||||
statusCounts.forEach(row => {
|
||||
logger.info(` ${row.status}: ${row.count}`);
|
||||
});
|
||||
|
||||
// 7. Failed emails summary
|
||||
logger.info('\n7. Failed Emails Summary:');
|
||||
const failedSummary = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.select('retry_count')
|
||||
.count('* as count')
|
||||
.groupBy('retry_count')
|
||||
.orderBy('retry_count');
|
||||
|
||||
if (failedSummary.length > 0) {
|
||||
failedSummary.forEach(row => {
|
||||
logger.info(` Retry count ${row.retry_count}: ${row.count} email(s)`);
|
||||
});
|
||||
} else {
|
||||
logger.info(' No failed emails');
|
||||
}
|
||||
|
||||
// 8. Recommendations
|
||||
logger.info('\n\n=== RECOMMENDATIONS ===');
|
||||
|
||||
if (adminCount.count > processorCount.count) {
|
||||
logger.info('\n❗ You have emails stuck with retry_count >= 3');
|
||||
logger.info(' These emails will NOT be processed automatically.');
|
||||
logger.info('\n To fix this, you can:');
|
||||
logger.info(' 1. Reset retry count: UPDATE email_queue SET retry_count = 0 WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 2. Mark as failed: UPDATE email_queue SET status = \'failed\' WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 3. Delete them: DELETE FROM email_queue WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
}
|
||||
|
||||
const anyPending = adminCount.count > 0;
|
||||
if (anyPending && processorCount.count === 0) {
|
||||
logger.info('\n❗ All pending emails have exceeded retry limit');
|
||||
logger.info(' The email processor will not attempt to send them.');
|
||||
} else if (anyPending && processorCount.count > 0) {
|
||||
logger.info('\n✅ Email processor should process the pending emails on next run');
|
||||
logger.info(' Make sure the email processor service is running.');
|
||||
}
|
||||
|
||||
logger.info('\n=== Debug report complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running debug report:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the debug
|
||||
debugEmailQueue();
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to diagnose thumbnail serving issues
|
||||
* Usage: node scripts/diagnose-thumbnails.js <eventId>
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||
|
||||
async function diagnoseThumbnails(eventId) {
|
||||
if (!eventId) {
|
||||
console.error('Usage: node scripts/diagnose-thumbnails.js <eventId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Diagnosing thumbnails for event ID: ${eventId}`);
|
||||
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`);
|
||||
|
||||
try {
|
||||
// Get event info
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
console.error(`Event not found with ID: ${eventId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Event: ${event.event_name} (${event.slug})`);
|
||||
console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`);
|
||||
|
||||
// Get photos for this event
|
||||
const photos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'filename', 'path', 'thumbnail_path');
|
||||
|
||||
console.log(`Found ${photos.length} photos in database\n`);
|
||||
|
||||
let missingThumbnails = 0;
|
||||
let existingThumbnails = 0;
|
||||
let pathIssues = [];
|
||||
|
||||
for (const photo of photos.slice(0, 10)) { // Check first 10 photos
|
||||
console.log(`Photo ID ${photo.id}: ${photo.filename}`);
|
||||
console.log(` Photo path: ${photo.path}`);
|
||||
console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`);
|
||||
|
||||
if (photo.thumbnail_path) {
|
||||
// Expected thumbnail filename
|
||||
const expectedThumbName = `thumb_${photo.filename}`;
|
||||
const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName);
|
||||
|
||||
// Check if thumbnail exists
|
||||
try {
|
||||
await fs.access(expectedThumbPath);
|
||||
console.log(` ✓ Thumbnail exists at: ${expectedThumbName}`);
|
||||
existingThumbnails++;
|
||||
|
||||
// Check if DB path matches expected path
|
||||
const dbThumbName = path.basename(photo.thumbnail_path);
|
||||
if (dbThumbName !== expectedThumbName) {
|
||||
console.log(` ⚠ Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`);
|
||||
pathIssues.push({
|
||||
photoId: photo.id,
|
||||
dbPath: photo.thumbnail_path,
|
||||
expectedPath: `thumbnails/${expectedThumbName}`
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.log(` ✗ Thumbnail missing: ${expectedThumbName}`);
|
||||
missingThumbnails++;
|
||||
}
|
||||
} else {
|
||||
console.log(` ✗ No thumbnail path in database`);
|
||||
missingThumbnails++;
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('--- Summary ---');
|
||||
console.log(`Existing thumbnails: ${existingThumbnails}`);
|
||||
console.log(`Missing thumbnails: ${missingThumbnails}`);
|
||||
console.log(`Path issues: ${pathIssues.length}`);
|
||||
|
||||
if (pathIssues.length > 0) {
|
||||
console.log('\n--- Path Issues ---');
|
||||
console.log('The following photos have incorrect thumbnail paths in the database:');
|
||||
for (const issue of pathIssues) {
|
||||
console.log(`Photo ID ${issue.photoId}:`);
|
||||
console.log(` Current: ${issue.dbPath}`);
|
||||
console.log(` Should be: ${issue.expectedPath}`);
|
||||
}
|
||||
|
||||
console.log('\nTo fix path issues, run:');
|
||||
console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`);
|
||||
}
|
||||
|
||||
// Check for any thumbnails in the directory that match this event
|
||||
const files = await fs.readdir(THUMBNAILS_DIR);
|
||||
const eventThumbnails = files.filter(f => {
|
||||
// Try to match thumbnails for this event
|
||||
for (const photo of photos) {
|
||||
if (f === `thumb_${photo.filename}`) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
console.log(`\n--- Filesystem Check ---`);
|
||||
console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during diagnosis:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||
|
||||
// Run the diagnosis
|
||||
diagnoseThumbnails(eventId).then(async () => {
|
||||
await db.destroy();
|
||||
console.log('\nDiagnosis complete');
|
||||
}).catch(async error => {
|
||||
console.error('Diagnosis failed:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to diagnose and fix email_queue schema issues
|
||||
* This helps resolve the "column updated_at does not exist" error
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkAndFixEmailQueueSchema() {
|
||||
console.log('Checking email_queue table schema...');
|
||||
|
||||
try {
|
||||
// Get column information
|
||||
const columns = await db('email_queue').columnInfo();
|
||||
console.log('\nCurrent email_queue columns:', Object.keys(columns));
|
||||
|
||||
// Check for updated_at column
|
||||
if (columns.updated_at) {
|
||||
console.log('\n⚠️ Found unexpected updated_at column in email_queue table!');
|
||||
console.log('This column should not exist and is causing errors.');
|
||||
|
||||
// Ask for confirmation before removing
|
||||
console.log('\nRemoving updated_at column...');
|
||||
await db.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
console.log('✅ Removed updated_at column from email_queue table');
|
||||
} else {
|
||||
console.log('✅ No updated_at column found (this is correct)');
|
||||
}
|
||||
|
||||
// Verify required columns exist
|
||||
const requiredColumns = [
|
||||
'id', 'event_id', 'recipient_email', 'email_type',
|
||||
'email_data', 'status', 'scheduled_at', 'sent_at',
|
||||
'error_message', 'retry_count', 'created_at'
|
||||
];
|
||||
|
||||
const missingColumns = requiredColumns.filter(col => !columns[col]);
|
||||
if (missingColumns.length > 0) {
|
||||
console.log('\n⚠️ Missing required columns:', missingColumns);
|
||||
} else {
|
||||
console.log('✅ All required columns are present');
|
||||
}
|
||||
|
||||
// Check for any database triggers
|
||||
if (process.env.DATABASE_CLIENT === 'pg') {
|
||||
console.log('\nChecking for PostgreSQL triggers on email_queue...');
|
||||
const triggers = await db.raw(`
|
||||
SELECT trigger_name, event_manipulation, action_statement
|
||||
FROM information_schema.triggers
|
||||
WHERE event_object_table = 'email_queue'
|
||||
AND trigger_schema = current_schema()
|
||||
`);
|
||||
|
||||
if (triggers.rows && triggers.rows.length > 0) {
|
||||
console.log('⚠️ Found triggers on email_queue table:');
|
||||
triggers.rows.forEach(trigger => {
|
||||
console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`);
|
||||
});
|
||||
} else {
|
||||
console.log('✅ No triggers found on email_queue table');
|
||||
}
|
||||
}
|
||||
|
||||
// Test update query
|
||||
console.log('\nTesting update query...');
|
||||
const testEmail = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.first();
|
||||
|
||||
if (testEmail) {
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', testEmail.id)
|
||||
.update({
|
||||
retry_count: testEmail.retry_count
|
||||
});
|
||||
console.log('✅ Update query works correctly');
|
||||
} catch (error) {
|
||||
console.log('❌ Update query failed:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No pending emails to test with');
|
||||
}
|
||||
|
||||
console.log('\nSchema check complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking schema:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkAndFixEmailQueueSchema();
|
||||
@@ -0,0 +1,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();
|
||||
@@ -0,0 +1,126 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function fixStuckEmails() {
|
||||
try {
|
||||
logger.info('=== Fix Stuck Emails Script ===\n');
|
||||
|
||||
// 1. Find stuck emails
|
||||
logger.info('1. Finding stuck emails (pending with retry_count >= 3)...');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length === 0) {
|
||||
logger.info(' ✅ No stuck emails found!');
|
||||
logger.info('\n=== Script complete ===');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s)\n`);
|
||||
|
||||
// 2. Show details
|
||||
logger.info('2. Stuck email details:');
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(`\n Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}`);
|
||||
});
|
||||
|
||||
// 3. Ask for action
|
||||
logger.info('\n\n3. Choose an action:');
|
||||
logger.info(' 1. Reset retry count to 0 (emails will be retried)');
|
||||
logger.info(' 2. Mark as failed (emails will not be retried)');
|
||||
logger.info(' 3. Delete these emails');
|
||||
logger.info(' 4. Cancel (do nothing)');
|
||||
|
||||
// Get command line argument
|
||||
const action = process.argv[2];
|
||||
|
||||
if (!action || !['reset', 'fail', 'delete'].includes(action)) {
|
||||
logger.info('\n❗ No valid action specified');
|
||||
logger.info('\nUsage:');
|
||||
logger.info(' node fix-stuck-emails.js reset - Reset retry count to 0');
|
||||
logger.info(' node fix-stuck-emails.js fail - Mark as failed');
|
||||
logger.info(' node fix-stuck-emails.js delete - Delete stuck emails');
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Execute action
|
||||
logger.info(`\n4. Executing action: ${action.toUpperCase()}`);
|
||||
|
||||
const emailIds = stuckEmails.map(e => e.id);
|
||||
|
||||
switch (action) {
|
||||
case 'reset':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
retry_count: 0,
|
||||
error_message: null
|
||||
});
|
||||
logger.info(` ✅ Reset retry count for ${emailIds.length} email(s)`);
|
||||
logger.info(' These emails will be processed on the next run');
|
||||
break;
|
||||
|
||||
case 'fail':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
status: 'failed'
|
||||
});
|
||||
logger.info(` ✅ Marked ${emailIds.length} email(s) as failed`);
|
||||
logger.info(' These emails will not be retried');
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.delete();
|
||||
logger.info(` ✅ Deleted ${emailIds.length} email(s)`);
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. Show updated counts
|
||||
logger.info('\n5. Updated email queue status:');
|
||||
const [pendingCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count');
|
||||
const [processableCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
|
||||
logger.info(` Total pending: ${pendingCount.count}`);
|
||||
logger.info(` Processable (retry < 3): ${processableCount.count}`);
|
||||
|
||||
if (pendingCount.count !== processableCount.count) {
|
||||
logger.info(` ⚠️ Still have ${pendingCount.count - processableCount.count} stuck email(s)`);
|
||||
} else {
|
||||
logger.info(' ✅ No stuck emails remaining');
|
||||
}
|
||||
|
||||
logger.info('\n=== Script complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the fix
|
||||
fixStuckEmails();
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to regenerate missing thumbnails for photos in the database
|
||||
* Usage: node scripts/regenerate-thumbnails.js [eventId]
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
// Configuration
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||
|
||||
async function ensureDirectoryExists(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
console.log(`Created directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail(photoPath, thumbnailPath) {
|
||||
try {
|
||||
await sharp(photoPath)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateThumbnails(eventId = null) {
|
||||
try {
|
||||
console.log('Starting thumbnail regeneration...');
|
||||
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
await ensureDirectoryExists(THUMBNAILS_DIR);
|
||||
|
||||
// Build query
|
||||
let query = db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.path',
|
||||
'photos.thumbnail_path',
|
||||
'events.slug as event_slug'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photos.event_id', eventId);
|
||||
console.log(`Filtering for event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
console.log(`Found ${photos.length} photos to process`);
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||
const thumbnailFilename = `thumb_${photo.filename}`;
|
||||
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
|
||||
|
||||
try {
|
||||
// Check if photo file exists
|
||||
await fs.access(photoPath);
|
||||
|
||||
// Check if thumbnail already exists
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
|
||||
skipCount++;
|
||||
continue;
|
||||
} catch {
|
||||
// Thumbnail doesn't exist, generate it
|
||||
}
|
||||
|
||||
console.log(`Generating thumbnail for ${photo.filename}...`);
|
||||
const success = await generateThumbnail(photoPath, thumbnailPath);
|
||||
|
||||
if (success) {
|
||||
// Update database with thumbnail path
|
||||
await db('photos')
|
||||
.where('id', photo.id)
|
||||
.update({
|
||||
thumbnail_path: `thumbnails/${thumbnailFilename}`
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`✓ Generated thumbnail for ${photo.filename}`);
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Photo file not found: ${photoPath}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nThumbnail regeneration complete!');
|
||||
console.log(`- Successfully generated: ${successCount}`);
|
||||
console.log(`- Skipped (already exist): ${skipCount}`);
|
||||
console.log(`- Errors: ${errorCount}`);
|
||||
console.log(`- Total processed: ${photos.length}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during thumbnail regeneration:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||
|
||||
// Run the script
|
||||
regenerateThumbnails(eventId).then(() => {
|
||||
console.log('Script completed successfully');
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { db } = require('../src/database/db');
|
||||
const {
|
||||
initializeTransporter,
|
||||
processEmailQueue,
|
||||
testEmailConnection
|
||||
} = require('../src/services/emailProcessor');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function runEmailProcessor(runOnce = false) {
|
||||
try {
|
||||
logger.info('=== Starting Email Processor ===\n');
|
||||
|
||||
// Initialize transporter
|
||||
logger.info('Initializing email transporter...');
|
||||
await initializeTransporter();
|
||||
|
||||
// Test connection
|
||||
logger.info('Testing email connection...');
|
||||
const connectionOk = await testEmailConnection();
|
||||
|
||||
if (!connectionOk) {
|
||||
logger.error('Email connection test failed! Check your SMTP configuration.');
|
||||
logger.info('\nRequired environment variables:');
|
||||
logger.info('- SMTP_HOST');
|
||||
logger.info('- SMTP_PORT');
|
||||
logger.info('- SMTP_USER');
|
||||
logger.info('- SMTP_PASS');
|
||||
logger.info('- SMTP_FROM');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('Email connection test successful!\n');
|
||||
|
||||
if (runOnce) {
|
||||
// Process queue once
|
||||
logger.info('Processing email queue once...');
|
||||
await processEmailQueue();
|
||||
logger.info('Email processing complete');
|
||||
|
||||
// Show final status
|
||||
const pendingCount = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`\nEmails still pending: ${pendingCount.count}`);
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Run continuously
|
||||
logger.info('Starting continuous email processor...');
|
||||
logger.info('Processing emails every 60 seconds. Press Ctrl+C to stop.\n');
|
||||
|
||||
// Process immediately
|
||||
await processEmailQueue();
|
||||
|
||||
// Then every minute
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await processEmailQueue();
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Fatal error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n\nShutting down email processor...');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Check command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const runOnce = args.includes('--once') || args.includes('-o');
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
Email Processor Runner
|
||||
|
||||
Usage: node run-email-processor.js [options]
|
||||
|
||||
Options:
|
||||
--once, -o Process the email queue once and exit
|
||||
--help, -h Show this help message
|
||||
|
||||
By default, the processor runs continuously, checking for emails every 60 seconds.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the processor
|
||||
runEmailProcessor(runOnce);
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to test photo authentication
|
||||
* Usage: node scripts/test-photo-auth.js <jwt-token>
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
async function testPhotoAuth(token) {
|
||||
if (!token) {
|
||||
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
|
||||
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
|
||||
console.log(`Base URL: ${baseUrl}\n`);
|
||||
|
||||
// Test URLs
|
||||
const tests = [
|
||||
{
|
||||
name: 'Thumbnail via static route',
|
||||
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Photo via static route',
|
||||
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Gallery photos API',
|
||||
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(`Testing: ${test.name}`);
|
||||
console.log(`URL: ${test.url}`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(test.url, {
|
||||
headers: test.headers,
|
||||
validateStatus: () => true // Don't throw on any status
|
||||
});
|
||||
|
||||
console.log(`Status: ${response.status}`);
|
||||
console.log(`Headers:`, response.headers['content-type']);
|
||||
|
||||
if (response.status === 200) {
|
||||
if (test.name.includes('API')) {
|
||||
console.log(`Photos count: ${response.data.photos?.length || 0}`);
|
||||
} else {
|
||||
console.log(`Content length: ${response.headers['content-length']} bytes`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Error:`, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Network error:`, error.message);
|
||||
}
|
||||
|
||||
console.log('---\n');
|
||||
}
|
||||
|
||||
// Decode token to show info
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
console.log('Token payload:', payload);
|
||||
} catch (error) {
|
||||
console.log('Failed to decode token');
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from command line
|
||||
const token = process.argv[2];
|
||||
|
||||
testPhotoAuth(token).catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+15
-8
@@ -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,10 @@ const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Trust proxy headers (required for Traefik/nginx)
|
||||
// Set to specific number of proxies or loopback to be more secure
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
|
||||
// Security middleware with custom CSP
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
@@ -121,9 +125,9 @@ const authLimiter = rateLimit({
|
||||
app.use('/api/', limiter);
|
||||
app.use('/api/auth', authLimiter);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// Body parsing middleware with increased limits for large uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||
|
||||
// Maintenance mode middleware - add after body parsing but before routes
|
||||
app.use(maintenanceMiddleware);
|
||||
@@ -142,17 +146,20 @@ const setCorsHeaders = (req, res, next) => {
|
||||
// Import secure static middleware
|
||||
const secureStatic = require('./src/middleware/secureStatic');
|
||||
|
||||
// Get storage path from environment or use default
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||
|
||||
// Static file serving for photos (protected)
|
||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/events/active')));
|
||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||
|
||||
// Static file serving for thumbnails (protected)
|
||||
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/thumbnails')));
|
||||
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
|
||||
|
||||
// Static file serving for uploads (public - logos, favicons)
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, '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');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
|
||||
|
||||
describe('Database Compatibility', () => {
|
||||
// Save original env
|
||||
const originalEnv = process.env.DATABASE_CLIENT;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env after each test
|
||||
if (originalEnv) {
|
||||
process.env.DATABASE_CLIENT = originalEnv;
|
||||
} else {
|
||||
delete process.env.DATABASE_CLIENT;
|
||||
}
|
||||
});
|
||||
|
||||
describe('formatBoolean', () => {
|
||||
test('should format boolean values correctly', () => {
|
||||
// Mock for SQLite
|
||||
process.env.DATABASE_CLIENT = 'sqlite3';
|
||||
expect(formatBoolean(true)).toBe(1);
|
||||
expect(formatBoolean(false)).toBe(0);
|
||||
|
||||
// Mock for PostgreSQL
|
||||
process.env.DATABASE_CLIENT = 'pg';
|
||||
expect(formatBoolean(true)).toBe(true);
|
||||
expect(formatBoolean(false)).toBe(false);
|
||||
|
||||
// Default (no env var) should be SQLite
|
||||
delete process.env.DATABASE_CLIENT;
|
||||
expect(formatBoolean(true)).toBe(1);
|
||||
expect(formatBoolean(false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPostgreSQL', () => {
|
||||
test('should detect PostgreSQL correctly', () => {
|
||||
process.env.DATABASE_CLIENT = 'pg';
|
||||
expect(isPostgreSQL()).toBe(true);
|
||||
|
||||
process.env.DATABASE_CLIENT = 'sqlite3';
|
||||
expect(isPostgreSQL()).toBe(false);
|
||||
|
||||
delete process.env.DATABASE_CLIENT;
|
||||
expect(isPostgreSQL()).toBe(false); // Default to SQLite
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateForDB', () => {
|
||||
test('should format dates as ISO strings', () => {
|
||||
const date = new Date('2024-01-15T10:30:00Z');
|
||||
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDays', () => {
|
||||
test('should add days correctly', () => {
|
||||
const date = new Date('2024-01-15');
|
||||
const result = addDays(date, 30);
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
|
||||
|
||||
const negativeResult = addDays(date, -7);
|
||||
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAndGetId', () => {
|
||||
test('should handle PostgreSQL result format', async () => {
|
||||
const mockQuery = {
|
||||
returning: jest.fn().mockResolvedValue([{ id: 123 }])
|
||||
};
|
||||
const result = await insertAndGetId(mockQuery);
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
test('should handle SQLite result format', async () => {
|
||||
const mockQuery = {
|
||||
returning: jest.fn().mockResolvedValue([456])
|
||||
};
|
||||
const result = await insertAndGetId(mockQuery);
|
||||
expect(result).toBe(456);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
|
||||
|
||||
// Check if admin still exists and is active
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: true })
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
|
||||
|
||||
// Check if admin still exists and is active
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: true })
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
|
||||
// Allow both admin and gallery tokens
|
||||
if (decoded.type === 'admin') {
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: true })
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first();
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
@@ -10,7 +11,13 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
console.log('PhotoAuth middleware - path:', req.path);
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
@@ -25,9 +28,22 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
// Check if it's a gallery token
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we accept any valid gallery token
|
||||
// For thumbnails, we need to verify the token is for a valid event
|
||||
if (!eventSlug) {
|
||||
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
|
||||
// Extract event ID from the decoded token
|
||||
if (decoded.eventId) {
|
||||
const event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// Fallback to slug
|
||||
const event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
@@ -35,7 +51,9 @@ async function photoAuth(req, res, next) {
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (decoded.eventSlug === eventSlug) {
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
|
||||
const event = await db('events')
|
||||
.where({ slug: eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
@@ -45,18 +63,12 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
// Check if it's an admin token (admins can view all photos)
|
||||
if (decoded.type === 'admin') {
|
||||
if (!eventSlug) {
|
||||
// For thumbnails with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
const event = await db('events').where({ slug: eventSlug }).first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
console.error('JWT verification failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +79,12 @@ async function photoAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), we require JWT token
|
||||
if (!eventSlug) {
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ const sessions = new Map();
|
||||
// Default session timeout (60 minutes)
|
||||
const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
|
||||
|
||||
// Cache for session timeout setting
|
||||
let cachedTimeout = null;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Clean up expired sessions every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -18,20 +23,46 @@ setInterval(() => {
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
async function getSessionTimeout() {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached value if still valid
|
||||
if (cachedTimeout && now < cacheExpiry) {
|
||||
return cachedTimeout;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where('setting_key', 'security_session_timeout_minutes')
|
||||
.first();
|
||||
.first()
|
||||
.timeout(5000); // 5 second timeout
|
||||
|
||||
if (setting && setting.setting_value) {
|
||||
const minutes = parseInt(JSON.parse(setting.setting_value));
|
||||
return minutes * 60 * 1000; // Convert to milliseconds
|
||||
let value = setting.setting_value;
|
||||
// Handle both string and object values
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// If it's not JSON, try to parse as number directly
|
||||
value = parseInt(value);
|
||||
}
|
||||
}
|
||||
const minutes = parseInt(value);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
cachedTimeout = minutes * 60 * 1000; // Convert to milliseconds
|
||||
cacheExpiry = now + CACHE_DURATION;
|
||||
return cachedTimeout;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting session timeout:', error);
|
||||
// Only log if it's not a connection error (to avoid spam)
|
||||
if (error.code !== 'ECONNRESET' && !error.message?.includes('Connection terminated')) {
|
||||
console.error('Error getting session timeout:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_SESSION_TIMEOUT;
|
||||
// Use cached value if available, otherwise default
|
||||
return cachedTimeout || DEFAULT_SESSION_TIMEOUT;
|
||||
}
|
||||
|
||||
async function sessionTimeoutMiddleware(req, res, next) {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
|
||||
// Get total count
|
||||
const totalCount = await db('events')
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
db.raw('SUM(photos.size_bytes) as total_size')
|
||||
)
|
||||
.leftJoin('photos', 'events.id', 'photos.event_id')
|
||||
.where('events.is_archived', true)
|
||||
.where('events.is_archived', formatBoolean(true))
|
||||
.groupBy('events.id')
|
||||
.orderBy('events.archived_at', 'desc')
|
||||
.limit(limit)
|
||||
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (!archive) {
|
||||
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (!archive) {
|
||||
@@ -211,12 +212,14 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
categoriesMap.set(categoryName, existingCategory.id);
|
||||
} else {
|
||||
// Create the category if it doesn't exist
|
||||
const [newCategoryId] = await db('photo_categories').insert({
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
event_id: archive.id,
|
||||
name: categoryName,
|
||||
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||
created_at: new Date()
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
const newCategoryId = insertResult[0]?.id || insertResult[0];
|
||||
categoriesMap.set(categoryName, newCategoryId);
|
||||
}
|
||||
}
|
||||
@@ -266,6 +269,9 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Update event status
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
await db('events')
|
||||
.where('id', req.params.id)
|
||||
.update({
|
||||
@@ -273,7 +279,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
is_active: true,
|
||||
archive_path: null,
|
||||
archived_at: null,
|
||||
expires_at: db.raw("datetime('now', '+30 days')") // Reset expiration
|
||||
expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs
|
||||
});
|
||||
|
||||
// Log activity
|
||||
@@ -298,7 +304,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (!archive) {
|
||||
@@ -347,7 +353,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (!archive) {
|
||||
@@ -357,12 +363,29 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
// Delete archive file if exists
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
await fs.unlink(archive.archive_path);
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
await fs.unlink(fullArchivePath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete archive file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete thumbnails for this event
|
||||
const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path');
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
for (const photo of photos) {
|
||||
if (photo.thumbnail_path) {
|
||||
try {
|
||||
const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, ''));
|
||||
await fs.unlink(thumbPath);
|
||||
} catch (error) {
|
||||
// Ignore errors - thumbnail might already be deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (cascade will delete photos and logs)
|
||||
await db('events').where('id', req.params.id).delete();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -8,7 +9,7 @@ const router = express.Router();
|
||||
router.get('/global', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', true)
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
res.json(categories);
|
||||
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', true)
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', eventId);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
|
||||
.where('slug', categorySlug)
|
||||
.where(function() {
|
||||
if (is_global) {
|
||||
this.where('is_global', true);
|
||||
this.where('is_global', formatBoolean(true));
|
||||
} else {
|
||||
this.where('event_id', event_id);
|
||||
}
|
||||
@@ -77,12 +78,14 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
|
||||
// Create category
|
||||
const [categoryId] = await db('photo_categories').insert({
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: categorySlug,
|
||||
is_global,
|
||||
event_id: is_global ? null : event_id
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
const categoryId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
const category = await db('photo_categories').where('id', categoryId).first();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get dashboard statistics
|
||||
@@ -9,8 +10,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Get active events count
|
||||
const activeEvents = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -20,8 +21,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
const now = new Date();
|
||||
|
||||
const expiringEvents = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
||||
.where('expires_at', '>', now.toISOString())
|
||||
.count('id as count')
|
||||
@@ -56,7 +57,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
|
||||
// Get archived events count
|
||||
const archivedEvents = await db('events')
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
@@ -122,7 +123,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
|
||||
}));
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -83,7 +83,7 @@ router.post('/', adminAuth, [
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const [eventId] = await db('events').insert({
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
@@ -99,7 +99,10 @@ router.post('/', adminAuth, [
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
|
||||
@@ -11,6 +11,7 @@ const { archiveEvent } = require('../services/archiveService');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
@@ -65,7 +66,12 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
const processedEventName = event_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
@@ -92,7 +98,7 @@ router.post('/', adminAuth, [
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const [eventId] = await db('events').insert({
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
@@ -108,7 +114,10 @@ router.post('/', adminAuth, [
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
@@ -133,7 +142,9 @@ router.post('/', adminAuth, [
|
||||
gallery_password: password,
|
||||
expiry_date: await formatDate(expires_at, emailLang),
|
||||
welcome_message: welcome_message || ''
|
||||
})
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
|
||||
@@ -178,17 +189,17 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
|
||||
// Apply status filter
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', true).where('is_archived', false);
|
||||
query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', true);
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
} else if (status === 'inactive') {
|
||||
query = query.where('is_active', false).where('is_archived', false);
|
||||
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
|
||||
} else if (status === 'expiring') {
|
||||
const sevenDaysFromNow = new Date();
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
query = query
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
||||
.where('expires_at', '>', new Date().toISOString());
|
||||
}
|
||||
@@ -382,13 +393,56 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete associated photos
|
||||
await db('photos').where('event_id', id).del();
|
||||
// Start a transaction to ensure all deletions succeed or fail together
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', id).del();
|
||||
|
||||
// Delete event
|
||||
await db('events').where('id', id).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', id).del();
|
||||
|
||||
// Log activity
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', id).del();
|
||||
|
||||
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', id).del();
|
||||
|
||||
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
|
||||
await trx('photo_categories').where('event_id', id).del();
|
||||
|
||||
// 6. Finally delete the event
|
||||
await trx('events').where('id', id).del();
|
||||
|
||||
// Delete event folder from storage if it exists
|
||||
if (event.folder_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete event folder:', err);
|
||||
// Don't fail the transaction if folder deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete archive if exists
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete archive file:', err);
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
@@ -398,7 +452,19 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
res.json({ message: 'Event deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||
details: error.message
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -498,6 +564,57 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get event details
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Format dates properly for the email
|
||||
const eventDate = new Date(event.event_date);
|
||||
const expiryDate = new Date(event.expires_at);
|
||||
|
||||
// Queue the email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: eventDate.toLocaleDateString('de-DE'), // Using German date format
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: '(Aus Sicherheitsgründen nicht angezeigt)', // Security: don't show password in resent emails
|
||||
expiry_date: expiryDate.toLocaleDateString('de-DE'),
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id
|
||||
});
|
||||
|
||||
// Log the activity
|
||||
await db('activity_logs').insert({
|
||||
event_id: id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
action_type: 'email_resent',
|
||||
action_details: JSON.stringify({ email_type: 'gallery_created' }),
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resending creation email:', error);
|
||||
res.status(500).json({ error: 'Failed to resend creation email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -549,7 +666,7 @@ router.post('/bulk-archive', adminAuth, [
|
||||
// Get all events to archive
|
||||
const events = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.where('is_archived', false);
|
||||
.where('is_archived', formatBoolean(false));
|
||||
|
||||
if (events.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
|
||||
@@ -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
|
||||
@@ -91,9 +100,13 @@ router.put('/read-all', adminAuth, async (req, res) => {
|
||||
// Delete old notifications (older than 30 days and read)
|
||||
router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Use database-agnostic date calculation
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const deletedCount = await db('activity_logs')
|
||||
.whereNotNull('read_at')
|
||||
.where('created_at', '<', db.raw("datetime('now', '-30 days')"))
|
||||
.where('created_at', '<', thirtyDaysAgo)
|
||||
.delete();
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
files: 500, // Maximum 500 files
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only with proper validation
|
||||
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
|
||||
});
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
upload.array('photos', 20)(req, res, (err) => {
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
||||
}
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
@@ -136,90 +143,122 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
}
|
||||
|
||||
const uploadedPhotos = [];
|
||||
const errors = [];
|
||||
|
||||
// Process each uploaded file
|
||||
for (const file of req.files) {
|
||||
let trx;
|
||||
// Process files in batches to optimize database operations
|
||||
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
|
||||
|
||||
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
|
||||
const batch = req.files.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Start a single transaction for the batch
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
// Start transaction for atomic counter update
|
||||
trx = await db.transaction();
|
||||
|
||||
// Get and increment the counter for this category
|
||||
let counter = 1;
|
||||
// Get initial counter for this batch
|
||||
let batchCounter = 1;
|
||||
if (category) {
|
||||
// Lock the category row and get current counter
|
||||
const categoryData = await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.forUpdate()
|
||||
.first();
|
||||
|
||||
counter = (categoryData.photo_counter || 0) + 1;
|
||||
|
||||
// Update counter
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: counter });
|
||||
batchCounter = (categoryData.photo_counter || 0) + 1;
|
||||
} else {
|
||||
// For uncategorized photos, count existing uncategorized photos
|
||||
const uncategorizedCount = await trx('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('category_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
counter = (uncategorizedCount.count || 0) + 1;
|
||||
batchCounter = (uncategorizedCount.count || 0) + 1;
|
||||
}
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
const batchPhotos = [];
|
||||
|
||||
// Rename the file
|
||||
const oldPath = file.path;
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
await fs.rename(oldPath, newPath);
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
|
||||
try {
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
|
||||
// Rename the file
|
||||
const oldPath = file.path;
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
await fs.rename(oldPath, newPath);
|
||||
|
||||
// Update file object
|
||||
file.filename = newFilename;
|
||||
file.path = newPath;
|
||||
|
||||
// Generate thumbnail with new filename
|
||||
const thumbnailPath = await generateThumbnail(file.path);
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
|
||||
const relativeThumbPath = thumbnailPath;
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
batchPhotos.push({
|
||||
event_id: eventId,
|
||||
filename: file.filename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: parsedCategoryId || null,
|
||||
type: 'individual',
|
||||
size_bytes: file.size
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.originalname}:`, error);
|
||||
errors.push({ filename: file.originalname, error: error.message });
|
||||
// Delete the file if it was partially processed
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update file object
|
||||
file.filename = newFilename;
|
||||
file.path = newPath;
|
||||
// Batch insert all photos from this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
// Update category counter if needed
|
||||
if (category) {
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
|
||||
}
|
||||
|
||||
// Add to uploaded photos array
|
||||
batchPhotos.forEach((photo, index) => {
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[index]?.id || insertedIds[index],
|
||||
filename: photo.filename,
|
||||
size: photo.size_bytes,
|
||||
category_id: photo.category_id
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate thumbnail with new filename
|
||||
const thumbnailPath = await generateThumbnail(file.path);
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database
|
||||
const [photoId] = await trx('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: file.filename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: parsedCategoryId || null,
|
||||
type: 'individual', // Keep for backwards compatibility
|
||||
size_bytes: file.size
|
||||
});
|
||||
|
||||
// Commit transaction
|
||||
// Commit the batch transaction
|
||||
await trx.commit();
|
||||
|
||||
uploadedPhotos.push({
|
||||
id: photoId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
category_id: parsedCategoryId || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.filename}:`, error);
|
||||
if (trx) await trx.rollback();
|
||||
// Continue with other files
|
||||
console.error(`Error processing batch starting at index ${i}:`, error);
|
||||
await trx.rollback();
|
||||
|
||||
// Try to clean up files from failed batch
|
||||
for (const file of batch) {
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +269,22 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
// Prepare response
|
||||
const response = {
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
photos: uploadedPhotos,
|
||||
totalFiles: req.files.length,
|
||||
successCount: uploadedPhotos.length,
|
||||
failureCount: errors.length
|
||||
};
|
||||
|
||||
// Include error details if any files failed
|
||||
if (errors.length > 0) {
|
||||
response.errors = errors;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
@@ -501,8 +552,8 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -595,4 +646,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
|
||||
const photos = await db('photos').where({ event_id: eventId }).limit(5);
|
||||
|
||||
res.json({
|
||||
event: event || 'Not found',
|
||||
photoCount: photoCount.count,
|
||||
samplePhotos: photos,
|
||||
storagePath: getStoragePath()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const router = express.Router();
|
||||
@@ -513,17 +514,21 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
|
||||
// Get archive storage
|
||||
const archives = await db('events')
|
||||
.where('is_archived', true)
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.whereNotNull('archive_path')
|
||||
.select('archive_path');
|
||||
|
||||
let archiveStorage = 0;
|
||||
for (const archive of archives) {
|
||||
try {
|
||||
const stats = await fs.stat(archive.archive_path);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -35,14 +36,30 @@ router.get('/version', adminAuth, async (req, res) => {
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Database size
|
||||
const dbPath = path.join(__dirname, '../../data/photo_sharing.db');
|
||||
// Database size - check if PostgreSQL or SQLite
|
||||
let dbSize = 0;
|
||||
try {
|
||||
const stats = await fs.stat(dbPath);
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
console.error('Error getting database size:', error);
|
||||
const dbClient = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
|
||||
if (dbClient === 'pg') {
|
||||
// PostgreSQL - query database size
|
||||
try {
|
||||
const dbName = process.env.DB_NAME || 'picpeak';
|
||||
const result = await db.raw(`
|
||||
SELECT pg_database_size(?) as size
|
||||
`, [dbName]);
|
||||
dbSize = result.rows[0]?.size || 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting PostgreSQL database size:', error);
|
||||
}
|
||||
} else {
|
||||
// SQLite - check file size
|
||||
const dbPath = path.join(__dirname, '../../data/photo_sharing.db');
|
||||
try {
|
||||
const stats = await fs.stat(dbPath);
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
console.error('Error getting SQLite database size:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Count various entities
|
||||
@@ -53,12 +70,46 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
|
||||
// Email queue status
|
||||
const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
const [processableEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count');
|
||||
const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count');
|
||||
const [stuckEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count');
|
||||
|
||||
// Activity logs count
|
||||
const [activityCount] = await db('activity_logs').count('* as count');
|
||||
|
||||
// Storage info
|
||||
const [{ totalPhotoStorage }] = await db('photos')
|
||||
.sum('size_bytes as totalPhotoStorage');
|
||||
|
||||
const archives = await db('events')
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.whereNotNull('archive_path')
|
||||
.select('archive_path');
|
||||
|
||||
let archiveStorage = 0;
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
|
||||
|
||||
// System info
|
||||
const systemInfo = {
|
||||
platform: os.platform(),
|
||||
@@ -89,8 +140,15 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
activityLogs: activityCount.count
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
totalUsed: totalStorage,
|
||||
photoStorage: parseInt(totalPhotoStorage) || 0,
|
||||
archiveStorage: archiveStorage
|
||||
},
|
||||
emailQueue: {
|
||||
pending: pendingEmails.count,
|
||||
processable: processableEmails.count,
|
||||
stuck: stuckEmails.count,
|
||||
sent: sentEmails.count,
|
||||
failed: failedEmails.count
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -76,7 +77,7 @@ router.post('/gallery/verify', [
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
@@ -118,7 +119,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
hero_photo_id: event.hero_photo_id
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
@@ -64,7 +65,7 @@ router.post('/', adminAuth, [
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const [eventId] = await db('events').insert({
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
@@ -76,7 +77,10 @@ router.post('/', adminAuth, [
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
});
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
@@ -110,9 +114,9 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
let query = db('events').select('*');
|
||||
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', true);
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', true);
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
}
|
||||
|
||||
const events = await query.orderBy('created_at', 'desc');
|
||||
@@ -159,7 +163,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db('events').where('id', id).update({ is_active: false });
|
||||
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -185,7 +189,7 @@ router.post('/:id/extend', adminAuth, [
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: true // Reactivate if expired
|
||||
is_active: formatBoolean(true) // Reactivate if expired
|
||||
});
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
|
||||
@@ -1,46 +1,23 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: true, is_archived: false })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
res.status(401).json({ error: 'Invalid token', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
try {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: true, is_archived: false })
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
.first();
|
||||
|
||||
@@ -83,7 +60,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
const expectedToken = event.share_link.split('/').pop();
|
||||
let expectedToken = event.share_link;
|
||||
// Handle both formats: full URL or just token
|
||||
if (event.share_link && event.share_link.includes('/')) {
|
||||
expectedToken = event.share_link.split('/').pop();
|
||||
}
|
||||
if (token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
@@ -121,7 +102,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
// Get all categories for this event
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', true)
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', req.event.id);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
@@ -155,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/photos/${photo.path}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
|
||||
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -339,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get photo stats
|
||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
// Get event
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.where('is_active', true)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
|
||||
// Generate hash from config for change detection
|
||||
function generateConfigHash(config) {
|
||||
const crypto = require('crypto');
|
||||
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
|
||||
return crypto.createHash('md5').update(configString).digest('hex');
|
||||
}
|
||||
|
||||
// Initialize transporter from database config
|
||||
async function initializeTransporter() {
|
||||
async function initializeTransporter(forceReinit = false) {
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
|
||||
@@ -14,6 +23,16 @@ async function initializeTransporter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if configuration has changed
|
||||
const currentConfigHash = generateConfigHash(config);
|
||||
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
|
||||
// Configuration hasn't changed, return existing transporter
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Configuration has changed or first initialization
|
||||
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp_host,
|
||||
port: config.smtp_port,
|
||||
@@ -28,23 +47,50 @@ async function initializeTransporter() {
|
||||
await transporter.verify();
|
||||
logger.info('Email transporter initialized successfully');
|
||||
|
||||
// Update the config hash
|
||||
lastConfigHash = currentConfigHash;
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize email transporter:', error);
|
||||
transporter = null;
|
||||
lastConfigHash = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the appropriate language for a recipient
|
||||
async function getRecipientLanguage(email) {
|
||||
// For now, check if the email domain ends with .de
|
||||
// In the future, this could check user preferences
|
||||
if (email && email.endsWith('.de')) {
|
||||
return 'de';
|
||||
async function getRecipientLanguage(email, eventId = null) {
|
||||
// First priority: Check event language setting if eventId is provided
|
||||
if (eventId) {
|
||||
try {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (event && event.language) {
|
||||
return event.language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event language:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a saved preference for this email
|
||||
// This could be expanded to check user preferences in the database
|
||||
// Second priority: Check email configs for default language
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig && emailConfig.default_language) {
|
||||
return emailConfig.default_language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Third priority: Check if the email domain suggests German
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
@@ -93,27 +139,15 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||
|
||||
// Process welcome message section if present
|
||||
let welcomeMessageSection = '';
|
||||
if (variables.welcome_message && variables.welcome_message.trim() !== '') {
|
||||
const welcomeTitle = language === 'de' ? 'Persönliche Nachricht:' : 'Personal Message:';
|
||||
welcomeMessageSection = `
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">${welcomeTitle}</p>
|
||||
<p style="margin: 0; color: #4b5563;">${variables.welcome_message}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Replace variables
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
subject = subject.replace(regex, value || '');
|
||||
htmlBody = htmlBody.replace(regex, value || '');
|
||||
textBody = textBody.replace(regex, value || '');
|
||||
});
|
||||
|
||||
// Replace welcome message section placeholder
|
||||
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with variables
|
||||
subject = subjectTemplate(variables);
|
||||
htmlBody = htmlTemplate(variables);
|
||||
textBody = textTemplate(variables);
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
@@ -256,11 +290,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
// Send email using template
|
||||
async function sendTemplateEmail(to, templateKey, variables) {
|
||||
try {
|
||||
// Always check for configuration changes before sending
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
// Get email template
|
||||
@@ -278,8 +311,8 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language
|
||||
const language = await getRecipientLanguage(to);
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
@@ -303,14 +336,33 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
try {
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
logger.info('Transporter not initialized, attempting to initialize...');
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,7 +370,9 @@ async function processEmailQueue() {
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = JSON.parse(email.email_data || '{}');
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data || '{}')
|
||||
: email.email_data || {};
|
||||
|
||||
await sendTemplateEmail(
|
||||
email.recipient_email,
|
||||
@@ -337,13 +391,24 @@ async function processEmailQueue() {
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
// Increment retry count
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message,
|
||||
updated_at: new Date()
|
||||
});
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
|
||||
// If update fails due to column issue, try without any potential auto-added fields
|
||||
if (updateError.message && updateError.message.includes('updated_at')) {
|
||||
logger.warn('Detected updated_at column issue, attempting raw query...');
|
||||
await db.raw(
|
||||
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
|
||||
[email.retry_count + 1, error.message, email.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to send email ${email.id}:`, error);
|
||||
}
|
||||
@@ -356,6 +421,8 @@ async function processEmailQueue() {
|
||||
// Queue an email for sending
|
||||
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
try {
|
||||
// Add eventId to emailData for language detection
|
||||
emailData.eventId = eventId;
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: recipientEmail,
|
||||
@@ -373,17 +440,45 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test email connection
|
||||
async function testEmailConnection() {
|
||||
try {
|
||||
if (!transporter) {
|
||||
await initializeTransporter();
|
||||
}
|
||||
if (!transporter) {
|
||||
return false;
|
||||
}
|
||||
await transporter.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Email connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start email queue processor
|
||||
let emailQueueInterval = null;
|
||||
|
||||
function startEmailQueueProcessor() {
|
||||
logger.info('Email queue processor: Attempting to start...');
|
||||
|
||||
if (!emailQueueInterval) {
|
||||
// Process immediately on start
|
||||
processEmailQueue();
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Initial processing failed:', err);
|
||||
});
|
||||
|
||||
// Then process every minute
|
||||
emailQueueInterval = setInterval(processEmailQueue, 60000);
|
||||
logger.info('Email queue processor started');
|
||||
emailQueueInterval = setInterval(() => {
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Periodic processing failed:', err);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
logger.info('Email queue processor started successfully');
|
||||
} else {
|
||||
logger.info('Email queue processor: Already running');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +502,6 @@ module.exports = {
|
||||
sendTemplateEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
startEmailQueueProcessor,
|
||||
stopEmailQueueProcessor
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const { archiveEvent } = require('./archiveService');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
function startExpirationChecker() {
|
||||
// Check every hour for expired events and warnings
|
||||
@@ -21,8 +22,8 @@ async function checkExpirations() {
|
||||
|
||||
// Check for events needing warning emails
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
@@ -40,8 +41,8 @@ async function checkExpirations() {
|
||||
|
||||
// Check for expired events
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
@@ -74,7 +75,7 @@ async function queueExpirationWarning(event) {
|
||||
async function handleExpiredEvent(event) {
|
||||
try {
|
||||
// Mark as inactive
|
||||
await db('events').where('id', event.id).update({ is_active: false });
|
||||
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
// Queue expiration emails
|
||||
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
||||
|
||||
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
|
||||
// Find the event
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) return;
|
||||
|
||||
// Get file stats
|
||||
|
||||
@@ -2,6 +2,10 @@ const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Configure sharp for better memory management with large batches
|
||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||
sharp.concurrency(2); // Limit concurrent operations
|
||||
|
||||
const THUMBNAIL_WIDTH = 300;
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
|
||||
// Ensure thumbnail directory exists
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
|
||||
// Generate thumbnail
|
||||
await sharp(imagePath)
|
||||
.resize(THUMBNAIL_WIDTH, null, {
|
||||
withoutEnlargement: true,
|
||||
fit: 'inside'
|
||||
try {
|
||||
// Generate thumbnail with memory-efficient settings
|
||||
await sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true // More memory efficient for large images
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
.resize(THUMBNAIL_WIDTH, null, {
|
||||
withoutEnlargement: true,
|
||||
fit: 'inside'
|
||||
})
|
||||
.jpeg({
|
||||
quality: 80,
|
||||
progressive: true, // Progressive JPEG for better loading
|
||||
mozjpeg: true // Better compression
|
||||
})
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${filename}:`, error);
|
||||
// Return null if thumbnail generation fails, don't fail the whole upload
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail };
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Configuration constants
|
||||
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
await db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', false)
|
||||
.where('success', formatBoolean(false))
|
||||
.where('attempt_time', '<', cutoffTime.toISOString())
|
||||
.delete();
|
||||
} catch (error) {
|
||||
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
|
||||
// Get recent failed attempts
|
||||
const failedAttempts = await db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', false)
|
||||
.where('success', formatBoolean(false))
|
||||
.where('attempt_time', '>=', recentWindow.toISOString())
|
||||
.orderBy('attempt_time', 'desc')
|
||||
.limit(MAX_LOGIN_ATTEMPTS);
|
||||
|
||||
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
|
||||
try {
|
||||
// Get date format setting from database
|
||||
const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
|
||||
const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT;
|
||||
let dateConfig = DEFAULT_FORMAT;
|
||||
|
||||
if (setting && setting.setting_value) {
|
||||
// Handle both string and object values
|
||||
if (typeof setting.setting_value === 'string') {
|
||||
try {
|
||||
dateConfig = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse date format setting:', e.message);
|
||||
dateConfig = DEFAULT_FORMAT;
|
||||
}
|
||||
} else {
|
||||
dateConfig = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Database Compatibility Utilities
|
||||
* Handles differences between PostgreSQL and SQLite
|
||||
*/
|
||||
|
||||
// Note: Requiring db here creates circular dependency
|
||||
// db should be passed as parameter or required where needed
|
||||
|
||||
/**
|
||||
* Get database client type
|
||||
* @returns {string} 'pg' or 'sqlite3'
|
||||
*/
|
||||
function getDbClient() {
|
||||
return process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using PostgreSQL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPostgreSQL() {
|
||||
return getDbClient() === 'pg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle insert operations that return IDs
|
||||
* Works with both PostgreSQL and SQLite
|
||||
* @param {object} query - Knex query builder
|
||||
* @returns {Promise<number>} The inserted ID
|
||||
*/
|
||||
async function insertAndGetId(query) {
|
||||
const result = await query.returning('id');
|
||||
|
||||
// PostgreSQL returns array of objects [{id: 1}]
|
||||
// SQLite returns array of IDs [1]
|
||||
return result[0]?.id || result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for database compatibility
|
||||
* @param {Date} date - JavaScript Date object
|
||||
* @returns {string} ISO string format that works on both databases
|
||||
*/
|
||||
function formatDateForDB(date) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date (database agnostic)
|
||||
* @param {Date} date - Starting date
|
||||
* @param {number} days - Number of days to add
|
||||
* @returns {Date} New date
|
||||
*/
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date extraction SQL that works on both databases
|
||||
* @param {object} db - Knex database instance
|
||||
* @param {string} column - Column name
|
||||
* @returns {object} Knex raw query
|
||||
*/
|
||||
function dateExtractSQL(db, column) {
|
||||
if (isPostgreSQL()) {
|
||||
return db.raw(`DATE(${column})`);
|
||||
} else {
|
||||
// SQLite uses date() function
|
||||
return db.raw(`date(${column})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database size query
|
||||
* @param {object} db - Knex database instance
|
||||
* @param {string} dbName - Database name
|
||||
* @returns {Promise<number>} Size in bytes
|
||||
*/
|
||||
async function getDatabaseSize(db, dbName) {
|
||||
if (isPostgreSQL()) {
|
||||
const result = await db.raw('SELECT pg_database_size(?) as size', [dbName]);
|
||||
return result.rows[0]?.size || 0;
|
||||
} else {
|
||||
// For SQLite, check file size
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../data/photo_sharing.db');
|
||||
try {
|
||||
const stats = await fs.stat(dbPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
console.error('Error getting SQLite database size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle boolean values for database compatibility
|
||||
* @param {boolean} value - Boolean value
|
||||
* @returns {any} Database-appropriate boolean representation
|
||||
*/
|
||||
function formatBoolean(value) {
|
||||
if (isPostgreSQL()) {
|
||||
return value;
|
||||
} else {
|
||||
// SQLite stores booleans as 0/1
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse boolean from database
|
||||
* @param {any} value - Database boolean value
|
||||
* @returns {boolean} JavaScript boolean
|
||||
*/
|
||||
function parseBoolean(value) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDbClient,
|
||||
isPostgreSQL,
|
||||
insertAndGetId,
|
||||
formatDateForDB,
|
||||
addDays,
|
||||
dateExtractSQL,
|
||||
getDatabaseSize,
|
||||
formatBoolean,
|
||||
parseBoolean
|
||||
};
|
||||
@@ -8,13 +8,13 @@ const logger = require('./logger');
|
||||
|
||||
// Configuration
|
||||
const PASSWORD_CONFIG = {
|
||||
minLength: 12,
|
||||
minLength: 8, // Reduced from 12 to 8 for better usability
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true,
|
||||
requireSpecialChars: false, // Made optional for gallery passwords
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 3, // zxcvbn score (0-4, where 3 is "good")
|
||||
minStrengthScore: 2, // Reduced from 3 to 2 (moderate strength)
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS) || 12 // Configurable, default 12
|
||||
};
|
||||
|
||||
@@ -78,6 +78,16 @@ function validatePassword(password, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip zxcvbn check if explicitly disabled (for gallery passwords)
|
||||
if (options.skipStrengthCheck) {
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
score: 2, // Default moderate score for gallery passwords
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Use zxcvbn for strength analysis
|
||||
const strength = zxcvbn(password);
|
||||
|
||||
@@ -111,7 +121,52 @@ function validatePassword(password, options = {}) {
|
||||
* @returns {Object} - Validation result
|
||||
*/
|
||||
function validatePasswordInContext(password, context, userData = {}) {
|
||||
// Base validation
|
||||
// For gallery context, use more lenient validation
|
||||
if (context === 'gallery') {
|
||||
// Gallery-specific validation options
|
||||
const galleryOptions = {
|
||||
minLength: 6, // Reduced minimum length
|
||||
requireUppercase: false, // Don't require uppercase for galleries
|
||||
requireLowercase: false, // Don't require lowercase for galleries
|
||||
requireNumbers: false, // Numbers are optional
|
||||
requireSpecialChars: false, // Special chars are optional
|
||||
preventCommonPasswords: true, // Still prevent common passwords
|
||||
minStrengthScore: 0, // Accept any score for galleries
|
||||
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
|
||||
};
|
||||
|
||||
// Base validation with gallery-specific options
|
||||
const result = validatePassword(password, galleryOptions);
|
||||
|
||||
// Override validation for common date formats
|
||||
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
|
||||
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||
if (datePattern.test(password)) {
|
||||
// Date format is valid for gallery passwords
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
score: 2,
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Additional gallery-specific checks
|
||||
if (password.length < 6) {
|
||||
result.valid = false;
|
||||
result.errors = ['Password must be at least 6 characters long'];
|
||||
}
|
||||
|
||||
// Check if it's too simple (e.g., just "123456")
|
||||
if (/^\d{1,6}$/.test(password)) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Base validation for other contexts
|
||||
const result = validatePassword(password);
|
||||
|
||||
// Context-specific validation
|
||||
@@ -136,19 +191,6 @@ function validatePasswordInContext(password, context, userData = {}) {
|
||||
result.errors.push('Password must not contain parts of your email');
|
||||
}
|
||||
}
|
||||
} else if (context === 'gallery') {
|
||||
// Gallery passwords can be slightly less strict
|
||||
// but still need to be secure
|
||||
if (result.score < 2) {
|
||||
result.valid = false;
|
||||
result.errors.push('Gallery passwords must have moderate strength or better');
|
||||
}
|
||||
|
||||
// Check password doesn't contain event name
|
||||
if (userData.eventName && password.toLowerCase().includes(userData.eventName.toLowerCase())) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password must not contain the event name');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -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 "$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user