Compare commits
295 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a75f1c929 | |||
| bce5f749b1 | |||
| 584cfb11df | |||
| f327f4cbcd | |||
| 14c4bc17f3 | |||
| 3d0a4564b6 | |||
| e85d1bf72a | |||
| bd3aa6206b | |||
| a971eee7b9 | |||
| 8e8dd358bf | |||
| ee1aa7e5cb | |||
| f446335e81 | |||
| d91ab436e8 | |||
| 0745b11745 | |||
| 97589a7c5f | |||
| 9f04da6956 | |||
| 62e6a67cb7 | |||
| b2ce011545 | |||
| 2f0fd7e360 | |||
| ae93755dbb | |||
| b2626918d3 | |||
| 41628b0578 | |||
| 8826fb7a12 | |||
| f29e9db99d | |||
| 81416737e8 | |||
| d2e97567a9 | |||
| 69538b86ea | |||
| f6f1c31369 | |||
| b76e45cb54 | |||
| 5b5e431b08 | |||
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e | |||
| 8c690155bf | |||
| 1b1e4f715d | |||
| 68eb9ba552 | |||
| 7040865154 | |||
| 013be18d98 | |||
| 3c2a79a31a | |||
| f20472ca26 | |||
| a1e9fb6ffc | |||
| 87f4526220 | |||
| 665ce5a6e7 | |||
| d42a11680f | |||
| 8c41dd626d | |||
| 38dd74b893 | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d | |||
| e0865b81b6 | |||
| d4404e39bd | |||
| 8611206396 | |||
| 39d2244e1e | |||
| eb626be22c | |||
| aaaf59817b | |||
| 2a4d38813f | |||
| ad9c6d63d3 | |||
| 8c77b30de6 | |||
| e51347d0a1 | |||
| 71e7179145 | |||
| bda76ff513 | |||
| 097ce2c205 | |||
| 1d8be3d840 | |||
| aebb8e66cb | |||
| ed2a278da2 | |||
| db2f5da66a | |||
| 19f8facc49 | |||
| b03760ab01 | |||
| 526dcd8dfc | |||
| 5b2561b6f1 | |||
| 3a6d06192a | |||
| 4b64b80b20 | |||
| ff89f96e31 | |||
| 465f997752 | |||
| 6948aaa92a | |||
| 4c7b49a5f6 | |||
| 6368f1027f | |||
| d64e7d08de | |||
| eb3751cb52 | |||
| 9fda54bd06 | |||
| 0d77a3a0a8 | |||
| 0618b78725 | |||
| 0178e71c67 | |||
| aa9b3a0227 | |||
| 410a33fecf | |||
| 05ebaaeedb | |||
| 84d0f63d36 | |||
| 6a4b549d9f | |||
| f3604b438b | |||
| 531831e84b | |||
| 90bb21e38b | |||
| 2f1a137342 | |||
| adf576fbe1 | |||
| 4264026bbe | |||
| 24b4a314a9 | |||
| ba825823a0 | |||
| fb16b7bbb8 | |||
| 8404125ff0 | |||
| 61ad2d61c1 | |||
| 9fd6b44487 | |||
| 9fe10bcce2 | |||
| f2abb40987 | |||
| 3697344cd0 | |||
| 4aa0ff705f | |||
| dc482e614a | |||
| 448882cfef | |||
| 7f9cb33a40 | |||
| 798f6211e0 | |||
| b992b151d3 | |||
| 87b8414e44 | |||
| ee13556c5c | |||
| afeb35a446 | |||
| ab324f1928 | |||
| 78ab0ad2e9 | |||
| 49c77785e7 | |||
| 1d826accdc | |||
| ceefe4f5a7 | |||
| e9171c7115 | |||
| 758c085467 | |||
| ecbc48815d | |||
| e91b138154 | |||
| dad1787aad | |||
| 909e760447 | |||
| 41857ec499 | |||
| f7a8765f58 | |||
| 214f120f7a | |||
| f26becad1d | |||
| 67ff415840 | |||
| 88659f1fa6 | |||
| c1e10f14a3 | |||
| 0881a0fa71 | |||
| e91209f7cb | |||
| 828d6bc456 | |||
| f945573f09 | |||
| 296430e4d7 | |||
| 7b517fa290 | |||
| 2c9a56f217 | |||
| 986b101040 | |||
| 0c283717cb | |||
| 4029559954 | |||
| 9c943bc69a | |||
| 29a8ff914c | |||
| a73d217273 | |||
| 1b4b497fdf | |||
| 827eb4819b | |||
| 086a4ca342 | |||
| 6de64a1df1 | |||
| 3074748bbc | |||
| 934d6ddc58 | |||
| a699a0477b | |||
| ed0243ec39 | |||
| ac31798bf5 | |||
| 65d796b9f0 | |||
| 6389b9df3f | |||
| 87d1761091 | |||
| fda132eed4 | |||
| 1cadce196b | |||
| 840b8870ec | |||
| ad495a92c4 | |||
| b428543452 | |||
| 6492cb9ec8 | |||
| 0e0a0b91d1 | |||
| f8fb1c3f4b | |||
| b108f6fe1c | |||
| 61299a33c4 | |||
| 96542d7e35 | |||
| ee855a3502 | |||
| 02c407d431 | |||
| 62617f627f | |||
| 1cbeb75094 | |||
| baa08e9ec9 | |||
| 8d85454ef6 | |||
| 596bba2c1b | |||
| 055de06315 | |||
| ccf59d1d4d | |||
| 4e052966d3 | |||
| ba2c021c45 | |||
| 519518ed6c | |||
| 8a0a4436b0 | |||
| 9854ca2f59 | |||
| 0c989ce086 | |||
| 35e360dcf7 | |||
| a209796b16 | |||
| 7c79052681 | |||
| d560453982 | |||
| ecb3263267 | |||
| 4d929a71ce | |||
| bf705674d5 | |||
| fee369a503 | |||
| ad75818566 | |||
| 65f2c8610d | |||
| 517128fd99 | |||
| 55c8384a25 | |||
| 0064122eff | |||
| 0c783c66d0 | |||
| 47e2351dab | |||
| e1aca6b00c | |||
| 2cb6577f26 | |||
| c51d756503 | |||
| c48b9780df | |||
| 618e2695fd | |||
| a289f97a31 | |||
| 7387a5e9f9 | |||
| 4615a5d795 | |||
| f926cd3adf | |||
| 96b05b5e0c | |||
| 99e47785e4 | |||
| 94f10e1645 | |||
| fe4a476e41 | |||
| e9f92e66d0 | |||
| 58f4217756 | |||
| 76a466c077 | |||
| 9dc3777985 | |||
| e006d73831 | |||
| 63e88c8324 | |||
| 105167fb57 | |||
| 22cc40617f | |||
| 7f28917795 | |||
| 77af2a8415 | |||
| 4c42b4c601 | |||
| 856cdc214c | |||
| 15b244292f | |||
| 558a966f85 | |||
| 1238db58c2 | |||
| 0502ed34c9 | |||
| 1f417c7e30 | |||
| a401fbdc54 | |||
| 247e154afe | |||
| cbfd84ddea | |||
| dc1419c051 | |||
| 2624ea6130 | |||
| 08eeac66eb | |||
| 11769219e4 | |||
| 7750170832 | |||
| 833591681a | |||
| b77e60c37c | |||
| 30f6780484 | |||
| aa39e132aa | |||
| f6a79c815e | |||
| 3c6837bd90 | |||
| 1773ed5f95 | |||
| 62dcaf8555 | |||
| 4ed35f1b16 | |||
| 5eff7dd4a6 | |||
| abbcdb1113 | |||
| 17fc40e65d | |||
| a54a2c0fda | |||
| febacb79ad | |||
| c7875102c5 | |||
| 3dc013d7b1 | |||
| b7c8953cb4 | |||
| d6adde4e09 | |||
| 0bf4764a07 | |||
| 08da01f021 | |||
| b4b09c1650 | |||
| b2ae5f18ad | |||
| 4a7a3bba07 | |||
| b3f240b2a5 | |||
| ba0bf11a1d | |||
| d5790ad635 | |||
| e6757bd51b | |||
| 8588133a4e | |||
| 4b18077573 | |||
| c584369d5d | |||
| 95939d57e6 | |||
| be58146dc7 | |||
| 45ce98806d | |||
| 23b7a848ab | |||
| 0fe6d738b2 | |||
| 3f73d44c5a |
@@ -1,286 +0,0 @@
|
||||
# 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*
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
steps:
|
||||
# Build Backend Release
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# Build Frontend Release
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
-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
|
||||
+69
-26
@@ -1,34 +1,77 @@
|
||||
# Environment Configuration Template
|
||||
# Copy this file to .env and adjust values for your environment
|
||||
# PicPeak Environment Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# Development: Use docker-compose.dev.yml
|
||||
# Production: Use docker-compose.prod.yml with .env.production.example
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (CRITICAL for production)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Application URLs
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Database Configuration
|
||||
# SQLite is used for development by default
|
||||
# For production PostgreSQL config, see .env.production.example
|
||||
DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=./data/photo_sharing.db
|
||||
# Redis Configuration
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
|
||||
# Email Configuration
|
||||
# Development: Uses Mailhog (included in docker-compose.dev.yml)
|
||||
# Production: Configure real SMTP server
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Optional: Umami Analytics
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_HASH_SALT=
|
||||
# Application URLs
|
||||
# Use full origin with scheme, no trailing slash.
|
||||
# Admin UI is served by the frontend at /admin.
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
# FRONTEND_PORT=3000
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Runtime user mapping for Docker (optional)
|
||||
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
|
||||
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
|
||||
PUID=1001
|
||||
PGID=1001
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
|
||||
# Storage variables (host paths)
|
||||
# These control where data is stored on the host. Defaults are local folders.
|
||||
APP_STORAGE=./storage
|
||||
APP_DATA=./data
|
||||
LOGS=./logs
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Production Environment Configuration Template
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Application URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# 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
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration
|
||||
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
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=your-random-hash-salt
|
||||
|
||||
# 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
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Test and Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend linting
|
||||
working-directory: ./backend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm test || true # Continue on test failures for now
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ./frontend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: 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"
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $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 ..
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- 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 }}"
|
||||
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 }}"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
needs: version-bump
|
||||
if: needs.version-bump.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -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.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
|
||||
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
|
||||
- 🏷️ **Smart tagging** based on branches, versions, and commits
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
|
||||
## Authentication
|
||||
|
||||
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
|
||||
|
||||
### Required Permissions
|
||||
|
||||
The workflow automatically sets the necessary permissions:
|
||||
- `contents: read` - To checkout the repository
|
||||
- `packages: write` - To push images to ghcr.io
|
||||
- `security-events: write` - To upload security scan results
|
||||
|
||||
## Image Tags
|
||||
|
||||
Images are automatically tagged based on the trigger event:
|
||||
|
||||
| Event | Tags Generated |
|
||||
|-------|---------------|
|
||||
| Push to main | `latest`, `main`, `main-<short-sha>` |
|
||||
| Push to develop | `develop`, `develop-<short-sha>` |
|
||||
| Pull Request | `pr-<number>` |
|
||||
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
| Manual trigger | Based on branch + optional push |
|
||||
|
||||
## Usage
|
||||
|
||||
### Pull Images
|
||||
|
||||
Once published, images can be pulled using:
|
||||
|
||||
```bash
|
||||
# Pull backend image
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
```
|
||||
|
||||
### Using in Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: picpeak-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
## Manual Workflow Trigger
|
||||
|
||||
You can manually trigger the workflow from the Actions tab:
|
||||
|
||||
1. Go to Actions → "Build and Push Docker Images"
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and whether to push images
|
||||
4. Click "Run workflow"
|
||||
|
||||
## Security Scanning
|
||||
|
||||
The workflow includes Trivy vulnerability scanning that:
|
||||
- Scans for CRITICAL and HIGH severity vulnerabilities
|
||||
- Uploads results to GitHub Security tab
|
||||
- Available under Security → Code scanning alerts
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The workflow uses several optimization techniques:
|
||||
|
||||
1. **GitHub Actions Cache**: Speeds up builds by caching layers
|
||||
2. **Multi-stage builds**: Reduces final image size
|
||||
3. **Parallel builds**: Backend and frontend build simultaneously
|
||||
4. **Smart rebuilds**: Only rebuilds changed components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
If you encounter permission errors when pushing images:
|
||||
|
||||
1. **First-time setup**: The first push creates a private package. You may need to:
|
||||
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
|
||||
- Link the package to your repository
|
||||
- Set package visibility (public/private)
|
||||
|
||||
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
|
||||
|
||||
### Build Failures
|
||||
|
||||
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
|
||||
- Missing dependencies in package.json
|
||||
- Dockerfile syntax errors
|
||||
- Network issues during package installation
|
||||
|
||||
### Image Not Found
|
||||
|
||||
If images aren't visible after successful push:
|
||||
- Check package visibility settings
|
||||
- Ensure you're authenticated to pull private images:
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
### View Packages
|
||||
|
||||
Your Docker images are available at:
|
||||
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
|
||||
|
||||
### Delete Old Versions
|
||||
|
||||
To save storage, you can delete old versions:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage versions"
|
||||
3. Select versions to delete
|
||||
4. Click "Delete selected versions"
|
||||
|
||||
### Set Retention Policy
|
||||
|
||||
Configure automatic cleanup in package settings:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage Actions access"
|
||||
3. Set retention days for untagged versions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic versioning** for releases (e.g., v1.2.3)
|
||||
2. **Test images locally** before pushing to production
|
||||
3. **Monitor security alerts** from Trivy scans
|
||||
4. **Clean up old images** regularly to save storage
|
||||
5. **Use specific tags** in production (avoid `latest`)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Registry
|
||||
|
||||
To use a different registry, update the workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: docker.io # or your custom registry
|
||||
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
|
||||
```
|
||||
|
||||
### Additional Platforms
|
||||
|
||||
To build for more platforms:
|
||||
|
||||
```yaml
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
```
|
||||
|
||||
### Custom Build Arguments
|
||||
|
||||
Add build arguments in the workflow:
|
||||
|
||||
```yaml
|
||||
build-args: |
|
||||
NODE_VERSION=20
|
||||
API_URL=${{ secrets.API_URL }}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
||||
- [Docker Build Action](https://github.com/docker/build-push-action)
|
||||
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
|
||||
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -0,0 +1,251 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push images to registry'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
|
||||
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
|
||||
# For main/develop/tags, build multi-arch
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# For PRs, build only amd64 to avoid QEMU emulation issues
|
||||
# For main/develop/tags, build multi-arch
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
|
||||
# automatically creates multi-arch manifests when building for multiple platforms.
|
||||
# The images are already properly tagged and include all architectures.
|
||||
|
||||
summary:
|
||||
needs: [build-backend, build-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -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
|
||||
+28
@@ -11,6 +11,9 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Docker override file
|
||||
docker-compose.override.yml
|
||||
|
||||
# Security - Never commit credentials
|
||||
ADMIN_CREDENTIALS.txt
|
||||
ADMIN_PASSWORD_RESET.txt
|
||||
@@ -48,9 +51,34 @@ coverage/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Backup and test directories
|
||||
backups/
|
||||
test-archiver/
|
||||
|
||||
# Keep directory structure
|
||||
!storage/events/active/.gitkeep
|
||||
!storage/events/archived/.gitkeep
|
||||
!storage/thumbnails/.gitkeep
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
# development files
|
||||
backend/.swarm/
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
# Ignore local contributor guide copy
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
|
||||
@@ -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
|
||||
@@ -1,261 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Product Overview
|
||||
|
||||
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
|
||||
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
|
||||
- **Storage**: File-based with active/archived separation
|
||||
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
|
||||
- **Analytics**: Umami integration for engagement tracking
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
npm install # Install dependencies
|
||||
npm run migrate # Initialize database schema
|
||||
npm run dev # Start with hot-reload (port 3001)
|
||||
npm test # Run Jest tests
|
||||
npm run lint # ESLint checks
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- path/to/test.test.js
|
||||
npm test -- --testNamePattern="test name"
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||
```
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
1. **File-Based System**: Drop photos in folders → automatic gallery creation
|
||||
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
|
||||
3. **Password Protection**: Secure access with customizable passwords
|
||||
4. **Automatic Archiving**: ZIP compression and storage after expiration
|
||||
5. **Email Notifications**: Creation, warning, and expiration notifications
|
||||
6. **Analytics**: Umami tracking for views, downloads, and engagement
|
||||
|
||||
### Folder Structure
|
||||
```
|
||||
/events/
|
||||
├── active/
|
||||
│ ├── wedding-smith-jones-2024-06-15/
|
||||
│ │ ├── collages/
|
||||
│ │ └── individual/
|
||||
│ └── birthday-emma-2024-07-20/
|
||||
└── archived/
|
||||
└── wedding-smith-jones-2024-06-15.zip
|
||||
```
|
||||
|
||||
## Frontend Implementation Requirements
|
||||
|
||||
### Design Style (scrappbook.de-inspired)
|
||||
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
|
||||
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
|
||||
- **Layout**: Minimalist, modular sections with grid-based photo displays
|
||||
- **Aesthetic**: Professional yet approachable, photographer-focused
|
||||
|
||||
### Key Frontend Components to Build
|
||||
1. **Landing Page**: Password entry with event preview
|
||||
2. **Gallery View**:
|
||||
- Responsive photo grid with lazy loading
|
||||
- Toggle between collages/individual photos
|
||||
- Prominent expiration banner
|
||||
- Download urgency indicators
|
||||
3. **Photo Lightbox**: Full-screen viewing with zoom
|
||||
4. **Mobile-First**: Responsive design with touch gestures
|
||||
5. **Personalization**: Dynamic theming per event type
|
||||
|
||||
### User Experience Priorities
|
||||
- Clear expiration warnings (sticky banner)
|
||||
- One-click "Download All" for urgent galleries
|
||||
- Smooth image loading with skeleton screens
|
||||
- Intuitive navigation between photo categories
|
||||
- Professional presentation matching photographer branding
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
### Authentication Flow
|
||||
- JWT-based with separate tokens for admin and gallery access
|
||||
- Gallery tokens include event-specific claims
|
||||
- Auth middleware: `backend/src/middleware/auth.js`
|
||||
- `adminAuth` - Admin panel protection
|
||||
- `photoAuth` - Protected photo access
|
||||
- `verifyGalleryAccess` - Gallery-specific validation
|
||||
|
||||
### Database Schema (Knex/SQLite)
|
||||
Main tables:
|
||||
- `events` - Gallery metadata with expiration, custom messages, themes
|
||||
- `photos` - Photo records linked to events
|
||||
- `access_logs` - IP-based usage tracking
|
||||
- `email_queue` - Async email processing
|
||||
- `admin_users` - Admin authentication
|
||||
|
||||
### Service Architecture
|
||||
Background services run as separate processes:
|
||||
- **emailService**: Processes email queue with retry logic
|
||||
- **archiveService**: Creates ZIP archives of expired events
|
||||
- **expirationChecker**: Cron job for expiration warnings
|
||||
- **fileWatcher**: Monitors for new photo uploads
|
||||
|
||||
### API Structure
|
||||
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
|
||||
- `/api/gallery/*` - Public gallery endpoints
|
||||
- `/api/auth/*` - Authentication endpoints
|
||||
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **Security**: All gallery access requires valid JWT with event-specific claims
|
||||
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
|
||||
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
|
||||
4. **File Processing**: Sharp library for thumbnail generation (300x300)
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
- `JWT_SECRET` - Token signing
|
||||
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
|
||||
- `SMTP_*` - Email configuration
|
||||
- `DB_*` - PostgreSQL credentials (production)
|
||||
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
|
||||
- `UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
|
||||
### Frontend (.env)
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- `VITE_UMAMI_URL` - Umami analytics URL
|
||||
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
|
||||
|
||||
## Testing Approach
|
||||
- Jest with Supertest for API testing
|
||||
- Test files in `__tests__` directories
|
||||
- Database migrations run before tests
|
||||
- Mock email sending in tests
|
||||
|
||||
## Umami Analytics Integration
|
||||
|
||||
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
|
||||
|
||||
### Tracked Events:
|
||||
- **Gallery Events**:
|
||||
- `gallery_password_entry` - Password attempts (success/failure)
|
||||
- `gallery_photo_view` - Individual photo views
|
||||
- `gallery_photo_download` - Single photo downloads
|
||||
- `gallery_bulk_download` - Bulk/all photo downloads
|
||||
- `gallery_expired` - Expired gallery access attempts
|
||||
- **Admin Events**:
|
||||
- `admin_login` - Admin authentication
|
||||
- `admin_event_created` - New event creation
|
||||
- `admin_event_archived` - Event archiving
|
||||
- `admin_event_deleted` - Event deletion
|
||||
- `admin_settings_updated` - Settings changes
|
||||
- **User Behavior**:
|
||||
- Search queries (with debouncing)
|
||||
- Expiration warning views
|
||||
- Page views with automatic tracking
|
||||
|
||||
### Setup:
|
||||
1. Install Umami (self-hosted or cloud)
|
||||
2. Create a website in Umami dashboard
|
||||
3. Set environment variables:
|
||||
```
|
||||
VITE_UMAMI_URL=https://your-umami-instance.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
|
||||
```
|
||||
|
||||
### Analytics Dashboard:
|
||||
- Admin panel includes analytics page at `/admin/analytics`
|
||||
- Summary view with key metrics
|
||||
- Option to embed full Umami dashboard
|
||||
- Real-time event tracking
|
||||
|
||||
## Accessibility & Performance Features
|
||||
|
||||
### Accessibility (WCAG 2.1 AA Compliance)
|
||||
- **Error Boundaries**: Graceful error handling with recovery options
|
||||
- **Skip Links**: Skip to main content for keyboard navigation
|
||||
- **ARIA Labels**: Proper labeling for screen readers
|
||||
- **Focus Management**: Focus trap in modals, visible focus indicators
|
||||
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
|
||||
- **Loading States**: Skeleton screens instead of spinners for better UX
|
||||
- **Offline Support**: Visual indicator when offline
|
||||
- **Form Validation**: Accessible error messages with aria-describedby
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy Loading**: Images load on scroll with Intersection Observer
|
||||
- **Skeleton Screens**: Instant visual feedback during loading
|
||||
- **Error Recovery**: Component-level error boundaries prevent full page crashes
|
||||
- **Optimistic Updates**: Immediate UI updates with background sync
|
||||
- **Debounced Search**: Prevents excessive API calls
|
||||
- **Analytics**: Non-blocking Umami integration
|
||||
|
||||
### Component Library Enhancements
|
||||
- `<ErrorBoundary>` - Catches and displays errors gracefully
|
||||
- `<PageErrorBoundary>` - Full-page error recovery
|
||||
- `<Skeleton>` - Flexible skeleton loader with variants
|
||||
- `<OfflineIndicator>` - Network status monitoring
|
||||
- `<SkipLink>` - Accessibility navigation
|
||||
- `useFocusTrap` - Modal focus management hook
|
||||
- `useOnlineStatus` - Network status hook
|
||||
|
||||
## Theme System & Branding
|
||||
|
||||
### Theme Features
|
||||
- **Dynamic Theming**: CSS variables for runtime theme switching
|
||||
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
|
||||
- **Customization Options**:
|
||||
- Primary/Accent/Background/Text colors
|
||||
- Font family selection
|
||||
- Border radius (none, sm, md, lg)
|
||||
- Custom logo upload
|
||||
- Custom CSS injection
|
||||
- **Event-Specific Themes**: Override global theme per gallery
|
||||
- **Live Preview**: Real-time theme changes in admin panel
|
||||
|
||||
### Theme Context API
|
||||
```typescript
|
||||
const { theme, setTheme, setThemeByName } = useTheme();
|
||||
```
|
||||
|
||||
### Branding Settings
|
||||
- Company name, tagline, and support email
|
||||
- Custom footer text
|
||||
- Optional watermarking on downloads
|
||||
- Logo upload for gallery header
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
--color-primary: #5C8762;
|
||||
--color-primary-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--border-radius: 0.5rem;
|
||||
```
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
@@ -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 by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. 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](https://github.com/the-luap/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
|
||||
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
@@ -1,92 +0,0 @@
|
||||
# Deployment Guide - Traefik Production Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to deploy PicPeak with an external Traefik reverse proxy for production use.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
1. **Database Migration**: Added missing `created_at` column to `email_queue` table
|
||||
2. **502 Bad Gateway**: Properly configured Traefik routing and backend accessibility
|
||||
3. **Health Checks**: Fixed health check endpoint imports and paths
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Update Environment Variables
|
||||
|
||||
Ensure your `.env` file has the correct URLs:
|
||||
```bash
|
||||
ADMIN_URL=https://picpeak.nothaft.cloud
|
||||
FRONTEND_URL=https://picpeak.nothaft.cloud
|
||||
```
|
||||
|
||||
### 2. Build Images
|
||||
|
||||
```bash
|
||||
# Build backend image
|
||||
docker build -t picpeak-backend:latest ./backend
|
||||
|
||||
# Build frontend image
|
||||
docker build -t picpeak-frontend:latest ./frontend \
|
||||
--build-arg VITE_API_URL=/api \
|
||||
--build-arg VITE_UMAMI_URL=${VITE_UMAMI_URL} \
|
||||
--build-arg VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID}
|
||||
```
|
||||
|
||||
### 3. Deploy with Traefik
|
||||
|
||||
Use the new Traefik-specific compose file:
|
||||
```bash
|
||||
docker-compose -f docker-compose.traefik.yml up -d
|
||||
```
|
||||
|
||||
### 4. Verify Deployment
|
||||
|
||||
Check that all services are healthy:
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose -f docker-compose.traefik.yml ps
|
||||
|
||||
# Check backend health
|
||||
curl https://picpeak.nothaft.cloud/api/health
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.traefik.yml logs -f backend
|
||||
```
|
||||
|
||||
## Key Differences from Standard Deployment
|
||||
|
||||
1. **No Internal Nginx**: Traefik handles all routing externally
|
||||
2. **API Path Stripping**: Traefik strips `/api` prefix when forwarding to backend
|
||||
3. **Network Configuration**: Services join external `traefik` network
|
||||
4. **Health Checks**: Backend exposes `/health` endpoint (not `/api/health`)
|
||||
|
||||
## Why CI/CD Tests Pass But Production Fails
|
||||
|
||||
CI/CD tests typically:
|
||||
- Use in-memory or temporary databases with fresh migrations
|
||||
- Don't test through reverse proxy (direct API calls)
|
||||
- Don't run background services (email processor, etc.)
|
||||
- Have different network configurations
|
||||
|
||||
Production environment has:
|
||||
- Persistent database that may have migration state issues
|
||||
- Reverse proxy routing complexity
|
||||
- All background services running
|
||||
- Different security and network constraints
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 502 Bad Gateway
|
||||
- Check Traefik network connectivity: `docker network ls`
|
||||
- Verify backend is in traefik network: `docker inspect picpeak-backend`
|
||||
- Check Traefik logs: `docker logs traefik`
|
||||
|
||||
### Database Issues
|
||||
- Connect to database: `docker exec -it picpeak-db psql -U picpeak`
|
||||
- Check migration status: `SELECT * FROM migrations;`
|
||||
- Run migrations manually: `docker exec -it picpeak-backend npm run migrate:safe`
|
||||
|
||||
### Email Service Errors
|
||||
- Check email queue: `SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;`
|
||||
- Monitor email processor: `docker logs picpeak-backend | grep "email"`
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
# PicPeak Deployment Guide
|
||||
|
||||
This guide covers deploying PicPeak for development and production environments.
|
||||
|
||||
## 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)
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
### 1. Clone and Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/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
|
||||
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```env
|
||||
# Your domain
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
# DB_PASSWORD already generated above
|
||||
|
||||
# Email
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Frontend Configuration
|
||||
|
||||
```bash
|
||||
# Configure frontend for production
|
||||
echo "VITE_API_URL=/api" > frontend/.env.production
|
||||
```
|
||||
|
||||
### 3. Deploy with Docker Compose
|
||||
|
||||
```bash
|
||||
# Build and start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### 4. Deploy with Traefik
|
||||
|
||||
If using Traefik, create `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Admin User Setup
|
||||
|
||||
### Create First Admin
|
||||
|
||||
After deployment, create your admin user:
|
||||
|
||||
```bash
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com \
|
||||
--username admin \
|
||||
--password yourSecurePassword
|
||||
|
||||
# Auto-generate password
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Remove unused images
|
||||
docker image prune -a
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,822 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
|
||||
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
- Choice between Docker or Native installation
|
||||
- OS detection and dependency installation
|
||||
- Database setup and service configuration
|
||||
- SSL/HTTPS setup (optional)
|
||||
|
||||
Perfect for:
|
||||
- Small to medium deployments
|
||||
- Local or VPS installations
|
||||
- Users new to server management
|
||||
- Quick testing and evaluation
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Compose Deployment
|
||||
|
||||
### Option 1: Using Pre-built Images (Recommended)
|
||||
|
||||
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Use pre-built images deployment
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The production compose file uses:
|
||||
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
|
||||
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
|
||||
|
||||
Available tags:
|
||||
- `latest` - Latest stable release
|
||||
- `main` - Latest main branch build
|
||||
- `develop` - Development branch (may be unstable)
|
||||
- `v1.0.0` - Specific version tags
|
||||
|
||||
### Option 2: Building from Source
|
||||
|
||||
If you need to customize the application or the pre-built images aren't available, you can build locally:
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Deployment](#deployment)
|
||||
- [First Login](#first-login)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [External Media Library](#external-media-library)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Method 1: Using Pre-built Images (Fastest)
|
||||
|
||||
1. **Clone the repository for configs**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy using pre-built images**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
## External Media Library
|
||||
|
||||
PicPeak can reference an existing, read‑only media library mounted into the backend container. This avoids copying originals into PicPeak storage.
|
||||
|
||||
- Map your host library path to the container as read‑only in `docker-compose.production.yml`:
|
||||
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
|
||||
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
- In `.env`, set:
|
||||
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
|
||||
- `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
|
||||
Usage:
|
||||
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
|
||||
|
||||
Backups and Archives:
|
||||
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
|
||||
- Archiving reference events creates a manifest‑only ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
|
||||
|
||||
Local (npm) setup (no Docker):
|
||||
|
||||
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
|
||||
2. In `backend/.env` (or your shell), set:
|
||||
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
|
||||
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
|
||||
3. Start services from source:
|
||||
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
|
||||
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
|
||||
4. In Admin → Events:
|
||||
- Create an event, set “Source Mode” to “Reference (external folder)”.
|
||||
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
|
||||
- Click “Import from selected folder” to index files and generate thumbnails on demand.
|
||||
|
||||
Notes:
|
||||
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
|
||||
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
|
||||
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
|
||||
|
||||
### Method 2: Building from Source
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Build and deploy**
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
|
||||
Generate secure values:
|
||||
```bash
|
||||
# JWT Secret
|
||||
openssl rand -base64 64
|
||||
|
||||
# Database Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
|
||||
# Redis Password (avoid $ character - see warning below)
|
||||
openssl rand -base64 32 | tr -d '$'
|
||||
```
|
||||
|
||||
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
|
||||
- Avoid `$` in passwords (recommended - use the commands above)
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
- `DB_PASSWORD` - PostgreSQL password
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
```env
|
||||
DB_HOST=db.example.com
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=picpeak_prod
|
||||
```
|
||||
|
||||
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||
|
||||
### Frontend Configuration (frontend/.env)
|
||||
Create `frontend/.env` from `frontend/.env.example`:
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
Update `frontend/.env` with:
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||
|
||||
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
|
||||
|
||||
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||
- The backend API runs on port **3001**
|
||||
- The frontend `.env` file MUST point to the correct backend port (3001)
|
||||
- Default `.env.example` is configured for Docker deployment
|
||||
|
||||
### Email Configuration Examples
|
||||
|
||||
#### Gmail
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
```
|
||||
|
||||
#### SendGrid
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Using Pre-built Images (Fastest)
|
||||
|
||||
```bash
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Start services using production compose file
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Building from Source (For Customization)
|
||||
|
||||
```bash
|
||||
# Build images locally
|
||||
docker compose build
|
||||
|
||||
# Or build with no cache for clean build
|
||||
docker compose build --no-cache
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
### Initial Admin Setup
|
||||
|
||||
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
|
||||
|
||||
#### Finding the Auto-Generated Admin Password
|
||||
|
||||
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
|
||||
|
||||
**Option 1: Search Docker logs for admin password** (recommended)
|
||||
```bash
|
||||
# Find the auto-generated admin password in logs
|
||||
docker compose logs backend | grep "Admin password"
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
```
|
||||
✅ Admin password generated: BraveTiger6231!
|
||||
```
|
||||
|
||||
**Option 2: View the complete initialization logs**
|
||||
```bash
|
||||
# View the complete admin setup logs
|
||||
docker compose logs backend | grep -A 10 "Admin user created"
|
||||
```
|
||||
|
||||
**Option 3: Check the saved credentials file**
|
||||
```bash
|
||||
# The password is also saved in the backend container
|
||||
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Option 4: Use the helper script**
|
||||
```bash
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- The admin password is only displayed once during initial setup
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
|
||||
## 🔐 First Login
|
||||
|
||||
After deployment, you must complete the first login process which includes mandatory password change for security.
|
||||
|
||||
### Step 1: Locate Your Admin Password
|
||||
|
||||
1. **Find the auto-generated password** from the credentials file:
|
||||
```bash
|
||||
# Docker deployment
|
||||
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
|
||||
|
||||
# Or directly from the host (if you have access)
|
||||
cat data/ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
2. **Note the admin email** (default: `admin@example.com` unless customized)
|
||||
|
||||
### Step 2: Access Admin Panel
|
||||
|
||||
1. Navigate to your frontend domain and open the admin section:
|
||||
- `http://your-domain.com/admin` (behind reverse proxy)
|
||||
- `http://localhost:3000/admin` (Docker local)
|
||||
|
||||
The backend at `:3001` serves API only and does not serve the admin UI.
|
||||
2. Login using:
|
||||
- **Email**: `admin@example.com` (or your custom admin email)
|
||||
- **Password**: The auto-generated password from the logs
|
||||
|
||||
### Step 3: Mandatory Password Change
|
||||
|
||||
Upon first login, the system will **automatically redirect** you to change your password:
|
||||
|
||||
1. **You cannot skip this step** - it's enforced for security
|
||||
2. Enter the current auto-generated password
|
||||
3. Create a new secure password meeting these requirements:
|
||||
- Minimum 12 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- At least one special character (!@#$%^&*)
|
||||
|
||||
### Security Best Practices for New Password
|
||||
|
||||
- **Use a unique password** not used elsewhere
|
||||
- **Consider a password manager** for generation and storage
|
||||
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
|
||||
- **Avoid personal information** (names, dates, etc.)
|
||||
- **Save securely** - you cannot recover this password easily
|
||||
|
||||
### If You Lose Access
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
|
||||
|
||||
```env
|
||||
# .env
|
||||
ADMIN_EMAIL=your-email@yourdomain.com
|
||||
```
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend (serves UI and /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Option 2: Traefik
|
||||
|
||||
Add labels to `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Frontend
|
||||
handle /* {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
# Backend API and admin
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /admin/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Protected resources
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For any reverse proxy, you can use Let's Encrypt:
|
||||
|
||||
```bash
|
||||
# With Certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
### Backup
|
||||
|
||||
#### Manual Backup
|
||||
```bash
|
||||
# Database backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Files backup
|
||||
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
|
||||
```
|
||||
|
||||
#### Automated Backup
|
||||
The application includes a built-in backup service. Configure it in the admin panel:
|
||||
1. Login to admin panel
|
||||
2. Go to Settings → Backup
|
||||
3. Configure destination and schedule
|
||||
4. Enable backup service
|
||||
|
||||
### Updates
|
||||
|
||||
#### Method 1: Using Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull latest changes (for configuration updates)
|
||||
git pull
|
||||
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
|
||||
# Restart with new images
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
#### Method 2: Building from Source
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version Updates
|
||||
|
||||
To use a specific version of the images:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.production.yml to specify version tags
|
||||
# Change: ghcr.io/the-luap/picpeak/backend:latest
|
||||
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
```bash
|
||||
docker exec picpeak-backend npm run migrate
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 502 Bad Gateway / Login Failures
|
||||
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
|
||||
|
||||
1. **CORS Configuration Errors**:
|
||||
```bash
|
||||
# WRONG - Missing port will cause CORS errors
|
||||
FRONTEND_URL=http://10.0.252.12
|
||||
|
||||
# CORRECT - Include the port you're accessing from
|
||||
FRONTEND_URL=http://10.0.252.12:3000
|
||||
```
|
||||
|
||||
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
|
||||
|
||||
2. **After Container Restarts**:
|
||||
- Nginx may have cached old container IPs
|
||||
- Solution: `docker restart picpeak-frontend`
|
||||
- Always wait 30-60 seconds for health checks
|
||||
|
||||
3. **Backend Not Starting After Migrations**:
|
||||
- The logs may only show migrations completed
|
||||
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
|
||||
- Should see `node server.js` process
|
||||
|
||||
4. **Login After Fresh Install**:
|
||||
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
|
||||
- Email: `admin@example.com` (or your custom admin email from .env)
|
||||
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
|
||||
- Remember: Password MUST be changed on first login
|
||||
|
||||
5. **Complete Fix Sequence**:
|
||||
```bash
|
||||
# 1. Fix your .env file URLs
|
||||
# 2. Full restart
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Wait for healthy status
|
||||
sleep 60
|
||||
docker ps # All should show (healthy)
|
||||
|
||||
# 4. Test backend directly
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 5. Test through frontend
|
||||
curl http://localhost:3000/api/public/settings
|
||||
```
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Change ports in .env
|
||||
FRONTEND_PORT=3002
|
||||
BACKEND_PORT=3003
|
||||
```
|
||||
|
||||
#### Docker Compose Variable Substitution Errors
|
||||
If you see warnings like:
|
||||
```
|
||||
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
|
||||
```
|
||||
|
||||
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
|
||||
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
|
||||
2. **Alternative**: Escape `$` as `$$` in your .env file
|
||||
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R 1000:1000 events data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
#### Database Connection Issues
|
||||
```bash
|
||||
# Check if database is running
|
||||
docker compose ps
|
||||
docker compose logs postgres
|
||||
|
||||
# Test connection
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
#### Email Not Sending
|
||||
- Verify SMTP settings in .env
|
||||
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
|
||||
- For Gmail, use app-specific password
|
||||
- Check logs: `docker compose logs backend | grep email`
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Frontend health
|
||||
curl http://localhost:3000
|
||||
|
||||
# Database health
|
||||
docker exec picpeak-postgres pg_isready
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Enter database
|
||||
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
|
||||
|
||||
# Reset admin password
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
|
||||
# Check disk usage
|
||||
df -h
|
||||
du -sh events/ storage/ backup/
|
||||
|
||||
# View running processes
|
||||
docker compose top
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
|
||||
2. **Firewall**: Only expose necessary ports (80, 443)
|
||||
3. **Secure passwords**: Use strong, unique passwords for all services
|
||||
4. **Regular updates**: Keep Docker images and system packages updated
|
||||
5. **Backup strategy**: Set up automated backups and test restoration
|
||||
6. **Monitor logs**: Regularly check logs for suspicious activity
|
||||
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check logs first: `docker compose logs`
|
||||
- Review documentation in the repository
|
||||
- Check existing issues on GitHub
|
||||
- Create a new issue with:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
@@ -1,111 +0,0 @@
|
||||
# Quick Fix for Migration Error
|
||||
|
||||
## Immediate Fix
|
||||
|
||||
The error "relation photo_categories already exists" occurs because the database already has tables but the migration tracking doesn't know they were applied.
|
||||
|
||||
### Option 1: Use Safe Migration Runner (Recommended)
|
||||
|
||||
Update your `docker-compose.prod.yml` to use the safe migration command:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# ... other config ...
|
||||
```
|
||||
|
||||
Then update the `wait-for-db.sh` (already done) to use `npm run migrate:safe` in production.
|
||||
|
||||
### Option 2: Quick Manual Fix
|
||||
|
||||
If you need to fix the running system immediately:
|
||||
|
||||
```bash
|
||||
# 1. Enter the backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# 2. Run the safe migration script
|
||||
npm run migrate:safe
|
||||
|
||||
# 3. If that fails, manually mark migrations as applied:
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||
|
||||
# In PostgreSQL:
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mark existing migrations as applied
|
||||
INSERT INTO migrations (filename) VALUES
|
||||
('init.js'),
|
||||
('004_add_categories_and_cms.js'),
|
||||
('006_add_photo_counter_to_categories.js'),
|
||||
('007_add_read_at_to_activity_logs.js'),
|
||||
('008_add_language_support_to_email_templates.js'),
|
||||
('009_update_german_email_templates.js'),
|
||||
('010_add_missing_email_templates.js'),
|
||||
('011_add_user_upload_settings.js'),
|
||||
('012_add_hero_photo_id.js'),
|
||||
('013_fix_email_links_and_date_format.js'),
|
||||
('014_add_default_welcome_message.js'),
|
||||
('014_add_host_name_to_events.js'),
|
||||
('015_add_login_attempts_table.js'),
|
||||
('016_add_auth_security_columns.js'),
|
||||
('017_add_token_revocation_tables.js')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
|
||||
\q
|
||||
```
|
||||
|
||||
### Option 3: Fresh Start (Nuclear Option)
|
||||
|
||||
If you don't have important data yet:
|
||||
|
||||
```bash
|
||||
# Stop everything
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Remove database volume
|
||||
docker volume rm wedding-photo-sharing_postgres_data
|
||||
|
||||
# Start fresh
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue happens when:
|
||||
1. Database volume persists between deployments
|
||||
2. Migration tracking table gets out of sync
|
||||
3. The original migration runner doesn't check for existing tables
|
||||
|
||||
## Permanent Solution
|
||||
|
||||
The new safe migration runner (`migrate:safe`) handles this by:
|
||||
1. Checking if tables exist before creating them
|
||||
2. Catching "already exists" errors gracefully
|
||||
3. Auto-detecting existing schema and marking migrations as applied
|
||||
|
||||
## Next Steps
|
||||
|
||||
After fixing the migration issue:
|
||||
|
||||
1. Create admin user:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com
|
||||
```
|
||||
|
||||
2. Check health:
|
||||
```bash
|
||||
curl http://yourdomain.com/api/health
|
||||
```
|
||||
|
||||
3. Monitor logs:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
@@ -1,98 +0,0 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
|
||||
|
||||
1. Copy the production environment template:
|
||||
```bash
|
||||
cp frontend/.env.production.example frontend/.env.production
|
||||
```
|
||||
|
||||
2. Set the API URL to use relative path:
|
||||
```env
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api
|
||||
```
|
||||
|
||||
This ensures all API calls will use the same domain and protocol as the frontend.
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Ensure your backend `.env` file has the correct URLs:
|
||||
```env
|
||||
# backend/.env
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Docker Compose Production
|
||||
|
||||
When using Docker Compose in production:
|
||||
|
||||
1. Build with production environment:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
|
||||
```
|
||||
|
||||
2. The frontend nginx configuration already includes proper proxy settings for:
|
||||
- `/api` → Backend API
|
||||
- `/photos` → Protected photo access
|
||||
- `/thumbnails` → Thumbnail images
|
||||
- `/uploads` → Public uploads (logos, favicons)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
Example Traefik labels for docker-compose:
|
||||
|
||||
```yaml
|
||||
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"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
|
||||
|
||||
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
|
||||
|
||||
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
|
||||
|
||||
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
1. Check browser console for any localhost URLs (there should be none)
|
||||
2. Verify all API calls use HTTPS
|
||||
3. Check that images load correctly with authentication
|
||||
4. Test favicon and logo display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you see console errors about localhost:
|
||||
|
||||
1. Ensure `VITE_API_URL=/api` in frontend environment
|
||||
2. Clear browser cache
|
||||
3. Rebuild frontend with production environment:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
If images don't load:
|
||||
|
||||
1. Check that nginx proxy locations are configured
|
||||
2. Verify authentication tokens are being sent
|
||||
3. Check backend logs for authentication errors
|
||||
@@ -1,100 +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
|
||||
|
||||
## PostgreSQL Connection Fix
|
||||
|
||||
### Issue: "no pg_hba.conf entry for host"
|
||||
This error occurs when PostgreSQL requires SSL but the client connects without encryption.
|
||||
|
||||
### Solution:
|
||||
- Disabled SSL requirement for PostgreSQL in Docker environment (`ssl=off`)
|
||||
- Added proper authentication method (`scram-sha-256`)
|
||||
- This is acceptable for internal Docker networks where all traffic is isolated
|
||||
|
||||
### Security Note:
|
||||
For production deployments exposed to the internet:
|
||||
1. Use SSL certificates for PostgreSQL
|
||||
2. Or ensure the database is only accessible within the Docker network
|
||||
3. Never expose PostgreSQL port (5432) directly to the internet
|
||||
@@ -1,312 +0,0 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide addresses all known production deployment issues and provides solutions.
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### 1. Environment Variables
|
||||
Create a `.env` file with ALL required variables:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
DB_PASSWORD=<strong-password>
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email (Optional but recommended)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=<generate-random-string>
|
||||
```
|
||||
|
||||
### 2. Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate JWT Secret
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Database Password
|
||||
openssl rand -base64 24
|
||||
|
||||
# Generate Umami Hash Salt
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/yourusername/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
|
||||
# Create required directories
|
||||
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
|
||||
mkdir -p data logs
|
||||
mkdir -p certbot/conf certbot/www
|
||||
|
||||
# Set permissions (important!)
|
||||
chmod -R 755 storage data logs
|
||||
```
|
||||
|
||||
### 2. Fix Docker Volume Permissions
|
||||
|
||||
Create `docker-compose.override.yml` for local volume configuration:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- ./storage:/app/storage:delegated
|
||||
- ./data:/app/data:delegated
|
||||
- ./logs:/app/logs:delegated
|
||||
user: "1001:1001" # nodejs user
|
||||
|
||||
db:
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
### 3. Build and Deploy
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### 4. Create Admin User
|
||||
|
||||
After deployment, create the first admin user:
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Create admin
|
||||
node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com \
|
||||
--password <your-secure-password>
|
||||
|
||||
# Exit container
|
||||
exit
|
||||
```
|
||||
|
||||
### 5. Configure Email (if using database config)
|
||||
|
||||
1. Login to admin panel: https://yourdomain.com/admin
|
||||
2. Go to Settings > Email Configuration
|
||||
3. Enter SMTP details
|
||||
4. Test email sending
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: Migration Failures
|
||||
|
||||
**Error**: "relation already exists"
|
||||
|
||||
**Solution**: The safe migration runner handles this automatically. If issues persist:
|
||||
|
||||
```bash
|
||||
# Reset migrations tracking
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||
|
||||
# In PostgreSQL:
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
\q
|
||||
|
||||
# Re-run migrations
|
||||
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
|
||||
```
|
||||
|
||||
### Issue 2: Permission Denied Errors
|
||||
|
||||
**Error**: "EACCES: permission denied"
|
||||
|
||||
**Solution**: Fix container permissions:
|
||||
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Fix permissions on host
|
||||
sudo chown -R 1001:1001 storage data logs
|
||||
|
||||
# Restart
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Issue 3: Database Connection Failed
|
||||
|
||||
**Error**: "no pg_hba.conf entry"
|
||||
|
||||
**Solution**: Already fixed in docker-compose.prod.yml with:
|
||||
- SSL disabled for internal Docker network
|
||||
- Proper authentication method (scram-sha-256)
|
||||
|
||||
### Issue 4: Frontend Can't Connect to Backend
|
||||
|
||||
**Error**: CORS errors or connection refused
|
||||
|
||||
**Solution**: Ensure environment variables match:
|
||||
- Backend: `FRONTEND_URL` must match your frontend URL
|
||||
- Frontend: `VITE_API_URL` must be set during build
|
||||
|
||||
### Issue 5: Email Not Sending
|
||||
|
||||
**Solution**: Check email configuration:
|
||||
|
||||
```bash
|
||||
# Check backend logs
|
||||
docker-compose -f docker-compose.prod.yml logs backend | grep email
|
||||
|
||||
# Verify SMTP settings
|
||||
# Gmail users: Use app password, not regular password
|
||||
# Enable "Less secure app access" or use OAuth2
|
||||
```
|
||||
|
||||
## SSL/HTTPS Setup
|
||||
|
||||
1. Update `nginx/sites-enabled/default` with your domain
|
||||
2. Run certbot:
|
||||
|
||||
```bash
|
||||
# Initial certificate
|
||||
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||
--webroot --webroot-path=/var/www/certbot \
|
||||
-d yourdomain.com -d www.yourdomain.com
|
||||
|
||||
# Auto-renewal is handled by the certbot container
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost/api/health
|
||||
|
||||
# Database connection
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql -U picpeak -d picpeak -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="./backups/$DATE"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
|
||||
|
||||
# Files
|
||||
tar -czf $BACKUP_DIR/storage.tar.gz storage/
|
||||
|
||||
echo "Backup completed: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
|
||||
|
||||
# Files
|
||||
tar -xzf ./backups/20240713_120000/storage.tar.gz
|
||||
```
|
||||
|
||||
## Production Best Practices
|
||||
|
||||
1. **Always use named volumes** in production for better data persistence
|
||||
2. **Set up monitoring** with Prometheus/Grafana
|
||||
3. **Enable backups** with automated scripts
|
||||
4. **Use a reverse proxy** (Nginx) for SSL termination
|
||||
5. **Implement rate limiting** at the Nginx level
|
||||
6. **Regular updates** - Keep Docker images updated
|
||||
7. **Log rotation** - Configure log rotation for application logs
|
||||
|
||||
## Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Restart a service
|
||||
docker-compose -f docker-compose.prod.yml restart backend
|
||||
|
||||
# View real-time logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f --tail=100
|
||||
|
||||
# Execute commands in container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Database shell
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
|
||||
|
||||
# Clean restart
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||
- [ ] Strong database password
|
||||
- [ ] SSL/HTTPS enabled
|
||||
- [ ] Firewall configured (only 80/443 open)
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Access logs monitored
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] File upload restrictions configured
|
||||
|
||||
## Support
|
||||
|
||||
For issues not covered here:
|
||||
1. Check application logs
|
||||
2. Review error messages carefully
|
||||
3. Ensure all environment variables are set
|
||||
4. Verify file permissions
|
||||
5. Check Docker daemon logs
|
||||
-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,264 @@
|
||||
# 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
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **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
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
## 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
|
||||
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||
|
||||
MIT License
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **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
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 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
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **CPU**: 2 CPU cores
|
||||
- **RAM**: 2GB minimum
|
||||
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||
- **Node.js**: v18.0.0 or higher
|
||||
- **Database**: SQLite (included) or PostgreSQL 12+
|
||||
|
||||
### Docker Requirements (Recommended)
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
## 🤝 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 open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 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>
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||
|
||||
### 🚧 Beta Features (Use at your own risk)
|
||||
|
||||
These features are currently in beta testing and may have limited functionality or stability:
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## 🙏 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.
|
||||
|
||||
### 🤖 AI-Assisted Development
|
||||
|
||||
This project was generated with the assistance of AI technology, but has been:
|
||||
- ✅ **Fully tested end-to-end** by human developers
|
||||
- 🔒 **Security audited** with comprehensive security checks
|
||||
- 👨💻 **Human-reviewed** for code quality and best practices
|
||||
- 🧪 **Production-tested** in real-world scenarios
|
||||
|
||||
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
|
||||
|
||||
## 📄 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_GUIDE.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_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# 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. Report the vulnerability by:
|
||||
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Mark it clearly as "SECURITY" in the title
|
||||
- Include:
|
||||
- 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: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- General support: [GitHub Issues](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!)
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
# 🚀 PicPeak Simple Setup Guide
|
||||
|
||||
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
|
||||
## 🎯 Installation Methods
|
||||
|
||||
### Method 1: Docker Installation (Recommended)
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Easier installation and updates
|
||||
- ✅ Better isolation from system
|
||||
- ✅ Consistent environment across platforms
|
||||
- ✅ Built-in PostgreSQL and Redis
|
||||
|
||||
**Cons:**
|
||||
- ❌ Requires more resources (~4GB RAM recommended)
|
||||
- ❌ Additional Docker overhead
|
||||
|
||||
### Method 2: Native Installation
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Lower resource usage (~1GB RAM minimum)
|
||||
- ✅ Direct system control
|
||||
- ✅ No Docker overhead
|
||||
- ✅ Better for ARM devices
|
||||
|
||||
**Cons:**
|
||||
- ❌ More complex setup
|
||||
- ❌ System dependencies required
|
||||
- ❌ Manual update process
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
|
||||
- **RAM**:
|
||||
- Docker: 2GB minimum (4GB recommended)
|
||||
- Native: 1GB minimum (2GB recommended)
|
||||
- **Storage**: 2GB for application + space for photos
|
||||
- **Network**: Port 3001 (or 80/443 with proxy)
|
||||
|
||||
### Supported Platforms
|
||||
- ✅ Ubuntu 20.04, 22.04, 24.04
|
||||
- ✅ Debian 11, 12
|
||||
- ✅ Raspberry Pi OS (32-bit and 64-bit)
|
||||
- ✅ Fedora 38, 39, 40
|
||||
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
|
||||
|
||||
## 🛠️ Installation Options
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
1. Installation method (Docker or Native)
|
||||
2. Admin email and password
|
||||
3. Domain configuration (optional)
|
||||
4. Email server settings (optional)
|
||||
5. SSL/HTTPS setup (optional)
|
||||
|
||||
### Unattended Installation
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
--smtp-host smtp.gmail.com \
|
||||
--smtp-port 587 \
|
||||
--smtp-user your-email@gmail.com \
|
||||
--smtp-pass your-app-password \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `--docker` | Use Docker installation | `--docker` |
|
||||
| `--native` | Use native installation | `--native` |
|
||||
| `--unattended` | Run without prompts | `--unattended` |
|
||||
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
|
||||
| `--email` | Admin email address | `--email admin@example.com` |
|
||||
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
|
||||
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
|
||||
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
|
||||
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
|
||||
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
|
||||
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
|
||||
| `--port` | Custom port (native only) | `--port 8080` |
|
||||
| `--update` | Update existing installation | `--update` |
|
||||
| `--uninstall` | Remove installation | `--uninstall` |
|
||||
| `--help` | Show help message | `--help` |
|
||||
|
||||
## 🏗️ What Gets Installed
|
||||
|
||||
### Docker Installation
|
||||
```
|
||||
~/picpeak/ # Or custom directory
|
||||
├── docker-compose.yml # Service definitions
|
||||
├── .env # Configuration
|
||||
├── storage/
|
||||
│ └── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── backup/ # Backup directory
|
||||
```
|
||||
|
||||
**Services:**
|
||||
- PicPeak Backend (Node.js application)
|
||||
- PostgreSQL Database
|
||||
- Redis Cache
|
||||
- Nginx Reverse Proxy (optional)
|
||||
- Background Workers
|
||||
|
||||
### Native Installation
|
||||
```
|
||||
/opt/picpeak/ # Installation directory
|
||||
├── backend/ # Application code
|
||||
├── events/ # Photo storage
|
||||
│ ├── active/ # Current galleries
|
||||
│ └── archived/ # Expired galleries
|
||||
├── logs/ # Application logs
|
||||
└── config/ # Configuration files
|
||||
```
|
||||
|
||||
**Services (systemd):**
|
||||
- `picpeak-backend` - Main application
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
|
||||
- Backend/API: `http://your-server:3001` (API only; no UI routes)
|
||||
|
||||
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
|
||||
|
||||
### With Domain & HTTPS
|
||||
If configured during setup:
|
||||
- `https://your-domain.com` - Gallery frontend
|
||||
- `https://your-domain.com/admin` - Admin panel
|
||||
|
||||
### Behind Existing Proxy
|
||||
Add to your Nginx/Apache configuration (split frontend vs backend):
|
||||
```nginx
|
||||
# Frontend (UI + /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Managing Galleries
|
||||
|
||||
### Creating a Gallery
|
||||
|
||||
#### Method 1: Via Admin Panel (Recommended)
|
||||
1. Login to admin panel
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings and upload photos
|
||||
|
||||
#### Method 2: File System
|
||||
```bash
|
||||
# Docker installation
|
||||
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
|
||||
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
|
||||
|
||||
# Native installation
|
||||
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
|
||||
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
|
||||
```
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
wedding-smith-2024/
|
||||
├── collages/ # Group photos
|
||||
├── individual/ # Individual photos
|
||||
└── thumbnails/ # Auto-generated thumbnails
|
||||
```
|
||||
|
||||
## 🔧 Service Management
|
||||
|
||||
### Docker Installation
|
||||
|
||||
```bash
|
||||
cd ~/picpeak
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
|
||||
# Update PicPeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Native Installation
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo systemctl status picpeak-backend
|
||||
sudo systemctl status picpeak-workers
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u picpeak-backend -f
|
||||
sudo journalctl -u picpeak-workers -f
|
||||
|
||||
# Start services
|
||||
sudo systemctl start picpeak-backend picpeak-workers
|
||||
|
||||
# Stop services
|
||||
sudo systemctl stop picpeak-backend picpeak-workers
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Docker Configuration
|
||||
Edit `~/picpeak/.env`:
|
||||
```bash
|
||||
nano ~/picpeak/.env
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/app/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
### Key Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `JWT_SECRET` | Token signing secret | Auto-generated |
|
||||
| `ADMIN_EMAIL` | Admin email | admin@example.com |
|
||||
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
|
||||
| `PHOTOS_DIR` | Photo storage path | Varies by method |
|
||||
| `SMTP_ENABLED` | Email notifications | false |
|
||||
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
|
||||
|
||||
## 📧 Email Configuration
|
||||
|
||||
### Gmail Setup
|
||||
1. Enable 2-Factor Authentication
|
||||
2. Generate App Password
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### SendGrid Setup
|
||||
1. Sign up at sendgrid.com (100 emails/day free)
|
||||
2. Create API key
|
||||
3. Configure:
|
||||
```env
|
||||
SMTP_ENABLED=true
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
SMTP_FROM=verified-sender@yourdomain.com
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backups
|
||||
|
||||
#### Docker:
|
||||
```bash
|
||||
# Backup script included
|
||||
cd ~/picpeak
|
||||
./backup.sh
|
||||
|
||||
# Manual backup
|
||||
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
|
||||
tar -czf photos-backup.tar.gz storage/events/
|
||||
```
|
||||
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
|
||||
|
||||
# Photos backup
|
||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
cd ~/picpeak
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Service Won't Start
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs backend
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo journalctl -u picpeak-backend -n 50
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
#### Can't Access Admin Panel
|
||||
1. Check firewall:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 3001
|
||||
|
||||
# RHEL/CentOS
|
||||
sudo firewall-cmd --add-port=3001/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
2. Verify service:
|
||||
```bash
|
||||
# Docker
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Native
|
||||
sudo systemctl is-active picpeak-backend
|
||||
```
|
||||
|
||||
#### Photos Not Showing
|
||||
```bash
|
||||
# Check permissions (Native)
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/
|
||||
sudo chmod -R 755 /opt/picpeak/events/
|
||||
|
||||
# Check permissions (Docker)
|
||||
ls -la ~/picpeak/storage/events/
|
||||
```
|
||||
|
||||
#### Reset Admin Password
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/app/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check logs:**
|
||||
- Docker: `docker compose logs -f`
|
||||
- Native: `sudo journalctl -u picpeak-backend -f`
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://github.com/the-luap/picpeak)
|
||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### Essential Security
|
||||
1. **Change default admin password immediately**
|
||||
2. **Use HTTPS for production** (Let's Encrypt included)
|
||||
3. **Configure firewall** (only open necessary ports)
|
||||
4. **Regular updates** (system and PicPeak)
|
||||
5. **Automated backups** (configure in admin panel)
|
||||
|
||||
### Advanced Security
|
||||
- Use VPN for admin panel access
|
||||
- Configure fail2ban for brute force protection
|
||||
- Enable audit logging
|
||||
- Regular security scans
|
||||
- Implement IP whitelisting
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Docker Optimization
|
||||
```yaml
|
||||
# Adjust in docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
```
|
||||
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
## 🎯 Quick Setup Examples
|
||||
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
```
|
||||
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
## ✅ Post-Installation Checklist
|
||||
|
||||
- [ ] Admin password changed
|
||||
- [ ] Email configuration tested
|
||||
- [ ] First test gallery created
|
||||
- [ ] Backup schedule configured
|
||||
- [ ] Firewall rules applied
|
||||
- [ ] SSL certificate working (if applicable)
|
||||
- [ ] Monitoring setup
|
||||
- [ ] Documentation bookmarked
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
|
||||
@@ -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,280 +0,0 @@
|
||||
# Traefik Deployment Guide
|
||||
|
||||
This guide explains how to deploy the PicPeak application with Traefik as the reverse proxy.
|
||||
|
||||
## Overview
|
||||
|
||||
The application consists of:
|
||||
- **Frontend**: React app served by nginx (port 80)
|
||||
- **Backend**: Node.js API (port 3000)
|
||||
- **Database**: PostgreSQL (port 5432, internal only)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
### 1. Docker Labels for Traefik
|
||||
|
||||
Add these labels to your `docker-compose.prod.yml` services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||
# Priority for catch-all route
|
||||
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||
# Higher priority for API routes
|
||||
- "traefik.http.routers.picpeak-api.priority=10"
|
||||
|
||||
# Additional routes for backend static files
|
||||
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-images.priority=10"
|
||||
```
|
||||
|
||||
### 2. Network Configuration
|
||||
|
||||
Ensure your services are on the Traefik network:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
picpeak:
|
||||
external: false
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
frontend:
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
|
||||
backend:
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
|
||||
db:
|
||||
networks:
|
||||
- picpeak # Don't expose to traefik
|
||||
```
|
||||
|
||||
### 3. Remove Nginx Service
|
||||
|
||||
Since you're using Traefik, remove the nginx service from `docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
# Remove this entire service:
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# ...
|
||||
```
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
The frontend is built with the API URL set to `/api`. This is important because:
|
||||
|
||||
1. All API calls will be relative to the same domain
|
||||
2. Traefik will route `/api/*` to the backend service
|
||||
3. No CORS issues since everything is on the same domain
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Ensure these are set correctly:
|
||||
|
||||
```bash
|
||||
# Backend needs to know the public URLs
|
||||
ADMIN_URL=https://picpeak.yourdomain.com
|
||||
FRONTEND_URL=https://picpeak.yourdomain.com
|
||||
|
||||
# Backend API is accessed via /api path
|
||||
API_URL=https://picpeak.yourdomain.com/api
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete `docker-compose.prod.yml` for Traefik:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
picpeak:
|
||||
external: false
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: picpeak-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_URL=https://picpeak.yourdomain.com
|
||||
- FRONTEND_URL=https://picpeak.yourdomain.com
|
||||
- DATABASE_CLIENT=pg
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-picpeak}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME:-picpeak}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- STORAGE_PATH=/app/storage
|
||||
- EVENTS_PATH=/app/storage/events
|
||||
- ARCHIVE_PATH=/app/storage/events/archived
|
||||
volumes:
|
||||
- ./storage:/app/storage
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.picpeak-api.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||
|
||||
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||
- "traefik.http.routers.picpeak-images.priority=10"
|
||||
|
||||
frontend:
|
||||
image: picpeak-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=/api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- picpeak
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- picpeak
|
||||
command: postgres -c ssl=off
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 502 Bad Gateway Errors
|
||||
|
||||
1. **Check if backend is running**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
docker-compose -f docker-compose.prod.yml logs backend
|
||||
```
|
||||
|
||||
2. **Verify Traefik can reach the backend**:
|
||||
- Ensure both services are on the same Docker network
|
||||
- Check Traefik logs: `docker logs traefik`
|
||||
|
||||
3. **Check backend health**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### Frontend Can't Reach API
|
||||
|
||||
1. **Verify API paths don't have double `/api`**:
|
||||
- Frontend should call `/auth/admin/login`, not `/api/auth/admin/login`
|
||||
- The base URL in axios should be `/api`
|
||||
|
||||
2. **Check browser console for actual URLs being called**
|
||||
|
||||
3. **Ensure Traefik routing rules are correct**:
|
||||
- API routes should have higher priority than frontend catch-all
|
||||
|
||||
### CORS Issues
|
||||
|
||||
Should not occur since everything is on the same domain. If you see CORS errors:
|
||||
1. Check that `FRONTEND_URL` and `ADMIN_URL` match your actual domain
|
||||
2. Ensure you're not mixing HTTP and HTTPS
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
1. **Test API directly**:
|
||||
```bash
|
||||
curl https://picpeak.yourdomain.com/api/health
|
||||
```
|
||||
|
||||
2. **Test frontend**:
|
||||
```bash
|
||||
curl https://picpeak.yourdomain.com/
|
||||
```
|
||||
|
||||
3. **Test admin login**:
|
||||
- Navigate to https://picpeak.yourdomain.com/admin/login
|
||||
- Check browser console for any errors
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **SSL/TLS**: Traefik handles SSL termination, so the backend doesn't need SSL
|
||||
2. **Port Exposure**: Don't expose backend ports directly - let Traefik handle routing
|
||||
3. **Health Checks**: Configure Traefik health checks for better reliability
|
||||
4. **Rate Limiting**: Consider adding Traefik rate limiting middleware for API routes
|
||||
@@ -1,134 +0,0 @@
|
||||
# Traefik Troubleshooting Guide
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### 1. 404 Errors on API Routes
|
||||
|
||||
**Problem**: Getting 404 errors when accessing `/api/*` routes
|
||||
|
||||
**Causes**:
|
||||
- Traefik routing rules not properly configured
|
||||
- Backend container not healthy
|
||||
- Path stripping not working correctly
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check container health**:
|
||||
```bash
|
||||
docker ps # Check if backend is running
|
||||
docker logs picpeak-backend # Check for startup errors
|
||||
```
|
||||
|
||||
2. **Test backend directly**:
|
||||
```bash
|
||||
# Access backend container
|
||||
docker exec -it picpeak-backend sh
|
||||
|
||||
# Test health endpoint
|
||||
wget -O- http://localhost:3000/health
|
||||
|
||||
# Test public settings endpoint
|
||||
wget -O- http://localhost:3000/public/settings
|
||||
```
|
||||
|
||||
3. **Check Traefik routing**:
|
||||
```bash
|
||||
# Check if routes are registered in Traefik
|
||||
curl https://traefik.yourdomain.com/api/http/routers | jq '.[] | select(.rule | contains("picpeak"))'
|
||||
```
|
||||
|
||||
### 2. Backend Not Accessible Through Traefik
|
||||
|
||||
**Key Configuration Points**:
|
||||
|
||||
1. **Traefik Labels** (in deploy section):
|
||||
- `traefik.enable=true` - Enable Traefik for this container
|
||||
- `traefik.docker.network=proxy` - Specify which network Traefik should use
|
||||
- `traefik.http.routers.picpeak-backend.priority=100` - Higher priority for API routes
|
||||
|
||||
2. **Path Stripping**:
|
||||
- Frontend expects `/api/*` but backend serves routes without `/api` prefix
|
||||
- Middleware strips `/api` before forwarding to backend
|
||||
|
||||
3. **Network Configuration**:
|
||||
- Backend must be in both `picpeak` (internal) and `proxy` (Traefik) networks
|
||||
|
||||
### 3. Environment Variable Issues
|
||||
|
||||
**Critical Variables**:
|
||||
- `ADMIN_URL` and `FRONTEND_URL` must match your actual domain
|
||||
- These affect CORS configuration
|
||||
|
||||
**Example .env**:
|
||||
```env
|
||||
# URLs
|
||||
ADMIN_URL=https://picpeak.local.nothaft.cloud
|
||||
FRONTEND_URL=https://picpeak.local.nothaft.cloud
|
||||
|
||||
# Database
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your_secure_password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_secure_jwt_secret
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASS=smtp_password
|
||||
EMAIL_FROM=noreply@example.com
|
||||
```
|
||||
|
||||
### 4. Debugging Steps
|
||||
|
||||
1. **Check if backend is receiving requests**:
|
||||
```bash
|
||||
# Watch backend logs
|
||||
docker logs -f picpeak-backend
|
||||
|
||||
# Look for incoming requests when you try to access the admin page
|
||||
```
|
||||
|
||||
2. **Test API routes directly**:
|
||||
```bash
|
||||
# From outside
|
||||
curl -v https://picpeak.local.nothaft.cloud/api/public/settings
|
||||
|
||||
# Should see backend logs if request reaches container
|
||||
```
|
||||
|
||||
3. **Verify Traefik middleware**:
|
||||
```bash
|
||||
# Check if stripprefix middleware exists
|
||||
curl https://traefik.yourdomain.com/api/http/middlewares | jq '.[] | select(.name | contains("picpeak"))'
|
||||
```
|
||||
|
||||
### 5. Quick Fix Checklist
|
||||
|
||||
- [ ] Backend container is healthy (`docker ps`)
|
||||
- [ ] Backend is in both networks (`docker inspect picpeak-backend | grep -A 20 Networks`)
|
||||
- [ ] Traefik labels use correct network (`traefik.docker.network=proxy`)
|
||||
- [ ] Priority is set correctly (backend: 100, frontend: 10)
|
||||
- [ ] ADMIN_URL and FRONTEND_URL match your domain
|
||||
- [ ] Database is accessible from backend
|
||||
- [ ] Migrations have run successfully
|
||||
|
||||
### 6. Alternative Testing
|
||||
|
||||
If Traefik routing is problematic, test backend directly:
|
||||
|
||||
```bash
|
||||
# Port forward to test backend directly
|
||||
docker run --rm -it --network picpeak alpine/curl curl http://backend:3000/health
|
||||
|
||||
# Or expose backend port temporarily
|
||||
docker run -d --name picpeak-backend-test \
|
||||
--network picpeak \
|
||||
-p 3001:3000 \
|
||||
registry.local.nothaft.cloud/picpeak-backend:latest
|
||||
```
|
||||
|
||||
Then access http://localhost:3001/health to verify backend is working.
|
||||
+34
-16
@@ -3,39 +3,57 @@
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
PORT=3001
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
# URLs (adjust for your domain)
|
||||
ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_PASSWORD=your-secure-database-password-change-this
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.example.com
|
||||
# Email Configuration (Examples for common providers)
|
||||
# Gmail example:
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-app-specific-password
|
||||
|
||||
# SendGrid example:
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-smtp-username
|
||||
SMTP_PASS=your-smtp-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths (Docker)
|
||||
# Storage Paths
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
ARCHIVE_PATH=/app/storage/events/archived
|
||||
|
||||
# Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
# Local development:
|
||||
# STORAGE_PATH=./storage
|
||||
# EVENTS_PATH=./storage/events
|
||||
# ARCHIVE_PATH=./storage/events/archived
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
# UMAMI_URL=https://analytics.example.com
|
||||
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
+16
-2
@@ -1,4 +1,15 @@
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -12,10 +23,13 @@ RUN npm ci --only=production
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Enhanced Backup System Test Suite
|
||||
|
||||
This directory contains comprehensive tests for the enhanced backup system with S3 support.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests
|
||||
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
|
||||
- Configuration management
|
||||
- S3 backup functionality
|
||||
- Manifest generation
|
||||
- Error handling and recovery
|
||||
- Backward compatibility (local and rsync)
|
||||
- Service lifecycle management
|
||||
|
||||
### Integration Tests
|
||||
- `integration/backup-s3.test.js` - Integration tests for S3 backups
|
||||
- Real S3/MinIO connection tests
|
||||
- Full backup process with actual files
|
||||
- Incremental backup verification
|
||||
- Manifest storage and retrieval
|
||||
- Error recovery scenarios
|
||||
|
||||
### Manual Integration Test Script
|
||||
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
|
||||
- Can test against MinIO, AWS S3, or any S3-compatible service
|
||||
- Tests all backup types (S3, local, rsync)
|
||||
- Performance testing with large files
|
||||
- Detailed progress reporting
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
|
||||
|
||||
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
|
||||
```bash
|
||||
# Start MinIO using Docker
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
3. **Environment Variables** (for integration tests):
|
||||
```bash
|
||||
# Optional - defaults work with local MinIO
|
||||
export TEST_S3_ENDPOINT=http://localhost:9000
|
||||
export TEST_S3_ACCESS_KEY=minioadmin
|
||||
export TEST_S3_SECRET_KEY=minioadmin
|
||||
|
||||
# Skip S3 tests if no S3 service available
|
||||
export SKIP_S3_TESTS=true
|
||||
```
|
||||
|
||||
### Running Unit Tests
|
||||
|
||||
```bash
|
||||
# Run all backup service tests
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Ensure MinIO is running first!
|
||||
|
||||
# Run S3 integration tests
|
||||
npm test -- __tests__/integration/backup-s3.test.js
|
||||
|
||||
# Run with verbose output
|
||||
npm test -- __tests__/integration/backup-s3.test.js --verbose
|
||||
|
||||
# Skip S3 tests if needed
|
||||
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
### Running Manual Integration Tests
|
||||
|
||||
```bash
|
||||
# Test with local MinIO (default)
|
||||
node scripts/test-backup-integration.js
|
||||
|
||||
# Test with AWS S3
|
||||
node scripts/test-backup-integration.js \
|
||||
--endpoint https://s3.amazonaws.com \
|
||||
--access-key YOUR_ACCESS_KEY \
|
||||
--secret-key YOUR_SECRET_KEY \
|
||||
--bucket your-test-bucket
|
||||
|
||||
# Test local backup
|
||||
node scripts/test-backup-integration.js --type local
|
||||
|
||||
# Test with cleanup after completion
|
||||
node scripts/test-backup-integration.js --cleanup
|
||||
|
||||
# Verbose output
|
||||
node scripts/test-backup-integration.js --verbose
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
### Configuration
|
||||
- ✅ Database configuration retrieval
|
||||
- ✅ JSON parsing and error handling
|
||||
- ✅ Configuration validation
|
||||
- ✅ Required field validation
|
||||
|
||||
### S3 Functionality
|
||||
- ✅ S3 client initialization
|
||||
- ✅ Connection testing
|
||||
- ✅ File upload with progress tracking
|
||||
- ✅ Large file handling (multipart upload)
|
||||
- ✅ Metadata and custom headers
|
||||
- ✅ Error handling and retries
|
||||
|
||||
### Backup Process
|
||||
- ✅ Full backup execution
|
||||
- ✅ Incremental backup (changed files only)
|
||||
- ✅ File checksum calculation and comparison
|
||||
- ✅ Database backup inclusion
|
||||
- ✅ Archive inclusion toggle
|
||||
- ✅ File size limits
|
||||
|
||||
### Manifest Generation
|
||||
- ✅ Full manifest generation
|
||||
- ✅ Incremental manifest with parent reference
|
||||
- ✅ JSON and YAML format support
|
||||
- ✅ Manifest validation
|
||||
- ✅ S3 manifest storage and retrieval
|
||||
- ✅ Checksum verification
|
||||
|
||||
### Error Handling
|
||||
- ✅ S3 connection failures
|
||||
- ✅ File read errors
|
||||
- ✅ Individual file failure recovery
|
||||
- ✅ Retry logic with exponential backoff
|
||||
- ✅ Email notifications on failure
|
||||
- ✅ Concurrent backup prevention
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Local directory backup
|
||||
- ✅ Rsync backup
|
||||
- ✅ Existing manifest format support
|
||||
|
||||
### Service Management
|
||||
- ✅ Cron job scheduling
|
||||
- ✅ Service start/stop
|
||||
- ✅ Manual backup triggering
|
||||
- ✅ Backup history and status
|
||||
|
||||
## Mock Setup
|
||||
|
||||
The unit tests use comprehensive mocking:
|
||||
|
||||
```javascript
|
||||
// Database mocking
|
||||
jest.mock('../../src/database/db');
|
||||
|
||||
// S3 client mocking
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
// File system mocking
|
||||
const mockFs = require('mock-fs');
|
||||
|
||||
// Cron job mocking
|
||||
jest.mock('node-cron');
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
To run tests in CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions
|
||||
- name: Run Unit Tests
|
||||
run: npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
```bash
|
||||
# Run tests in debug mode
|
||||
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run single test with console output
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Integration tests create real files and S3 objects
|
||||
- Each test run creates a unique S3 bucket to avoid conflicts
|
||||
- Cleanup is automatic but can be disabled for debugging
|
||||
- Large file tests (10MB+) are included but can be slow
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new backup features:
|
||||
|
||||
1. Add unit tests to `backupService.enhanced.test.js`
|
||||
2. Add integration tests to `backup-s3.test.js` if S3-specific
|
||||
3. Update manual test script for comprehensive testing
|
||||
4. Ensure mocks are properly configured
|
||||
5. Document any new environment requirements
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin photos in reference mode', () => {
|
||||
let tmpDir;
|
||||
let storagePath;
|
||||
let db;
|
||||
let app;
|
||||
let categoryId;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||
validateUploadedFiles: (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: () => true,
|
||||
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}));
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
db = dbModule.db;
|
||||
|
||||
await db.schema.dropTableIfExists('photo_feedback');
|
||||
await db.schema.dropTableIfExists('photos');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
await db.schema.dropTableIfExists('events');
|
||||
|
||||
await db.schema.createTable('events', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.string('source_mode').notNullable();
|
||||
table.string('external_path');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.string('slug').notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photos', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable();
|
||||
table.string('filename').notNullable();
|
||||
table.string('path').notNullable();
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
table.string('source_origin');
|
||||
table.string('external_relpath');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('photo_id');
|
||||
table.string('feedback_type');
|
||||
table.boolean('is_approved');
|
||||
table.boolean('is_hidden');
|
||||
});
|
||||
|
||||
await db('events').insert({
|
||||
id: 1,
|
||||
slug: 'test-event',
|
||||
event_name: 'Test Event',
|
||||
source_mode: 'reference',
|
||||
external_path: 'external/library'
|
||||
});
|
||||
|
||||
const insertedCategory = await db('photo_categories').insert({
|
||||
name: 'Highlights',
|
||||
slug: 'highlights',
|
||||
is_global: true
|
||||
});
|
||||
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||
|
||||
const router = require('../../src/routes/adminPhotos');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
}
|
||||
resetModules();
|
||||
delete process.env.TEST_DATABASE_PATH;
|
||||
delete process.env.STORAGE_PATH;
|
||||
if (tmpDir) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stores managed uploads with category information and managed origin', async () => {
|
||||
const uploadResponse = await request(app)
|
||||
.post(`/api/admin/events/1/upload`)
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
const photo = await db('photos').first();
|
||||
expect(photo).toBeTruthy();
|
||||
expect(photo.category_id).toBe(categoryId);
|
||||
expect(photo.source_origin).toBe('managed');
|
||||
expect(photo.external_relpath).toBeNull();
|
||||
});
|
||||
|
||||
it('returns numeric category metadata when listing photos', async () => {
|
||||
await db('photos').insert({
|
||||
event_id: 1,
|
||||
filename: 'external.jpg',
|
||||
path: 'test-event/external.jpg',
|
||||
thumbnail_path: null,
|
||||
type: 'individual',
|
||||
size_bytes: 123,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'individual/external.jpg'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||
expect(managedPhoto).toBeTruthy();
|
||||
expect(managedPhoto.category_name).toBe('Highlights');
|
||||
|
||||
const filtered = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.query({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
|
||||
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes category updates', async () => {
|
||||
const photo = await db('photos').first();
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: '0' })
|
||||
.expect(200);
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initialize: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
region: 'us-east-1'
|
||||
};
|
||||
|
||||
describe('S3 Backup Integration Tests', () => {
|
||||
let s3Client;
|
||||
let testStoragePath;
|
||||
let originalEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if no S3 endpoint configured
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Initialize S3 client for test setup
|
||||
s3Client = new S3Client({
|
||||
endpoint: TEST_CONFIG.endpoint,
|
||||
region: TEST_CONFIG.region,
|
||||
credentials: {
|
||||
accessKeyId: TEST_CONFIG.accessKeyId,
|
||||
secretAccessKey: TEST_CONFIG.secretAccessKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
// Create test bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
||||
console.error('Failed to create test bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
await fs.mkdir(testStoragePath, { recursive: true });
|
||||
process.env.STORAGE_PATH = testStoragePath;
|
||||
|
||||
// Set up test data
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
try {
|
||||
// Clean up S3 bucket
|
||||
await cleanupS3Bucket();
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 bucket:', error);
|
||||
}
|
||||
|
||||
// Clean up test storage
|
||||
await fs.rm(testStoragePath, { recursive: true, force: true });
|
||||
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Close database
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean backup tables
|
||||
await db('backup_runs').del();
|
||||
await db('backup_file_states').del();
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
// Configure S3 backup settings
|
||||
await configureS3Backup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Clean up S3 objects created during test
|
||||
await cleanupS3Bucket();
|
||||
});
|
||||
|
||||
describe('S3 Connection and Configuration', () => {
|
||||
it('should successfully connect to S3-compatible storage', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
...TEST_CONFIG,
|
||||
bucket: TEST_CONFIG.bucket,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false
|
||||
});
|
||||
|
||||
const connected = await s3Adapter.testConnection();
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate S3 configuration before backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Remove required configuration
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_secret_key')
|
||||
.del();
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const lastRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(lastRun.status).toBe('failed');
|
||||
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full S3 Backup Process', () => {
|
||||
it('should perform complete S3 backup with all file types', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify backup run completed
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
expect(s3Objects.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for expected file types
|
||||
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
|
||||
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
|
||||
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
|
||||
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
|
||||
|
||||
expect(hasPhotos).toBe(true);
|
||||
expect(hasThumbnails).toBe(true);
|
||||
expect(hasManifest).toBe(true);
|
||||
expect(hasSummary).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large file uploads with multipart', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a large test file (15MB)
|
||||
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
|
||||
const largeFileSize = 15 * 1024 * 1024; // 15MB
|
||||
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
|
||||
await fs.writeFile(largeFilePath, largeFileContent);
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify large file was uploaded
|
||||
const s3Objects = await listS3Objects();
|
||||
const largeFileUploaded = s3Objects.some(obj =>
|
||||
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
|
||||
);
|
||||
|
||||
expect(largeFileUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should include database backup when available', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a mock database backup
|
||||
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
|
||||
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
|
||||
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
|
||||
|
||||
// Record database backup
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'sqlite',
|
||||
file_path: dbBackupPath,
|
||||
file_size_bytes: 100,
|
||||
checksum: 'test123',
|
||||
statistics: JSON.stringify({ tables: {} }),
|
||||
table_checksums: JSON.stringify({})
|
||||
});
|
||||
|
||||
// Configure to include database
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_include_database')
|
||||
.update({ setting_value: 'true' });
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
|
||||
expect(hasDbBackup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Incremental Backup', () => {
|
||||
it('should only upload changed files in incremental backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// First backup - full
|
||||
await backupService.runBackup();
|
||||
|
||||
const firstRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const firstObjectCount = (await listS3Objects()).length;
|
||||
|
||||
// Wait a moment to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Modify one file
|
||||
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
|
||||
await fs.writeFile(modifiedFile, 'modified content');
|
||||
|
||||
// Second backup - incremental
|
||||
await backupService.runBackup();
|
||||
|
||||
const secondRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(secondRun.files_backed_up).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should track file states across backups', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Check file states are recorded
|
||||
const fileStates = await db('backup_file_states').select('*');
|
||||
expect(fileStates.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify checksums are stored
|
||||
const hasChecksums = fileStates.every(state => state.checksum !== null);
|
||||
expect(hasChecksums).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Manifest Storage', () => {
|
||||
it('should upload manifest to S3 and retrieve it', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure YAML manifest format
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_manifest_format')
|
||||
.update({ setting_value: '"yaml"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
|
||||
|
||||
// Retrieve manifest
|
||||
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
|
||||
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest.backup.id).toBeDefined();
|
||||
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
|
||||
});
|
||||
|
||||
it('should validate manifest integrity', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
|
||||
|
||||
expect(validationResult.valid).toBe(true);
|
||||
expect(validationResult.manifest).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle S3 connection failures gracefully', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure with invalid endpoint
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_endpoint')
|
||||
.update({ setting_value: '"http://invalid-endpoint:9999"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('failed');
|
||||
expect(backupRun.error_message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should continue backup despite individual file failures', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a file that will be deleted during backup
|
||||
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
|
||||
await fs.writeFile(tempFile, 'temporary');
|
||||
|
||||
// Mock file deletion during backup
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let callCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
// Delete the temp file to cause an error
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should complete despite one file error
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
|
||||
it('should retry failed uploads with exponential backoff', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Mock S3 upload to fail twice then succeed
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let attemptCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
attemptCount++;
|
||||
if (attemptCount <= 2) {
|
||||
const error = new Error('Network timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should succeed after retries
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(attemptCount).toBeGreaterThan(2);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test directory structure
|
||||
const dirs = [
|
||||
'events/active/event1',
|
||||
'events/active/event2',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'uploads'
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files
|
||||
const files = [
|
||||
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
|
||||
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
|
||||
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
|
||||
{ path: 'events/archived/old-event.zip', content: 'archived content' },
|
||||
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
|
||||
{ path: 'uploads/logo.png', content: 'logo content' }
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
await fs.writeFile(
|
||||
path.join(testStoragePath, file.path),
|
||||
file.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureS3Backup() {
|
||||
const settings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
|
||||
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
|
||||
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
|
||||
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: 'true' },
|
||||
{ setting_key: 'backup_incremental', setting_value: 'true' },
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict(['setting_type', 'setting_key'])
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
async function listS3Objects() {
|
||||
const response = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: TEST_CONFIG.bucket
|
||||
}));
|
||||
return response.Contents || [];
|
||||
}
|
||||
|
||||
async function cleanupS3Bucket() {
|
||||
try {
|
||||
const objects = await listS3Objects();
|
||||
if (objects.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: TEST_CONFIG.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map(obj => ({ Key: obj.Key }))
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
// Mock dependencies before requiring the module
|
||||
jest.mock('../../src/database/db');
|
||||
jest.mock('../../src/utils/logger');
|
||||
jest.mock('../../src/services/emailProcessor');
|
||||
jest.mock('node-cron');
|
||||
jest.mock('../../src/services/backupManifest');
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const { db } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
const { queueEmail } = require('../../src/services/emailProcessor');
|
||||
const cron = require('node-cron');
|
||||
const backupManifest = require('../../src/services/backupManifest');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
|
||||
describe('Enhanced Backup Service Tests', () => {
|
||||
let mockDb;
|
||||
let mockS3Client;
|
||||
let mockCronJob;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
first: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
db.mockReturnValue(mockDb);
|
||||
|
||||
// Mock cron job
|
||||
mockCronJob = {
|
||||
stop: jest.fn()
|
||||
};
|
||||
cron.schedule.mockReturnValue(mockCronJob);
|
||||
|
||||
// Mock S3 client
|
||||
mockS3Client = {
|
||||
testConnection: jest.fn().mockResolvedValue(true),
|
||||
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
||||
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
||||
download: jest.fn().mockResolvedValue(),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(),
|
||||
list: jest.fn().mockResolvedValue({ Contents: [] })
|
||||
};
|
||||
S3StorageAdapter.mockImplementation(() => mockS3Client);
|
||||
|
||||
// Mock backup manifest
|
||||
backupManifest.generateManifest = jest.fn().mockResolvedValue({
|
||||
backup: { id: 'test-backup-123' },
|
||||
version: '2.0'
|
||||
});
|
||||
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
|
||||
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
|
||||
backupManifest.validateManifest = jest.fn();
|
||||
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
|
||||
|
||||
// Mock logger
|
||||
logger.info = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('should retrieve and parse backup configuration from database', async () => {
|
||||
const mockSettings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
|
||||
{ setting_key: 'backup_retention_days', setting_value: '30' }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue(mockSettings);
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_retention_days: 30
|
||||
});
|
||||
|
||||
expect(db).toHaveBeenCalledWith('app_settings');
|
||||
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors gracefully', async () => {
|
||||
const mockSettings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue(mockSettings);
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
backup_enabled: 'invalid-json'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null on database error', async () => {
|
||||
mockDb.select.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Backup Functionality', () => {
|
||||
beforeEach(() => {
|
||||
// Mock file system
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content'),
|
||||
'photo2.jpg': Buffer.from('photo2 content')
|
||||
},
|
||||
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
|
||||
'/storage/thumbnails': {
|
||||
'thumb1.jpg': Buffer.from('thumb1 content')
|
||||
},
|
||||
'/storage/uploads': {
|
||||
'logo.png': Buffer.from('logo content')
|
||||
}
|
||||
});
|
||||
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
});
|
||||
|
||||
it('should perform S3 backup with correct configuration', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_region: 'us-east-1',
|
||||
backup_s3_endpoint: 'https://s3.amazonaws.com',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_include_archived: true,
|
||||
backup_max_file_size_mb: 100
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.where.mockReturnThis();
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
||||
type: 'sqlite',
|
||||
backupFile: null,
|
||||
hasChanged: true
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalledWith({
|
||||
bucket: 'test-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
accessKeyId: 'test-key',
|
||||
secretAccessKey: 'test-secret',
|
||||
forcePathStyle: false,
|
||||
sslEnabled: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000
|
||||
});
|
||||
|
||||
expect(mockS3Client.testConnection).toHaveBeenCalled();
|
||||
expect(mockS3Client.upload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle S3 upload failures gracefully', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
|
||||
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'failed',
|
||||
error_message: expect.stringContaining('Connection failed')
|
||||
}));
|
||||
});
|
||||
|
||||
it('should skip unchanged files in incremental backup', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_incremental: true
|
||||
};
|
||||
|
||||
// Mock existing file state
|
||||
mockDb.first.mockImplementation((query) => {
|
||||
if (query === undefined) {
|
||||
return Promise.resolve({
|
||||
file_path: 'events/active/event1/photo1.jpg',
|
||||
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Should skip unchanged file
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const photo1Uploaded = uploadCalls.some(call =>
|
||||
call[1].includes('photo1.jpg')
|
||||
);
|
||||
expect(photo1Uploaded).toBe(false);
|
||||
});
|
||||
|
||||
it('should include database backup when configured', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_include_database: true
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
||||
type: 'sqlite',
|
||||
backupFile: '/backup/db-backup.sql',
|
||||
size: 1024000,
|
||||
checksum: 'abc123',
|
||||
hasChanged: false
|
||||
});
|
||||
|
||||
// Mock database backup file
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup/db-backup.sql': Buffer.from('database backup content')
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup was uploaded
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const dbBackupUploaded = uploadCalls.some(call =>
|
||||
call[1].includes('database/db-backup.sql')
|
||||
);
|
||||
expect(dbBackupUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate required S3 configuration', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket'
|
||||
// Missing access key and secret key
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'S3 backup failed:',
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('S3 backup configuration incomplete')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manifest Generation', () => {
|
||||
it('should generate and save manifest for successful backup', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup',
|
||||
backup_manifest_format: 'json'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content')
|
||||
},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup',
|
||||
format: 'json'
|
||||
})
|
||||
);
|
||||
|
||||
expect(backupManifest.saveManifest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate incremental manifest when parent exists', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup'
|
||||
};
|
||||
|
||||
const lastBackup = {
|
||||
id: 1,
|
||||
manifest_path: '/backup/manifests/previous.json',
|
||||
manifest_id: 'previous-backup-123'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([2]);
|
||||
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
|
||||
mockDb.orderBy.mockReturnThis();
|
||||
mockDb.where.mockReturnThis();
|
||||
mockDb.whereNot = jest.fn().mockReturnThis();
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
|
||||
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upload manifest to S3 for S3 backups', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret',
|
||||
backup_manifest_format: 'yaml'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
const manifest = {
|
||||
backup: { id: 'backup-123' },
|
||||
version: '2.0'
|
||||
};
|
||||
backupManifest.generateManifest.mockResolvedValue(manifest);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/storage/temp': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify manifest was uploaded to S3
|
||||
const uploadCalls = mockS3Client.upload.mock.calls;
|
||||
const manifestUploaded = uploadCalls.some(call =>
|
||||
call[1].includes('manifests/backup-manifest-backup-123.yaml')
|
||||
);
|
||||
expect(manifestUploaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should support local backup destination', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup/local'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active/event1': {
|
||||
'photo1.jpg': Buffer.from('photo1 content')
|
||||
},
|
||||
'/backup/local': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify files were copied to local destination
|
||||
const fs = require('fs');
|
||||
const destPath = '/backup/local/events/active/event1/photo1.jpg';
|
||||
expect(fs.existsSync(destPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support rsync backup destination', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'rsync',
|
||||
backup_rsync_host: 'backup.example.com',
|
||||
backup_rsync_user: 'backup',
|
||||
backup_rsync_path: '/remote/backup'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
// Mock exec for rsync
|
||||
const { exec } = require('child_process');
|
||||
const mockExec = jest.fn((cmd, callback) => {
|
||||
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
|
||||
});
|
||||
exec.mockImplementation(mockExec);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('rsync'),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_s3_access_key: 'test-key',
|
||||
backup_s3_secret_key: 'test-secret'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.first.mockResolvedValue(null);
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
// Mock file that throws error on read
|
||||
const fs = require('fs');
|
||||
const originalCreateReadStream = fs.createReadStream;
|
||||
fs.createReadStream = jest.fn((path) => {
|
||||
if (path.includes('error.jpg')) {
|
||||
const stream = new EventEmitter();
|
||||
process.nextTick(() => stream.emit('error', new Error('File read error')));
|
||||
return stream;
|
||||
}
|
||||
return originalCreateReadStream(path);
|
||||
});
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {
|
||||
'error.jpg': Buffer.from('content'),
|
||||
'good.jpg': Buffer.from('content')
|
||||
}
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Should continue with other files despite error
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to backup file'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
fs.createReadStream = originalCreateReadStream;
|
||||
});
|
||||
|
||||
it('should send failure email on backup error', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_bucket: 'test-bucket',
|
||||
backup_email_on_failure: true
|
||||
};
|
||||
|
||||
const admins = [
|
||||
{ email: 'admin1@example.com', is_active: true },
|
||||
{ email: 'admin2@example.com', is_active: true }
|
||||
];
|
||||
|
||||
mockDb.select.mockResolvedValue([]);
|
||||
mockDb.insert.mockResolvedValue([1]);
|
||||
mockDb.where.mockReturnThis();
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig')
|
||||
.mockResolvedValueOnce(config)
|
||||
.mockResolvedValueOnce(config);
|
||||
|
||||
// Force an error
|
||||
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
|
||||
|
||||
// Mock admin users query
|
||||
db.mockImplementation((table) => {
|
||||
if (table === 'admin_users') {
|
||||
return {
|
||||
where: jest.fn().mockResolvedValue(admins)
|
||||
};
|
||||
}
|
||||
return mockDb;
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
expect(queueEmail).toHaveBeenCalledTimes(2);
|
||||
expect(queueEmail).toHaveBeenCalledWith(
|
||||
null,
|
||||
'admin1@example.com',
|
||||
'backup_failed',
|
||||
expect.objectContaining({
|
||||
error_message: 'Storage error'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle concurrent backup attempts', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_destination_type: 'local',
|
||||
backup_destination_path: '/backup'
|
||||
};
|
||||
|
||||
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
||||
|
||||
mockFs({
|
||||
'/storage/events/active': {},
|
||||
'/backup': {}
|
||||
});
|
||||
|
||||
// Start two backups concurrently
|
||||
const backup1 = backupService.runBackup();
|
||||
const backup2 = backupService.runBackup();
|
||||
|
||||
await Promise.all([backup1, backup2]);
|
||||
|
||||
// Second backup should be skipped
|
||||
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Lifecycle', () => {
|
||||
it('should start backup service with cron schedule', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_schedule: '0 3 * * *' // 3 AM daily
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue(
|
||||
Object.entries(config).map(([key, value]) => ({
|
||||
setting_key: key,
|
||||
setting_value: value.toString()
|
||||
}))
|
||||
);
|
||||
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
|
||||
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
|
||||
});
|
||||
|
||||
it('should stop existing job when restarting service', async () => {
|
||||
const config = {
|
||||
backup_enabled: true,
|
||||
backup_schedule: '0 2 * * *'
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue(
|
||||
Object.entries(config).map(([key, value]) => ({
|
||||
setting_key: key,
|
||||
setting_value: value.toString()
|
||||
}))
|
||||
);
|
||||
|
||||
// Start service twice
|
||||
await backupService.startBackupService();
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(mockCronJob.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start service when backup is disabled', async () => {
|
||||
const config = {
|
||||
backup_enabled: false
|
||||
};
|
||||
|
||||
mockDb.select.mockResolvedValue([
|
||||
{ setting_key: 'backup_enabled', setting_value: 'false' }
|
||||
]);
|
||||
|
||||
await backupService.startBackupService();
|
||||
|
||||
expect(cron.schedule).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup Status and History', () => {
|
||||
it('should return backup status with recent runs', async () => {
|
||||
const recentRuns = [
|
||||
{
|
||||
id: 1,
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
files_backed_up: 100,
|
||||
total_size_bytes: 1024000,
|
||||
manifest_path: '/backup/manifest.json'
|
||||
}
|
||||
];
|
||||
|
||||
mockDb.limit.mockResolvedValue(recentRuns);
|
||||
|
||||
backupManifest.validateManifest.mockImplementation(() => true);
|
||||
|
||||
const status = await backupService.getBackupStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
isRunning: false,
|
||||
isHealthy: true,
|
||||
lastRun: expect.objectContaining({
|
||||
...recentRuns[0],
|
||||
manifestValid: true
|
||||
}),
|
||||
recentRuns: recentRuns,
|
||||
nextScheduledRun: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up old backup runs', async () => {
|
||||
mockDb.delete.mockResolvedValue(5);
|
||||
|
||||
await backupService.cleanupOldBackupRuns(30);
|
||||
|
||||
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackupManifest', () => {
|
||||
it('should retrieve manifest from local filesystem', async () => {
|
||||
const backupRun = {
|
||||
id: 1,
|
||||
manifest_path: '/backup/manifests/backup-123.json'
|
||||
};
|
||||
|
||||
mockDb.first.mockResolvedValue(backupRun);
|
||||
|
||||
const manifest = { backup: { id: 'backup-123' } };
|
||||
backupManifest.loadManifest.mockResolvedValue(manifest);
|
||||
backupManifest.generateSummaryReport.mockReturnValue('Summary');
|
||||
|
||||
const result = await backupService.getBackupManifest(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
manifest: manifest,
|
||||
summary: 'Summary'
|
||||
});
|
||||
});
|
||||
|
||||
it('should retrieve manifest from S3', async () => {
|
||||
const backupRun = {
|
||||
id: 1,
|
||||
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
|
||||
};
|
||||
|
||||
mockDb.first.mockResolvedValue(backupRun);
|
||||
mockDb.select.mockResolvedValue([
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
|
||||
]);
|
||||
|
||||
const manifest = { backup: { id: 'backup-123' } };
|
||||
backupManifest.loadManifest.mockResolvedValue(manifest);
|
||||
|
||||
await backupService.getBackupManifest(1);
|
||||
|
||||
expect(S3StorageAdapter).toHaveBeenCalled();
|
||||
expect(mockS3Client.download).toHaveBeenCalledWith(
|
||||
'backups/manifests/backup-123.json',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const path = require('path');
|
||||
const mockPath = path;
|
||||
|
||||
jest.mock('../../src/services/externalMediaService', () => ({
|
||||
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||
}));
|
||||
|
||||
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||
|
||||
describe('resolvePhotoFilePath', () => {
|
||||
const backendRoot = path.resolve(__dirname, '../../');
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (typeof originalStoragePath === 'string') {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
} else {
|
||||
delete process.env.STORAGE_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('delegates external photos to external media resolver', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||
});
|
||||
|
||||
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||
});
|
||||
|
||||
it('falls back to managed storage when external metadata is missing', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { path: 'fashion-show/new-upload.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).not.toHaveBeenCalled();
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
|
||||
});
|
||||
|
||||
it('throws when external photo is missing relative path data', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external' };
|
||||
|
||||
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Security Logging Documentation
|
||||
|
||||
## Overview
|
||||
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
|
||||
|
||||
## Log Files
|
||||
|
||||
### 1. **security.log**
|
||||
- Location: `logs/security.log`
|
||||
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
|
||||
- Max Size: 20MB with rotation (keeps 10 files)
|
||||
- Format: JSON with timestamp
|
||||
|
||||
### 2. **error.log**
|
||||
- Location: `logs/error.log`
|
||||
- Contains: All error-level logs including auth failures
|
||||
- Max Size: 10MB with rotation (keeps 5 files)
|
||||
|
||||
### 3. **combined.log**
|
||||
- Location: `logs/combined.log`
|
||||
- Contains: All logs (info, warn, error)
|
||||
- Max Size: 50MB with rotation (keeps 10 files)
|
||||
|
||||
## Security Events Logged
|
||||
|
||||
### Rate Limiting
|
||||
When rate limits are exceeded, the following is logged:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "Rate limit exceeded",
|
||||
"security": true,
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/login",
|
||||
"method": "POST",
|
||||
"authenticated": false,
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"referer": "https://app.example.com",
|
||||
"origin": "https://app.example.com",
|
||||
"headers": {
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.1"
|
||||
},
|
||||
"requestUrl": "/api/admin/login",
|
||||
"rateLimitInfo": {
|
||||
"limit": 5,
|
||||
"current": 6,
|
||||
"remaining": 0,
|
||||
"resetTime": "2024-01-18T14:38:45.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
#### Admin Login Failures
|
||||
- Tracked in `login_attempts` table
|
||||
- Logged with: IP address, username, user agent, timestamp
|
||||
- Account lockout after 5 failures in 15 minutes
|
||||
|
||||
#### Gallery Password Failures
|
||||
- Tracked in `access_logs` table with action='login_fail'
|
||||
- Logged with: event_id, IP address, user agent
|
||||
- Gallery lockout after 5 failures in 15 minutes
|
||||
|
||||
### JWT Validation Failures
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "JWT validation failed",
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/events",
|
||||
"method": "GET",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"error": "TokenExpiredError",
|
||||
"message": "jwt expired"
|
||||
}
|
||||
```
|
||||
|
||||
### Suspicious Activity
|
||||
- Multiple IPs attempting login for same account
|
||||
- Token usage from different IP than issued
|
||||
- Token usage after password change
|
||||
- Revoked token usage attempts
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
All rate limiting settings are configurable via the admin panel:
|
||||
|
||||
| Setting | Default | Range | Description |
|
||||
|---------|---------|-------|-------------|
|
||||
| rate_limit_enabled | true | - | Enable/disable rate limiting |
|
||||
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
|
||||
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
|
||||
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
|
||||
|
||||
## Database Tables
|
||||
|
||||
### login_attempts
|
||||
```sql
|
||||
- id
|
||||
- username
|
||||
- ip_address
|
||||
- user_agent
|
||||
- success (boolean)
|
||||
- created_at
|
||||
```
|
||||
|
||||
### access_logs
|
||||
```sql
|
||||
- id
|
||||
- event_id
|
||||
- ip_address
|
||||
- user_agent
|
||||
- action ('view', 'download', 'login_success', 'login_fail')
|
||||
- photo_id (nullable)
|
||||
- created_at
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `LOG_LEVEL`: Set logging level (default: 'info')
|
||||
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
|
||||
|
||||
## Monitoring Recommendations
|
||||
|
||||
1. **Set up alerts for:**
|
||||
- Rate limit exceeded events (possible DDoS)
|
||||
- Multiple failed login attempts from same IP
|
||||
- Account lockout events
|
||||
- JWT validation failures spike
|
||||
|
||||
2. **Regular review:**
|
||||
- Check security.log for patterns
|
||||
- Review login_attempts table for brute force attempts
|
||||
- Monitor access_logs for suspicious gallery access patterns
|
||||
|
||||
3. **Log analysis tools:**
|
||||
- Use log aggregation tools (ELK stack, Splunk)
|
||||
- Set up dashboards for security metrics
|
||||
- Configure alerts for threshold breaches
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
1. Ensure logs directory has proper permissions
|
||||
2. Set up log rotation outside of application if needed
|
||||
3. Consider shipping logs to centralized logging service
|
||||
4. Monitor disk space for log files
|
||||
5. Set `LOG_TO_CONSOLE=true` for container deployments
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log sensitive data (passwords, tokens)
|
||||
2. Use generic error messages to prevent user enumeration
|
||||
3. Clean up old login attempts regularly (7 days retention)
|
||||
4. Monitor for unusual patterns in real-time
|
||||
5. Keep rate limit settings appropriate for your usage
|
||||
@@ -1,4 +1,10 @@
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
if (!process.env.SKIP_S3_TESTS) {
|
||||
process.env.SKIP_S3_TESTS = 'true';
|
||||
}
|
||||
if (!process.env.STORAGE_PATH) {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
+102
-25
@@ -3,6 +3,54 @@ require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const resolveSqliteFilename = (filenameEnv) => {
|
||||
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(__dirname, trimmed);
|
||||
} else {
|
||||
resolved = path.join(__dirname, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: resolveSqliteFilename(filenameEnv)
|
||||
});
|
||||
|
||||
const baseSqliteConfig = {
|
||||
client: 'sqlite3',
|
||||
connection: sqliteConnection(),
|
||||
useNullAsDefault: true,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
@@ -13,7 +61,7 @@ const config = {
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
migrations: {
|
||||
@@ -24,36 +72,65 @@ const config = {
|
||||
}
|
||||
},
|
||||
|
||||
test: (() => {
|
||||
const client = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
const isPostgres = client === 'pg';
|
||||
|
||||
return {
|
||||
...baseSqliteConfig,
|
||||
client,
|
||||
useNullAsDefault: !isPostgres,
|
||||
connection: isPostgres
|
||||
? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing_test'
|
||||
}
|
||||
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
|
||||
};
|
||||
})(),
|
||||
|
||||
production: {
|
||||
client: process.env.DATABASE_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
acquireTimeoutMillis: 30000,
|
||||
createTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
},
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
}
|
||||
: {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
}
|
||||
: undefined,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
acquireConnectionTimeout: 60000
|
||||
}
|
||||
};
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||
module.exports = config[env] || config.development;
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding photo categories and CMS tables...');
|
||||
|
||||
// Create photo_categories table
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
|
||||
// Unique constraint for slug within event scope
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
|
||||
// Create cms_pages table
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
// Add category_id to photos table
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
|
||||
// Add language preference to admin_users
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
|
||||
// Add language preference to app_settings for global default
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: 'en',
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Insert default global categories
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true }
|
||||
];
|
||||
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
|
||||
// Insert default legal pages
|
||||
await db('cms_pages').insert([
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('Photo categories and CMS tables created successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove language from app_settings
|
||||
await db('app_settings').where('setting_key', 'default_language').delete();
|
||||
|
||||
// Drop columns
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.dropColumn('language');
|
||||
});
|
||||
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('category_id');
|
||||
});
|
||||
|
||||
// Drop tables
|
||||
await db.schema.dropTableIfExists('cms_pages');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -1,23 +0,0 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add user upload settings to events table
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
// Add uploaded_by field to photos table to track who uploaded
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('allow_user_uploads');
|
||||
table.dropColumn('upload_category_id');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.dropColumn('uploaded_by');
|
||||
});
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.createTable('login_attempts', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier').notNullable(); // username or email
|
||||
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
||||
table.text('user_agent');
|
||||
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
||||
table.boolean('success').defaultTo(false);
|
||||
|
||||
// Indexes for performance
|
||||
table.index('identifier');
|
||||
table.index('attempt_time');
|
||||
table.index(['identifier', 'success', 'attempt_time']);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('login_attempts');
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.table('admin_users', table => {
|
||||
// Add password change tracking
|
||||
table.timestamp('password_changed_at').nullable();
|
||||
|
||||
// Add last login IP for security monitoring
|
||||
table.string('last_login_ip', 45).nullable();
|
||||
|
||||
// Add account security flags
|
||||
table.boolean('two_factor_enabled').defaultTo(false);
|
||||
table.string('two_factor_secret').nullable();
|
||||
|
||||
// Add index for performance
|
||||
table.index('password_changed_at');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.table('admin_users', table => {
|
||||
table.dropColumn('password_changed_at');
|
||||
table.dropColumn('last_login_ip');
|
||||
table.dropColumn('two_factor_enabled');
|
||||
table.dropColumn('two_factor_secret');
|
||||
});
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
// Table for individual token revocations
|
||||
.createTable('revoked_tokens', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
||||
table.integer('user_id').nullable(); // User who owned the token
|
||||
table.string('token_type', 20); // admin, gallery, etc.
|
||||
table.timestamp('revoked_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('expires_at').notNullable(); // When token would have expired
|
||||
table.string('reason', 100); // password_change, logout, compromised, etc.
|
||||
table.text('metadata'); // Additional JSON data
|
||||
|
||||
// Indexes for performance
|
||||
table.index('token_id');
|
||||
table.index('user_id');
|
||||
table.index('expires_at'); // For cleanup
|
||||
})
|
||||
// Table for user-level revocations (revoke all tokens before a certain time)
|
||||
.createTable('user_token_revocations', table => {
|
||||
table.integer('user_id').primary();
|
||||
table.timestamp('revoked_at').notNullable();
|
||||
table.string('reason', 100);
|
||||
|
||||
// Index for quick lookups
|
||||
table.index('revoked_at');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists('user_token_revocations')
|
||||
.dropTableIfExists('revoked_tokens');
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 041_add_logo_customization_settings');
|
||||
|
||||
// Add default logo customization settings
|
||||
const logoSettings = [
|
||||
{
|
||||
setting_key: 'branding_logo_size',
|
||||
setting_value: JSON.stringify('medium'),
|
||||
setting_type: 'branding',
|
||||
description: 'Logo size: small, medium, large, xlarge, or custom',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_max_height',
|
||||
setting_value: JSON.stringify(48),
|
||||
setting_type: 'branding',
|
||||
description: 'Maximum logo height in pixels (used when size is custom)',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_position',
|
||||
setting_value: JSON.stringify('left'),
|
||||
setting_type: 'branding',
|
||||
description: 'Logo position in header: left, center, right',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_header',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'branding',
|
||||
description: 'Show logo in gallery header',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_hero',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'branding',
|
||||
description: 'Show logo in hero section (for non-grid layouts)',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_mode',
|
||||
setting_value: JSON.stringify('logo_and_text'),
|
||||
setting_type: 'branding',
|
||||
description: 'Display mode: logo_only, text_only, logo_and_text',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
// Insert settings that don't already exist
|
||||
for (const setting of logoSettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert(setting);
|
||||
console.log(`Added setting: ${setting.setting_key}`);
|
||||
} else {
|
||||
console.log(`Setting already exists: ${setting.setting_key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration 041_add_logo_customization_settings completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back migration: 041_add_logo_customization_settings');
|
||||
|
||||
// Remove the logo customization settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_size',
|
||||
'branding_logo_max_height',
|
||||
'branding_logo_position',
|
||||
'branding_logo_display_header',
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_display_mode'
|
||||
])
|
||||
.del();
|
||||
|
||||
console.log('Rollback of 041_add_logo_customization_settings completed');
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains database migrations for the Wedding Photo Sharing platform.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### `/core`
|
||||
Essential migrations that are always run for new deployments. These include:
|
||||
- `init.js` - Initial database schema creation
|
||||
- Backup service tables (029-035)
|
||||
- Gallery feedback tables (033)
|
||||
|
||||
### `/legacy`
|
||||
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
|
||||
|
||||
## For New Deployments
|
||||
|
||||
If you're deploying this application for the first time:
|
||||
1. The `initializeDatabase()` function in `src/database/db.js` will create all necessary tables
|
||||
2. Only migrations in the `/core` directory will be run
|
||||
3. This ensures a clean, optimized database schema
|
||||
|
||||
## For Existing Deployments
|
||||
|
||||
If you're upgrading from an older version:
|
||||
1. All migrations (both core and legacy) will be run in sequence
|
||||
2. The migration system tracks which migrations have been applied
|
||||
3. Only new migrations will be executed
|
||||
|
||||
## Running Migrations
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run migrate
|
||||
|
||||
# Production
|
||||
npm run migrate:prod
|
||||
```
|
||||
|
||||
## Note on Duplicate Migration Numbers
|
||||
|
||||
The legacy directory contains renamed duplicates:
|
||||
- `014_add_host_name_to_events_duplicate.js` (was duplicate of 014)
|
||||
- `027_add_rate_limit_settings_duplicate.js` (was duplicate of 027)
|
||||
|
||||
These have been renamed to avoid conflicts while preserving the migration history.
|
||||
@@ -1,33 +1,39 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db, initializeDatabase } = require('../src/database/db');
|
||||
const { generateReadablePassword } = require('../src/utils/passwordGenerator');
|
||||
const { initializeDatabase } = require('../../src/database/db');
|
||||
const { generateReadablePassword } = require('../../src/utils/passwordGenerator');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
async function runMigrations() {
|
||||
console.log('Running database migrations...');
|
||||
exports.up = async function(knex) {
|
||||
console.log('Initializing database schema...');
|
||||
|
||||
try {
|
||||
// Initialize tables
|
||||
await initializeDatabase();
|
||||
|
||||
// Create default admin user if none exists
|
||||
const adminExists = await db('admin_users').first();
|
||||
const adminExists = await knex('admin_users').first();
|
||||
if (!adminExists) {
|
||||
// Generate a secure random password
|
||||
const generatedPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||
|
||||
await db('admin_users').insert({
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
// Get admin credentials from environment or use defaults
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
|
||||
await knex('admin_users').insert({
|
||||
username: adminUsername,
|
||||
email: adminEmail,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true, // Flag for forcing password change
|
||||
must_change_password: true,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
// Save the generated password to a file for the user to retrieve
|
||||
const setupInfoPath = path.join(__dirname, '..', '..', 'ADMIN_CREDENTIALS.txt');
|
||||
// Try to save credentials to file, but don't fail if we can't
|
||||
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||
const setupInfoPath = path.join(dataDir, 'ADMIN_CREDENTIALS.txt');
|
||||
|
||||
const setupInfo = `
|
||||
========================================
|
||||
PicPeak Admin Credentials
|
||||
@@ -35,39 +41,49 @@ PicPeak Admin Credentials
|
||||
|
||||
Your admin account has been created with these credentials:
|
||||
|
||||
Username: admin
|
||||
Email: ${adminEmail}
|
||||
Password: ${generatedPassword}
|
||||
|
||||
IMPORTANT SECURITY NOTES:
|
||||
1. You MUST change this password on first login
|
||||
1. Please change this password after first login
|
||||
2. This file will be created only once
|
||||
3. Store these credentials securely
|
||||
4. Delete this file after noting the password
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
|
||||
Login with the email address shown above
|
||||
|
||||
Generated on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
|
||||
await fs.writeFile(setupInfoPath, setupInfo, 'utf8');
|
||||
try {
|
||||
// Try to create directory and write file
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(setupInfoPath, setupInfo, 'utf8');
|
||||
console.log(`📁 Credentials also saved to: data/ADMIN_CREDENTIALS.txt`);
|
||||
} catch (error) {
|
||||
// If we can't write the file, that's okay - credentials are shown in console
|
||||
console.log('⚠️ Could not save credentials to file (permission denied)');
|
||||
console.log(' Please copy the credentials shown above');
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('✅ Admin user created successfully!');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Email: ${adminEmail}`);
|
||||
console.log(`Password: ${generatedPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. Save these credentials securely');
|
||||
console.log('2. You will be required to change the password on first login');
|
||||
console.log('3. Credentials are also saved in: ADMIN_CREDENTIALS.txt');
|
||||
console.log('2. Please change the password after first login');
|
||||
console.log('========================================\n');
|
||||
}
|
||||
|
||||
// Create default email templates if none exist
|
||||
const templateExists = await db('email_templates').first();
|
||||
const templateExists = await knex('email_templates').first();
|
||||
if (!templateExists) {
|
||||
await db('email_templates').insert([
|
||||
await knex('email_templates').insert([
|
||||
{
|
||||
template_key: 'gallery_created',
|
||||
subject: 'Your Photo Gallery is Ready!',
|
||||
@@ -101,9 +117,9 @@ Generated on: ${new Date().toISOString()}
|
||||
}
|
||||
|
||||
// Create default email config if none exists
|
||||
const emailConfig = await db('email_configs').first();
|
||||
const emailConfig = await knex('email_configs').first();
|
||||
if (!emailConfig) {
|
||||
await db('email_configs').insert({
|
||||
await knex('email_configs').insert({
|
||||
smtp_host: process.env.SMTP_HOST || 'mailhog',
|
||||
smtp_port: process.env.SMTP_PORT || 1025,
|
||||
smtp_secure: process.env.SMTP_SECURE === 'true',
|
||||
@@ -116,11 +132,13 @@ Generated on: ${new Date().toISOString()}
|
||||
}
|
||||
|
||||
console.log('Migrations completed successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
console.error('Initial setup failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
exports.down = async function(knex) {
|
||||
// This migration cannot be rolled back as it creates the initial schema
|
||||
console.log('Initial setup cannot be rolled back');
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding backup service tables and settings...');
|
||||
|
||||
// Create backup_runs table to track backup history
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (!hasBackupRunsTable) {
|
||||
await db.schema.createTable('backup_runs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed
|
||||
table.string('backup_type'); // full, incremental
|
||||
table.integer('files_backed_up').defaultTo(0);
|
||||
table.bigInteger('total_size_bytes').defaultTo(0);
|
||||
table.integer('duration_seconds');
|
||||
table.text('error_message');
|
||||
table.json('statistics'); // Detailed stats about the backup
|
||||
table.json('file_checksums'); // Store checksums for change detection
|
||||
});
|
||||
}
|
||||
|
||||
// Create backup_file_states table to track individual file states
|
||||
const hasBackupFileStatesTable = await db.schema.hasTable('backup_file_states');
|
||||
if (!hasBackupFileStatesTable) {
|
||||
await db.schema.createTable('backup_file_states', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('file_path').notNullable();
|
||||
table.string('checksum').notNullable();
|
||||
table.bigInteger('size_bytes');
|
||||
table.datetime('last_modified');
|
||||
table.datetime('last_backed_up');
|
||||
table.boolean('is_archived').defaultTo(false);
|
||||
table.index(['file_path'], 'idx_backup_file_path');
|
||||
table.index(['checksum'], 'idx_backup_checksum');
|
||||
});
|
||||
}
|
||||
|
||||
// Add backup-related settings to app_settings
|
||||
const backupSettings = [
|
||||
{
|
||||
setting_key: 'backup_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_schedule',
|
||||
setting_value: JSON.stringify('0 2 * * *'), // Default: 2 AM daily
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_destination_type',
|
||||
setting_value: JSON.stringify('local'), // local, rsync, s3
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_destination_path',
|
||||
setting_value: JSON.stringify('/backup/picpeak'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_host',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_user',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_path',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_rsync_ssh_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_endpoint',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_bucket',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_access_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_secret_key',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_region',
|
||||
setting_value: JSON.stringify('us-east-1'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_retention_days',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_compression',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_email_on_failure',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_email_on_success',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_max_file_size_mb',
|
||||
setting_value: JSON.stringify(5000), // Skip files larger than 5GB
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_exclude_patterns',
|
||||
setting_value: JSON.stringify(['*.tmp', '.DS_Store', 'Thumbs.db']),
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert backup settings if they don't exist
|
||||
for (const setting of backupSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add backup-related email templates
|
||||
const backupEmailTemplates = [
|
||||
{
|
||||
template_key: 'backup_failed',
|
||||
subject: 'Backup Failed - Immediate Attention Required',
|
||||
body_html: `<h2>Backup Failed</h2>
|
||||
<p>The scheduled backup has failed and requires immediate attention.</p>
|
||||
<p><strong>Error Details:</strong></p>
|
||||
<ul>
|
||||
<li>Start Time: {{start_time}}</li>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Error: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>Please check the system logs for more details and resolve the issue as soon as possible.</p>`,
|
||||
body_text: 'Backup Failed\n\nThe scheduled backup has failed and requires immediate attention.\n\nStart Time: {{start_time}}\nBackup Type: {{backup_type}}\nError: {{error_message}}\n\nPlease check the system logs for more details.',
|
||||
variables: JSON.stringify(['start_time', 'backup_type', 'error_message'])
|
||||
},
|
||||
{
|
||||
template_key: 'backup_completed',
|
||||
subject: 'Backup Completed Successfully',
|
||||
body_html: `<h2>Backup Completed</h2>
|
||||
<p>The scheduled backup has been completed successfully.</p>
|
||||
<p><strong>Backup Summary:</strong></p>
|
||||
<ul>
|
||||
<li>Start Time: {{start_time}}</li>
|
||||
<li>Duration: {{duration}}</li>
|
||||
<li>Files Backed Up: {{files_count}}</li>
|
||||
<li>Total Size: {{total_size}}</li>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
</ul>`,
|
||||
body_text: 'Backup Completed\n\nThe scheduled backup has been completed successfully.\n\nStart Time: {{start_time}}\nDuration: {{duration}}\nFiles Backed Up: {{files_count}}\nTotal Size: {{total_size}}\nBackup Type: {{backup_type}}',
|
||||
variables: JSON.stringify(['start_time', 'duration', 'files_count', 'total_size', 'backup_type'])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert backup email templates if they don't exist
|
||||
for (const template of backupEmailTemplates) {
|
||||
const exists = await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('email_templates').insert(template);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Backup service tables and settings added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove backup tables
|
||||
await db.schema.dropTableIfExists('backup_file_states');
|
||||
await db.schema.dropTableIfExists('backup_runs');
|
||||
|
||||
// Remove backup settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.delete();
|
||||
|
||||
// Remove backup email templates
|
||||
await db('email_templates')
|
||||
.whereIn('template_key', ['backup_failed', 'backup_completed'])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,159 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding database backup tables and settings...');
|
||||
|
||||
// Create database_backup_runs table to track database backup history
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (!hasDatabaseBackupRunsTable) {
|
||||
await db.schema.createTable('database_backup_runs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed
|
||||
table.string('backup_type'); // sqlite, postgresql
|
||||
table.string('destination_path');
|
||||
table.string('file_path');
|
||||
table.bigInteger('file_size_bytes').defaultTo(0);
|
||||
table.bigInteger('original_size_bytes').defaultTo(0);
|
||||
table.integer('duration_seconds');
|
||||
table.string('checksum'); // SHA256 checksum of backup file
|
||||
table.float('compression_ratio'); // Compression percentage
|
||||
table.json('table_checksums'); // Individual table checksums
|
||||
table.text('error_message');
|
||||
table.json('statistics'); // Detailed stats about the backup
|
||||
table.index(['started_at'], 'idx_db_backup_started');
|
||||
table.index(['status'], 'idx_db_backup_status');
|
||||
});
|
||||
}
|
||||
|
||||
// Add database backup-related settings to app_settings
|
||||
const databaseBackupSettings = [
|
||||
{
|
||||
setting_key: 'database_backup_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_schedule',
|
||||
setting_value: JSON.stringify('0 3 * * *'), // Default: 3 AM daily
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_destination_path',
|
||||
setting_value: JSON.stringify('/backup/database'),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_compress',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_validate_integrity',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_include_checksums',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_retention_days',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_email_on_failure',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_email_on_success',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'database_backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'database_backup_max_retries',
|
||||
setting_value: JSON.stringify(3),
|
||||
setting_type: 'database_backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert database backup settings if they don't exist
|
||||
for (const setting of databaseBackupSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add database backup-related email templates
|
||||
const databaseBackupEmailTemplates = [
|
||||
{
|
||||
template_key: 'database_backup_failed',
|
||||
subject: 'Database Backup Failed - Critical Alert',
|
||||
body_html: `<h2>Database Backup Failed</h2>
|
||||
<p>The scheduled database backup has failed and requires immediate attention.</p>
|
||||
<p><strong>Error Details:</strong></p>
|
||||
<ul>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Timestamp: {{timestamp}}</li>
|
||||
<li>Error: {{error_message}}</li>
|
||||
</ul>
|
||||
<p>This is a critical issue that could affect disaster recovery. Please investigate immediately.</p>`,
|
||||
body_text: 'Database Backup Failed\n\nThe scheduled database backup has failed.\n\nBackup Type: {{backup_type}}\nTimestamp: {{timestamp}}\nError: {{error_message}}\n\nThis is critical - please investigate immediately.',
|
||||
variables: JSON.stringify(['backup_type', 'timestamp', 'error_message'])
|
||||
},
|
||||
{
|
||||
template_key: 'database_backup_completed',
|
||||
subject: 'Database Backup Completed Successfully',
|
||||
body_html: `<h2>Database Backup Completed</h2>
|
||||
<p>The scheduled database backup has been completed successfully.</p>
|
||||
<p><strong>Backup Summary:</strong></p>
|
||||
<ul>
|
||||
<li>Backup Type: {{backup_type}}</li>
|
||||
<li>Duration: {{duration}}</li>
|
||||
<li>File Size: {{file_size}}</li>
|
||||
<li>Compression Ratio: {{compression_ratio}}</li>
|
||||
<li>File Path: {{file_path}}</li>
|
||||
</ul>`,
|
||||
body_text: 'Database Backup Completed\n\nThe scheduled database backup has been completed successfully.\n\nBackup Type: {{backup_type}}\nDuration: {{duration}}\nFile Size: {{file_size}}\nCompression Ratio: {{compression_ratio}}\nFile Path: {{file_path}}',
|
||||
variables: JSON.stringify(['backup_type', 'duration', 'file_size', 'compression_ratio', 'file_path'])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert database backup email templates if they don't exist
|
||||
for (const template of databaseBackupEmailTemplates) {
|
||||
const exists = await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('email_templates').insert(template);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Database backup tables and settings added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove database backup tables
|
||||
await db.schema.dropTableIfExists('database_backup_runs');
|
||||
|
||||
// Remove database backup settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'database_backup')
|
||||
.delete();
|
||||
|
||||
// Remove database backup email templates
|
||||
await db('email_templates')
|
||||
.whereIn('template_key', ['database_backup_failed', 'database_backup_completed'])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,86 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding backup manifest columns...');
|
||||
|
||||
// Add manifest columns to backup_runs table
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
if (!hasManifestPath) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.string('manifest_path'); // Path to the manifest file
|
||||
table.string('manifest_id'); // Unique manifest ID
|
||||
table.string('manifest_format').defaultTo('json'); // json or yaml
|
||||
});
|
||||
}
|
||||
|
||||
// Add backup manifest-related settings to app_settings
|
||||
const manifestSettings = [
|
||||
{
|
||||
setting_key: 'backup_manifest_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_format',
|
||||
setting_value: JSON.stringify('json'), // json or yaml
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_path',
|
||||
setting_value: JSON.stringify('/backup/manifests'),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_validate',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_manifest_include_checksums',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert manifest settings if they don't exist
|
||||
for (const setting of manifestSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✓ Backup manifest columns and settings added');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove manifest columns from backup_runs table
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
if (hasManifestPath) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.dropColumn('manifest_path');
|
||||
table.dropColumn('manifest_id');
|
||||
table.dropColumn('manifest_format');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove manifest settings
|
||||
await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.whereIn('setting_key', [
|
||||
'backup_manifest_enabled',
|
||||
'backup_manifest_format',
|
||||
'backup_manifest_path',
|
||||
'backup_manifest_validate',
|
||||
'backup_manifest_include_checksums'
|
||||
])
|
||||
.delete();
|
||||
|
||||
console.log('✓ Backup manifest columns and settings removed');
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,234 @@
|
||||
// No helpers needed for this migration
|
||||
|
||||
/**
|
||||
* Add restore_runs table for tracking restore operations
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// Create restore_runs table
|
||||
const hasRestoreRunsTable = await knex.schema.hasTable('restore_runs');
|
||||
if (!hasRestoreRunsTable) {
|
||||
await knex.schema.createTable('restore_runs', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
// Timing
|
||||
table.timestamp('started_at').notNullable().defaultTo(knex.fn.now());
|
||||
table.timestamp('completed_at');
|
||||
table.integer('duration_seconds');
|
||||
|
||||
// Status and type
|
||||
table.string('status', 50).notNullable().defaultTo('running');
|
||||
table.string('restore_type', 50).notNullable(); // full, database, files, selective
|
||||
|
||||
// Source information
|
||||
table.string('source', 500).notNullable(); // Backup source path or S3 URL
|
||||
table.string('manifest_path', 500); // Path to manifest file
|
||||
|
||||
// Results
|
||||
table.text('error_message');
|
||||
table.text('statistics'); // JSON object with detailed statistics
|
||||
table.text('restore_log'); // JSON array of log entries
|
||||
|
||||
// Safety backup
|
||||
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
|
||||
|
||||
// Flags
|
||||
table.boolean('is_dry_run').defaultTo(false);
|
||||
table.boolean('was_rollback_attempted').defaultTo(false);
|
||||
table.boolean('was_successful').defaultTo(false);
|
||||
|
||||
// Operator information
|
||||
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
|
||||
table.integer('operator_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.string('operator_ip', 50);
|
||||
|
||||
// Metadata
|
||||
table.text('metadata'); // JSON object for additional data
|
||||
|
||||
table.index(['status', 'started_at']);
|
||||
table.index(['restore_type', 'started_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create restore_file_operations table for tracking individual file operations
|
||||
const hasRestoreFileOperationsTable = await knex.schema.hasTable('restore_file_operations');
|
||||
if (!hasRestoreFileOperationsTable) {
|
||||
await knex.schema.createTable('restore_file_operations', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
table.integer('restore_run_id').notNullable()
|
||||
.references('id').inTable('restore_runs').onDelete('CASCADE');
|
||||
|
||||
table.string('file_path', 500).notNullable();
|
||||
table.string('operation', 50).notNullable(); // restore, skip, error
|
||||
table.string('status', 50).notNullable(); // pending, in_progress, completed, failed
|
||||
|
||||
table.bigInteger('file_size');
|
||||
table.string('checksum', 64);
|
||||
table.boolean('checksum_verified').defaultTo(false);
|
||||
|
||||
table.text('error_message');
|
||||
table.timestamp('started_at');
|
||||
table.timestamp('completed_at');
|
||||
|
||||
table.index(['restore_run_id', 'status']);
|
||||
table.index(['file_path']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create restore_validation_results table
|
||||
const hasRestoreValidationResultsTable = await knex.schema.hasTable('restore_validation_results');
|
||||
if (!hasRestoreValidationResultsTable) {
|
||||
await knex.schema.createTable('restore_validation_results', table => {
|
||||
table.increments('id').primary();
|
||||
|
||||
table.integer('restore_run_id').notNullable()
|
||||
.references('id').inTable('restore_runs').onDelete('CASCADE');
|
||||
|
||||
table.string('validation_type', 50).notNullable(); // pre-restore, post-restore
|
||||
table.boolean('is_valid').notNullable();
|
||||
|
||||
table.text('errors'); // JSON array of errors
|
||||
table.text('warnings'); // JSON array of warnings
|
||||
table.text('checksums'); // JSON object with checksum comparisons
|
||||
|
||||
table.timestamp('validated_at').notNullable().defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['restore_run_id', 'validation_type']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add restore-related settings to app_settings
|
||||
const restoreSettings = [
|
||||
{
|
||||
setting_key: 'restore_allow_force',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_require_pre_backup',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_max_file_size_mb',
|
||||
setting_value: '5000',
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_verify_checksums',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_email_on_completion',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
setting_key: 'restore_retention_days',
|
||||
setting_value: '30',
|
||||
setting_type: 'restore'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of restoreSettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new email templates for restore notifications
|
||||
const emailTemplates = [
|
||||
{
|
||||
template_key: 'restore_completed',
|
||||
subject: '✅ Restore Completed Successfully',
|
||||
body_html: `<h2>Restore Operation Completed</h2>
|
||||
<p>A restore operation has completed successfully.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
||||
<li><strong>Duration:</strong> {{duration}}</li>
|
||||
<li><strong>Files Restored:</strong> {{files_restored}}</li>
|
||||
<li><strong>Backup ID:</strong> {{backup_id}}</li>
|
||||
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
|
||||
body_text: `Restore Operation Completed
|
||||
|
||||
A restore operation has completed successfully.
|
||||
|
||||
Details:
|
||||
- Restore Type: {{restore_type}}
|
||||
- Duration: {{duration}}
|
||||
- Files Restored: {{files_restored}}
|
||||
- Backup ID: {{backup_id}}
|
||||
- Timestamp: {{timestamp}}
|
||||
|
||||
Please verify that all systems are functioning correctly after the restore.`,
|
||||
variables: JSON.stringify(['restore_type', 'duration', 'files_restored', 'backup_id', 'timestamp'])
|
||||
},
|
||||
{
|
||||
template_key: 'restore_failed',
|
||||
subject: '❌ Restore Operation Failed',
|
||||
body_html: `<h2>Restore Operation Failed</h2>
|
||||
<p>A restore operation has failed and requires attention.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
||||
<li><strong>Error:</strong> {{error_message}}</li>
|
||||
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
||||
</ul>
|
||||
|
||||
<p>Please check the system logs for more details and take appropriate action.</p>
|
||||
|
||||
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
|
||||
body_text: `Restore Operation Failed
|
||||
|
||||
A restore operation has failed and requires attention.
|
||||
|
||||
Details:
|
||||
- Restore Type: {{restore_type}}
|
||||
- Error: {{error_message}}
|
||||
- Timestamp: {{timestamp}}
|
||||
|
||||
Please check the system logs for more details and take appropriate action.
|
||||
|
||||
Important: If a pre-restore backup was created, it may be used for recovery.`,
|
||||
variables: JSON.stringify(['restore_type', 'error_message', 'timestamp'])
|
||||
}
|
||||
];
|
||||
|
||||
for (const template of emailTemplates) {
|
||||
const exists = await knex('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('email_templates').insert(template);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove email templates
|
||||
await knex('email_templates')
|
||||
.whereIn('template_key', ['restore_completed', 'restore_failed'])
|
||||
.delete();
|
||||
|
||||
// Remove settings
|
||||
await knex('app_settings')
|
||||
.where('setting_type', 'restore')
|
||||
.delete();
|
||||
|
||||
// Drop tables
|
||||
await knex.schema.dropTableIfExists('restore_validation_results');
|
||||
await knex.schema.dropTableIfExists('restore_file_operations');
|
||||
await knex.schema.dropTableIfExists('restore_runs');
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
// No helpers needed for boolean values
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding gallery feedback tables...');
|
||||
|
||||
// Check if tables and columns already exist
|
||||
const hasEventFeedbackSettingsTable = await knex.schema.hasTable('event_feedback_settings');
|
||||
const hasPhotoFeedbackTable = await knex.schema.hasTable('photo_feedback');
|
||||
const hasFeedbackRateLimitsTable = await knex.schema.hasTable('feedback_rate_limits');
|
||||
const hasFeedbackWordFiltersTable = await knex.schema.hasTable('feedback_word_filters');
|
||||
const hasFeedbackCountColumn = await knex.schema.hasColumn('photos', 'feedback_count');
|
||||
|
||||
// Create event_feedback_settings table
|
||||
if (!hasEventFeedbackSettingsTable) {
|
||||
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.boolean('feedback_enabled').defaultTo(false);
|
||||
table.boolean('allow_ratings').defaultTo(true);
|
||||
table.boolean('allow_likes').defaultTo(true);
|
||||
table.boolean('allow_comments').defaultTo(false);
|
||||
table.boolean('allow_favorites').defaultTo(true);
|
||||
table.boolean('require_name_email').defaultTo(false);
|
||||
table.boolean('moderate_comments').defaultTo(true);
|
||||
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique(['event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create photo_feedback table
|
||||
if (!hasPhotoFeedbackTable) {
|
||||
await knex.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('feedback_type', 20).notNullable();
|
||||
table.integer('rating');
|
||||
table.text('comment_text');
|
||||
table.string('guest_name', 100);
|
||||
table.string('guest_email', 255);
|
||||
table.string('guest_identifier', 64);
|
||||
table.string('ip_address', 45);
|
||||
table.text('user_agent');
|
||||
table.boolean('is_approved').defaultTo(true);
|
||||
table.boolean('is_hidden').defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['photo_id']);
|
||||
table.index(['event_id']);
|
||||
table.index(['feedback_type']);
|
||||
table.index(['guest_identifier']);
|
||||
|
||||
// Add check constraint for rating (PostgreSQL)
|
||||
if (knex.client.config.client === 'pg') {
|
||||
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create feedback_rate_limits table
|
||||
if (!hasFeedbackRateLimitsTable) {
|
||||
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier', 64).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.string('action_type', 20).notNullable();
|
||||
table.integer('action_count').defaultTo(1);
|
||||
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||
|
||||
// Add indexes
|
||||
table.index(['identifier', 'event_id', 'action_type']);
|
||||
table.index(['window_start']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create feedback_word_filters table
|
||||
if (!hasFeedbackWordFiltersTable) {
|
||||
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('word', 100).notNullable();
|
||||
table.string('severity', 20).defaultTo('moderate');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.unique(['word']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add feedback summary columns to photos table
|
||||
if (!hasFeedbackCountColumn) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('feedback_count').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Add feedback notification settings to app_settings
|
||||
const hasFeedbackNotificationEmail = await knex('app_settings')
|
||||
.where('setting_key', 'feedback_notification_email')
|
||||
.first();
|
||||
|
||||
if (!hasFeedbackNotificationEmail) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'feedback_notification_email',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'feedback'
|
||||
},
|
||||
{
|
||||
setting_key: 'feedback_rate_limits',
|
||||
setting_value: JSON.stringify({
|
||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||
like: { max: 200, window: 3600 } // 200 likes per hour
|
||||
}),
|
||||
setting_type: 'feedback'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Gallery feedback tables created successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing gallery feedback tables...');
|
||||
|
||||
// Remove feedback settings from app_settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', ['feedback_notification_email', 'feedback_rate_limits'])
|
||||
.delete();
|
||||
|
||||
// Remove feedback columns from photos table
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('feedback_count');
|
||||
table.dropColumn('like_count');
|
||||
table.dropColumn('average_rating');
|
||||
table.dropColumn('favorite_count');
|
||||
});
|
||||
|
||||
// Drop tables in reverse order
|
||||
await knex.schema.dropTableIfExists('feedback_word_filters');
|
||||
await knex.schema.dropTableIfExists('feedback_rate_limits');
|
||||
await knex.schema.dropTableIfExists('photo_feedback');
|
||||
await knex.schema.dropTableIfExists('event_feedback_settings');
|
||||
|
||||
console.log('Gallery feedback tables removed');
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding version tracking to backup tables...');
|
||||
|
||||
// Add version columns to database_backup_runs table
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (hasDatabaseBackupRunsTable) {
|
||||
const hasAppVersion = await db.schema.hasColumn('database_backup_runs', 'app_version');
|
||||
if (!hasAppVersion) {
|
||||
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||
table.string('app_version'); // Application version
|
||||
table.string('node_version'); // Node.js version
|
||||
table.string('db_schema_version'); // Database schema version (migration name)
|
||||
table.json('environment_info'); // Additional environment information
|
||||
});
|
||||
console.log('Added version columns to database_backup_runs table');
|
||||
}
|
||||
}
|
||||
|
||||
// Add version columns to backup_runs table (file backups)
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
const hasAppVersion = await db.schema.hasColumn('backup_runs', 'app_version');
|
||||
if (!hasAppVersion) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.string('app_version'); // Application version
|
||||
table.string('node_version'); // Node.js version
|
||||
table.string('db_schema_version'); // Database schema version
|
||||
table.json('manifest_info'); // Manifest summary information
|
||||
});
|
||||
console.log('Added version columns to backup_runs table');
|
||||
}
|
||||
}
|
||||
|
||||
// Add restore tracking table
|
||||
const hasRestoreHistoryTable = await db.schema.hasTable('restore_history');
|
||||
if (!hasRestoreHistoryTable) {
|
||||
await db.schema.createTable('restore_history', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.datetime('started_at').notNullable();
|
||||
table.datetime('completed_at');
|
||||
table.string('status').defaultTo('running'); // running, completed, failed, partial
|
||||
table.string('restore_type'); // database, files, full
|
||||
table.string('backup_id'); // Reference to the backup that was restored
|
||||
table.string('backup_app_version'); // Version of app that created the backup
|
||||
table.string('restore_app_version'); // Version of app performing the restore
|
||||
table.string('backup_node_version'); // Node version that created the backup
|
||||
table.string('restore_node_version'); // Node version performing the restore
|
||||
table.string('backup_schema_version'); // Schema version in the backup
|
||||
table.string('restore_schema_version'); // Current schema version
|
||||
table.json('version_compatibility'); // Compatibility check results
|
||||
table.json('restore_options'); // Options used during restore
|
||||
table.json('statistics'); // Restore statistics
|
||||
table.text('warnings'); // Any warnings during restore
|
||||
table.text('error_message'); // Error details if failed
|
||||
table.string('restored_by'); // User who initiated the restore
|
||||
table.index(['started_at'], 'idx_restore_started');
|
||||
table.index(['backup_id'], 'idx_restore_backup_id');
|
||||
});
|
||||
console.log('Created restore_history table');
|
||||
}
|
||||
|
||||
// Add version compatibility settings
|
||||
const versionSettings = [
|
||||
{
|
||||
setting_key: 'backup_require_version_match',
|
||||
setting_value: JSON.stringify(false), // If true, exact version match required for restore
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_allow_minor_version_mismatch',
|
||||
setting_value: JSON.stringify(true), // Allow restoring from same major version
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_warn_on_version_mismatch',
|
||||
setting_value: JSON.stringify(true), // Show warning when versions don't match
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_check_schema_compatibility',
|
||||
setting_value: JSON.stringify(true), // Check if migrations are compatible
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert version settings if they don't exist
|
||||
for (const setting of versionSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Version tracking for backups added successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove version columns from database_backup_runs
|
||||
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||
if (hasDatabaseBackupRunsTable) {
|
||||
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||
table.dropColumn('app_version');
|
||||
table.dropColumn('node_version');
|
||||
table.dropColumn('db_schema_version');
|
||||
table.dropColumn('environment_info');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove version columns from backup_runs
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
table.dropColumn('app_version');
|
||||
table.dropColumn('node_version');
|
||||
table.dropColumn('db_schema_version');
|
||||
table.dropColumn('manifest_info');
|
||||
});
|
||||
}
|
||||
|
||||
// Drop restore_history table
|
||||
await db.schema.dropTableIfExists('restore_history');
|
||||
|
||||
// Remove version settings
|
||||
await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'backup_require_version_match',
|
||||
'backup_allow_minor_version_mismatch',
|
||||
'backup_warn_on_version_mismatch',
|
||||
'backup_check_schema_compatibility'
|
||||
])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,221 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Enhancing backup system...');
|
||||
|
||||
// Add new settings to app_settings table if they don't exist
|
||||
const backupSettings = [
|
||||
{
|
||||
setting_key: 'backup_s3_force_path_style',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_ssl_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_s3_prefix',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_incremental',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_include_database',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_encryption_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup'
|
||||
},
|
||||
{
|
||||
setting_key: 'backup_database_schedule',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'backup'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert settings if they don't exist
|
||||
for (const setting of backupSettings) {
|
||||
const exists = await db('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await db('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Check and add columns to backup_runs table
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
// Check for existing columns before adding
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
|
||||
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
|
||||
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
|
||||
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
|
||||
|
||||
if (!hasManifestPath || !hasManifestId || !hasManifestFormat || !hasParentBackupId || !hasBackupMode) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
if (!hasManifestPath) {
|
||||
table.string('manifest_path', 500).comment('Path to backup manifest file');
|
||||
}
|
||||
if (!hasManifestId) {
|
||||
table.uuid('manifest_id').comment('Unique identifier for the manifest');
|
||||
}
|
||||
if (!hasManifestFormat) {
|
||||
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
|
||||
}
|
||||
if (!hasParentBackupId) {
|
||||
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
|
||||
}
|
||||
if (!hasBackupMode) {
|
||||
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes if they don't exist
|
||||
try {
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)');
|
||||
} catch (error) {
|
||||
console.log('Note: Some indexes may already exist, continuing...');
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup_manifest table if it doesn't exist
|
||||
const hasManifestTable = await db.schema.hasTable('backup_manifest');
|
||||
if (!hasManifestTable) {
|
||||
await db.schema.createTable('backup_manifest', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
|
||||
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
|
||||
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
|
||||
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
|
||||
|
||||
// Backup metadata
|
||||
table.timestamp('backup_start').notNullable();
|
||||
table.timestamp('backup_end').notNullable();
|
||||
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
|
||||
table.integer('file_count').unsigned().comment('Number of files in backup');
|
||||
table.integer('photo_count').unsigned().comment('Number of photos backed up');
|
||||
table.integer('event_count').unsigned().comment('Number of events backed up');
|
||||
|
||||
// Incremental backup metadata
|
||||
table.boolean('is_incremental').defaultTo(false);
|
||||
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
|
||||
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
|
||||
|
||||
// Content checksums
|
||||
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
|
||||
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
|
||||
|
||||
// Storage information
|
||||
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
|
||||
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
|
||||
|
||||
// Encryption metadata
|
||||
table.boolean('is_encrypted').defaultTo(false);
|
||||
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
|
||||
table.string('encryption_key_id', 255).comment('ID of encryption key used');
|
||||
|
||||
// Additional metadata as JSON
|
||||
table.json('metadata').comment('Additional metadata as JSON');
|
||||
|
||||
// Timestamps
|
||||
table.timestamps(true, true);
|
||||
|
||||
// Indexes
|
||||
table.index(['backup_run_id'], 'idx_manifest_backup_run');
|
||||
table.index(['manifest_id'], 'idx_manifest_uuid');
|
||||
table.index(['parent_manifest_id'], 'idx_manifest_parent');
|
||||
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
|
||||
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
|
||||
});
|
||||
}
|
||||
|
||||
// Add composite indexes for common query patterns
|
||||
try {
|
||||
await db.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
||||
ON backup_runs(created_at DESC)
|
||||
WHERE status = 'completed' AND backup_mode = 'full';
|
||||
`);
|
||||
await db.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
||||
ON backup_runs(parent_backup_id, created_at)
|
||||
WHERE backup_mode = 'incremental';
|
||||
`);
|
||||
} catch (error) {
|
||||
console.log('Note: Some composite indexes may already exist, continuing...');
|
||||
}
|
||||
|
||||
console.log('Backup system enhancements completed');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Drop indexes first
|
||||
try {
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;');
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;');
|
||||
} catch (error) {
|
||||
// Ignore errors if indexes don't exist
|
||||
}
|
||||
|
||||
// Drop backup_manifest table
|
||||
await db.schema.dropTableIfExists('backup_manifest');
|
||||
|
||||
// Remove columns from backup_runs if they exist
|
||||
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||
if (hasBackupRunsTable) {
|
||||
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
|
||||
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
|
||||
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
|
||||
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
|
||||
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||
|
||||
if (hasBackupMode || hasParentBackupId || hasManifestFormat || hasManifestId || hasManifestPath) {
|
||||
await db.schema.alterTable('backup_runs', (table) => {
|
||||
if (hasBackupMode) table.dropColumn('backup_mode');
|
||||
if (hasParentBackupId) table.dropColumn('parent_backup_id');
|
||||
if (hasManifestFormat) table.dropColumn('manifest_format');
|
||||
if (hasManifestId) table.dropColumn('manifest_id');
|
||||
if (hasManifestPath) table.dropColumn('manifest_path');
|
||||
});
|
||||
}
|
||||
|
||||
// Drop indexes
|
||||
try {
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
|
||||
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
|
||||
} catch (error) {
|
||||
// Ignore errors if indexes don't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Remove settings
|
||||
await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'backup_s3_force_path_style',
|
||||
'backup_s3_ssl_enabled',
|
||||
'backup_s3_prefix',
|
||||
'backup_incremental',
|
||||
'backup_include_database',
|
||||
'backup_encryption_enabled',
|
||||
'backup_database_schedule'
|
||||
])
|
||||
.delete();
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,87 @@
|
||||
// Fix missing columns identified in GitHub issues
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding missing columns to database tables...');
|
||||
|
||||
// Add must_change_password column to admin_users table
|
||||
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||
if (!hasMustChangePassword) {
|
||||
console.log('Adding must_change_password column to admin_users table...');
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.boolean('must_change_password').defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Add password_changed_at column to admin_users table
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
if (!hasPasswordChangedAt) {
|
||||
console.log('Adding password_changed_at column to admin_users table...');
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.datetime('password_changed_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Add require_moderation column to event_feedback_settings table
|
||||
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||
if (hasEventFeedbackSettings) {
|
||||
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||
if (!hasRequireModeration) {
|
||||
console.log('Adding require_moderation column to event_feedback_settings table...');
|
||||
await knex.schema.table('event_feedback_settings', (table) => {
|
||||
table.boolean('require_moderation').defaultTo(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add host_name column to events table if missing
|
||||
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||
if (!hasHostName) {
|
||||
console.log('Adding host_name column to events table...');
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Missing columns have been added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing added columns...');
|
||||
|
||||
// Remove must_change_password column from admin_users table
|
||||
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||
if (hasMustChangePassword) {
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.dropColumn('must_change_password');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove password_changed_at column from admin_users table
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
if (hasPasswordChangedAt) {
|
||||
await knex.schema.table('admin_users', (table) => {
|
||||
table.dropColumn('password_changed_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove require_moderation column from event_feedback_settings table
|
||||
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||
if (hasEventFeedbackSettings) {
|
||||
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||
if (hasRequireModeration) {
|
||||
await knex.schema.table('event_feedback_settings', (table) => {
|
||||
table.dropColumn('require_moderation');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove host_name column from events table
|
||||
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||
if (hasHostName) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Columns removed');
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Add download control features to events table
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding download control columns to events table...');
|
||||
|
||||
// Add download control columns to events table
|
||||
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||
if (!hasAllowDownloads) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
});
|
||||
}
|
||||
|
||||
// Add download control settings to app_settings
|
||||
const downloadSettingExists = await knex('app_settings')
|
||||
.where('setting_key', 'default_allow_downloads')
|
||||
.first();
|
||||
|
||||
if (!downloadSettingExists) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'default_allow_downloads',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'gallery'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_disable_right_click',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'gallery'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_watermark_downloads',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'gallery'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Download control features added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing download control columns...');
|
||||
|
||||
// Remove app settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_allow_downloads',
|
||||
'default_disable_right_click',
|
||||
'default_watermark_downloads'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Remove columns from events table
|
||||
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||
if (hasAllowDownloads) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('allow_downloads');
|
||||
table.dropColumn('disable_right_click');
|
||||
table.dropColumn('watermark_downloads');
|
||||
table.dropColumn('watermark_text');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Download control columns removed');
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// Add enhanced image protection features
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding enhanced image protection features...');
|
||||
|
||||
// Add protection columns to events table
|
||||
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
|
||||
if (!hasProtectionLevel) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.enum('protection_level', ['basic', 'standard', 'enhanced', 'maximum']).defaultTo('standard');
|
||||
table.integer('image_quality').defaultTo(85);
|
||||
table.boolean('add_fingerprint').defaultTo(true);
|
||||
table.boolean('enable_devtools_protection').defaultTo(true);
|
||||
table.boolean('use_canvas_rendering').defaultTo(false);
|
||||
table.integer('fragmentation_level').defaultTo(3);
|
||||
table.boolean('overlay_protection').defaultTo(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Create image access logs table
|
||||
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
|
||||
if (!hasImageAccessLogs) {
|
||||
await knex.schema.createTable('image_access_logs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('photo_id').unsigned().notNullable();
|
||||
table.integer('event_id').unsigned().notNullable();
|
||||
table.string('client_ip', 45).notNullable();
|
||||
table.text('user_agent');
|
||||
table.string('access_type', 20).defaultTo('view'); // view, download, suspicious
|
||||
table.string('client_fingerprint', 32).notNullable();
|
||||
table.timestamp('accessed_at').defaultTo(knex.fn.now());
|
||||
table.json('metadata'); // Additional security metadata
|
||||
|
||||
table.foreign('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||
table.foreign('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
|
||||
table.index(['photo_id', 'accessed_at']);
|
||||
table.index(['client_fingerprint', 'accessed_at']);
|
||||
table.index(['client_ip', 'accessed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add protection settings to app_settings
|
||||
const protectionSettingExists = await knex('app_settings')
|
||||
.where('setting_key', 'default_protection_level')
|
||||
.first();
|
||||
|
||||
if (!protectionSettingExists) {
|
||||
await knex('app_settings').insert([
|
||||
{
|
||||
setting_key: 'default_protection_level',
|
||||
setting_value: JSON.stringify('standard'),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_image_quality',
|
||||
setting_value: JSON.stringify(85),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'enable_devtools_protection',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_minute',
|
||||
setting_value: JSON.stringify(30),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'suspicious_activity_threshold',
|
||||
setting_value: JSON.stringify(10),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'enable_canvas_rendering',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'default_fragmentation_level',
|
||||
setting_value: JSON.stringify(3),
|
||||
setting_type: 'security'
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Enhanced image protection features added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing enhanced image protection features...');
|
||||
|
||||
// Remove app settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'default_protection_level',
|
||||
'default_image_quality',
|
||||
'enable_devtools_protection',
|
||||
'max_image_requests_per_minute',
|
||||
'suspicious_activity_threshold',
|
||||
'enable_canvas_rendering',
|
||||
'default_fragmentation_level'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Drop image access logs table
|
||||
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
|
||||
if (hasImageAccessLogs) {
|
||||
await knex.schema.dropTable('image_access_logs');
|
||||
}
|
||||
|
||||
// Remove protection columns from events table
|
||||
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
|
||||
if (hasProtectionLevel) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('protection_level');
|
||||
table.dropColumn('image_quality');
|
||||
table.dropColumn('add_fingerprint');
|
||||
table.dropColumn('enable_devtools_protection');
|
||||
table.dropColumn('use_canvas_rendering');
|
||||
table.dropColumn('fragmentation_level');
|
||||
table.dropColumn('overlay_protection');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Enhanced image protection features removed');
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
// Add security logging and monitoring tables
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding security logging and monitoring tables...');
|
||||
|
||||
// Create security logs table for general security events
|
||||
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
|
||||
if (!hasSecurityLogs) {
|
||||
await knex.schema.createTable('security_logs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('event_type', 50).notNullable(); // rate_limit_exceeded, suspicious_activity, etc.
|
||||
table.string('client_ip', 45).notNullable();
|
||||
table.string('client_fingerprint', 32);
|
||||
table.text('user_agent');
|
||||
table.string('request_path');
|
||||
table.string('request_method', 10);
|
||||
table.json('details'); // Additional event details
|
||||
table.timestamp('timestamp').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for performance
|
||||
table.index(['event_type', 'timestamp']);
|
||||
table.index(['client_ip', 'timestamp']);
|
||||
table.index(['client_fingerprint', 'timestamp']);
|
||||
});
|
||||
}
|
||||
|
||||
// Add security monitoring settings to app_settings
|
||||
const securitySettings = [
|
||||
{
|
||||
setting_key: 'security_monitoring_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_5_minutes',
|
||||
setting_value: JSON.stringify(100),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'max_image_requests_per_hour',
|
||||
setting_value: JSON.stringify(500),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'block_suspicious_ips',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'log_security_events_to_db',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'auto_block_threshold',
|
||||
setting_value: JSON.stringify(5),
|
||||
setting_type: 'security'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of securitySettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert(setting);
|
||||
}
|
||||
}
|
||||
|
||||
// Add mime_type column to photos table if it doesn't exist
|
||||
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
|
||||
if (!hasMimeType) {
|
||||
await knex.schema.table('photos', (table) => {
|
||||
table.string('mime_type', 100);
|
||||
});
|
||||
|
||||
// Update existing photos with default mime type
|
||||
await knex('photos')
|
||||
.whereNull('mime_type')
|
||||
.update({ mime_type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
console.log('Security logging and monitoring tables added successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing security logging and monitoring tables...');
|
||||
|
||||
// Remove security settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'security_monitoring_enabled',
|
||||
'max_image_requests_per_5_minutes',
|
||||
'max_image_requests_per_hour',
|
||||
'block_suspicious_ips',
|
||||
'log_security_events_to_db',
|
||||
'auto_block_threshold'
|
||||
])
|
||||
.delete();
|
||||
|
||||
// Drop security logs table
|
||||
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
|
||||
if (hasSecurityLogs) {
|
||||
await knex.schema.dropTable('security_logs');
|
||||
}
|
||||
|
||||
// Remove mime_type column from photos table
|
||||
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
|
||||
if (hasMimeType) {
|
||||
await knex.schema.table('photos', (table) => {
|
||||
table.dropColumn('mime_type');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Security logging and monitoring tables removed');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add thumbnail settings to app_settings table
|
||||
const thumbnailSettings = [
|
||||
{ setting_key: 'thumbnail_width', setting_value: 300, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_height', setting_value: 300, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_fit', setting_value: JSON.stringify('cover'), setting_type: 'string' },
|
||||
{ setting_key: 'thumbnail_quality', setting_value: 85, setting_type: 'number' },
|
||||
{ setting_key: 'thumbnail_format', setting_value: JSON.stringify('jpeg'), setting_type: 'string' }
|
||||
];
|
||||
|
||||
for (const setting of thumbnailSettings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove thumbnail settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Migration 041: Add external media reference support
|
||||
* - events.source_mode: 'managed' | 'reference'
|
||||
* - events.external_path: relative path under external media root
|
||||
* - photos.source_origin: 'managed' | 'external'
|
||||
* - photos.external_relpath: relative path within event.external_path
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 041_add_external_media');
|
||||
|
||||
// events.source_mode (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => {
|
||||
table.string('source_mode').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// events.external_path (nullable)
|
||||
await addColumnIfNotExists(knex, 'events', 'external_path', (table) => {
|
||||
table.text('external_path');
|
||||
});
|
||||
|
||||
// photos.source_origin (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => {
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// photos.external_relpath (nullable)
|
||||
await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => {
|
||||
table.text('external_relpath');
|
||||
});
|
||||
|
||||
// Helpful index for queries
|
||||
try {
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)");
|
||||
} else {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.index(['event_id', 'source_origin'], 'photos_event_source_idx');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Index creation skipped or failed (may already exist):', e.message);
|
||||
}
|
||||
|
||||
console.log('Migration 041_add_external_media completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 041_add_external_media');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
async function ensureColumn(knex, tableName, columnName, alterFn) {
|
||||
const exists = await knex.schema.hasColumn(tableName, columnName);
|
||||
if (!exists) {
|
||||
logger.info(`Adding column ${tableName}.${columnName}`);
|
||||
await knex.schema.table(tableName, alterFn);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await ensureColumn(knex, 'events', 'host_name', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
|
||||
table.integer('upload_category_id');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
|
||||
table.text('watermark_text');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
|
||||
table.string('uploaded_by').defaultTo('admin');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// Non destructive migration; no rollback
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
} = require('../../src/constants/publicSiteDefaults');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const defaults = [
|
||||
{
|
||||
setting_key: 'general_public_site_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_html',
|
||||
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_custom_css',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'general'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of defaults) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
exports.up = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('require_password').notNullable().defaultTo(true);
|
||||
});
|
||||
await knex('events').update({ require_password: true });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('require_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
const settingKey = 'general_max_files_per_upload';
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Normalize existing value into allowed bounds
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
|
||||
} catch {
|
||||
parsedValue = existing.setting_value;
|
||||
}
|
||||
|
||||
const numeric = Number(parsedValue);
|
||||
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
if (Number.isFinite(numeric) && numeric >= 1) {
|
||||
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
|
||||
}
|
||||
|
||||
if (normalized !== numeric) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.update({
|
||||
setting_value: JSON.stringify(normalized),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: settingKey,
|
||||
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
|
||||
table.string('customer_name');
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
|
||||
table.string('customer_email');
|
||||
});
|
||||
|
||||
// Backfill new columns from legacy host_* fields
|
||||
const client = knex?.client?.config?.client;
|
||||
|
||||
if (client === 'pg') {
|
||||
await knex.raw(`
|
||||
UPDATE events
|
||||
SET customer_name = COALESCE(customer_name, host_name),
|
||||
customer_email = COALESCE(customer_email, host_email)
|
||||
`);
|
||||
} else {
|
||||
// SQLite fallback
|
||||
await knex('events').update({
|
||||
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
|
||||
customer_email: knex.raw('COALESCE(customer_email, host_email)')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
|
||||
if (hasCustomerName) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_name');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
|
||||
if (hasCustomerEmail) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
// Add tls_reject_unauthorized column to email_configs table
|
||||
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
|
||||
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
|
||||
table.boolean('tls_reject_unauthorized').defaultTo(true);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('email_configs', (table) => {
|
||||
table.dropColumn('tls_reject_unauthorized');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
console.log('Adding photo categories and CMS tables...');
|
||||
|
||||
// Check if tables already exist
|
||||
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
|
||||
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
|
||||
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
|
||||
const hasLanguageColumn = await db.schema.hasColumn('admin_users', 'language');
|
||||
|
||||
// Create photo_categories table
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
|
||||
// Unique constraint for slug within event scope
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// Create cms_pages table
|
||||
if (!hasCmsPagesTable) {
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Add category_id to photos table
|
||||
if (!hasCategoryIdColumn) {
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
}
|
||||
|
||||
// Add language preference to admin_users
|
||||
if (!hasLanguageColumn) {
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Add language preference to app_settings for global default
|
||||
const hasDefaultLanguageSetting = await db('app_settings')
|
||||
.where('setting_key', 'default_language')
|
||||
.first();
|
||||
|
||||
if (!hasDefaultLanguageSetting) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: JSON.stringify('en'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Insert default global categories
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true }
|
||||
];
|
||||
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
}
|
||||
|
||||
// Insert default legal pages
|
||||
if (!hasCmsPagesTable) {
|
||||
await db('cms_pages').insert([
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date()
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Photo categories and CMS tables created successfully');
|
||||
}
|
||||
|
||||
async function down() {
|
||||
// Remove language from app_settings
|
||||
await db('app_settings').where('setting_key', 'default_language').delete();
|
||||
|
||||
// Drop columns
|
||||
await db.schema.alterTable('admin_users', (table) => {
|
||||
table.dropColumn('language');
|
||||
});
|
||||
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('category_id');
|
||||
});
|
||||
|
||||
// Drop tables
|
||||
await db.schema.dropTableIfExists('cms_pages');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
@@ -0,0 +1,56 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add user upload settings to events table (check if columns exist first)
|
||||
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
|
||||
if (!hasAllowUserUploads) {
|
||||
console.log('Adding allow_user_uploads column to events table...');
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
});
|
||||
} else {
|
||||
console.log('Column allow_user_uploads already exists in events table, skipping...');
|
||||
}
|
||||
|
||||
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
|
||||
if (!hasUploadCategoryId) {
|
||||
console.log('Adding upload_category_id column to events table...');
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
|
||||
});
|
||||
} else {
|
||||
console.log('Column upload_category_id already exists in events table, skipping...');
|
||||
}
|
||||
|
||||
// Add uploaded_by field to photos table to track who uploaded
|
||||
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
|
||||
if (!hasUploadedBy) {
|
||||
console.log('Adding uploaded_by column to photos table...');
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
|
||||
});
|
||||
} else {
|
||||
console.log('Column uploaded_by already exists in photos table, skipping...');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
|
||||
if (hasAllowUserUploads) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('allow_user_uploads');
|
||||
});
|
||||
}
|
||||
|
||||
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
|
||||
if (hasUploadCategoryId) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('upload_category_id');
|
||||
});
|
||||
}
|
||||
|
||||
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
|
||||
if (hasUploadedBy) {
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.dropColumn('uploaded_by');
|
||||
});
|
||||
}
|
||||
};
|
||||
+1
-2
@@ -8,8 +8,7 @@ exports.up = async function(knex) {
|
||||
await knex('app_settings').insert({
|
||||
setting_key: 'general_default_welcome_message',
|
||||
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
setting_type: 'general'
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const { db } = require('../../src/database/db');
|
||||
|
||||
async function up() {
|
||||
// Check if host_name column already exists
|
||||
@@ -0,0 +1,23 @@
|
||||
exports.up = async function(knex) {
|
||||
const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts');
|
||||
|
||||
if (!hasLoginAttemptsTable) {
|
||||
return knex.schema.createTable('login_attempts', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('identifier').notNullable(); // username or email
|
||||
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
||||
table.text('user_agent');
|
||||
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
||||
table.boolean('success').defaultTo(false);
|
||||
|
||||
// Indexes for performance
|
||||
table.index('identifier');
|
||||
table.index('attempt_time');
|
||||
table.index(['identifier', 'success', 'attempt_time']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('login_attempts');
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
exports.up = async function(knex) {
|
||||
// Check if columns already exist to avoid conflicts
|
||||
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||
const hasLastLoginIp = await knex.schema.hasColumn('admin_users', 'last_login_ip');
|
||||
const hasTwoFactorEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasTwoFactorSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
return knex.schema.table('admin_users', table => {
|
||||
// Add password change tracking
|
||||
if (!hasPasswordChangedAt) {
|
||||
table.timestamp('password_changed_at').nullable();
|
||||
}
|
||||
|
||||
// Add last login IP for security monitoring
|
||||
if (!hasLastLoginIp) {
|
||||
table.string('last_login_ip', 45).nullable();
|
||||
}
|
||||
|
||||
// Add account security flags
|
||||
if (!hasTwoFactorEnabled) {
|
||||
table.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasTwoFactorSecret) {
|
||||
table.string('two_factor_secret').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.table('admin_users', table => {
|
||||
table.dropColumn('password_changed_at');
|
||||
table.dropColumn('last_login_ip');
|
||||
table.dropColumn('two_factor_enabled');
|
||||
table.dropColumn('two_factor_secret');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
exports.up = async function(knex) {
|
||||
// Check if tables already exist to avoid conflicts
|
||||
const hasRevokedTokensTable = await knex.schema.hasTable('revoked_tokens');
|
||||
const hasUserTokenRevocationsTable = await knex.schema.hasTable('user_token_revocations');
|
||||
|
||||
// Create revoked_tokens table if it doesn't exist
|
||||
if (!hasRevokedTokensTable) {
|
||||
await knex.schema.createTable('revoked_tokens', table => {
|
||||
table.increments('id').primary();
|
||||
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
||||
table.integer('user_id').nullable(); // User who owned the token
|
||||
table.string('token_type', 20); // admin, gallery, etc.
|
||||
table.timestamp('revoked_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('expires_at').notNullable(); // When token would have expired
|
||||
table.string('reason', 100); // password_change, logout, compromised, etc.
|
||||
table.text('metadata'); // Additional JSON data
|
||||
|
||||
// Indexes for performance
|
||||
table.index('token_id');
|
||||
table.index('user_id');
|
||||
table.index('expires_at'); // For cleanup
|
||||
});
|
||||
}
|
||||
|
||||
// Create user_token_revocations table if it doesn't exist
|
||||
if (!hasUserTokenRevocationsTable) {
|
||||
await knex.schema.createTable('user_token_revocations', table => {
|
||||
table.integer('user_id').primary();
|
||||
table.timestamp('revoked_at').notNullable();
|
||||
table.string('reason', 100);
|
||||
|
||||
// Index for quick lookups
|
||||
table.index('revoked_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Add any missing indexes if tables already existed
|
||||
if (hasRevokedTokensTable) {
|
||||
try {
|
||||
// Try to add indexes if they don't exist (PostgreSQL syntax)
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_token_id_index" ON "revoked_tokens" ("token_id")');
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_user_id_index" ON "revoked_tokens" ("user_id")');
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_expires_at_index" ON "revoked_tokens" ("expires_at")');
|
||||
} catch (error) {
|
||||
// For SQLite compatibility, ignore errors if indexes already exist
|
||||
console.log('Note: Some indexes may already exist, continuing...');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUserTokenRevocationsTable) {
|
||||
try {
|
||||
await knex.raw('CREATE INDEX IF NOT EXISTS "user_token_revocations_revoked_at_index" ON "user_token_revocations" ("revoked_at")');
|
||||
} catch (error) {
|
||||
console.log('Note: Some indexes may already exist, continuing...');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists('user_token_revocations')
|
||||
.dropTableIfExists('revoked_tokens');
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user