Compare commits
305 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7e1d067f4 | |||
| d8d7b500ad | |||
| 250fb93021 | |||
| c0bb2aa5d1 | |||
| 8b0c1655a1 | |||
| ff950244d5 | |||
| cd3d6fc704 | |||
| c536fe45ff | |||
| 42c8ef1c0e | |||
| d07d742528 | |||
| dbc9f0a605 | |||
| 3eb8550cbf | |||
| bebc045029 | |||
| 7325a9e5a2 | |||
| d8c229203e | |||
| 95a6ad505d | |||
| 9dc82c8490 | |||
| 554bf1d43c | |||
| f147bd6cfb | |||
| f564f40ed0 | |||
| b50cc18045 | |||
| 9adb2b04eb | |||
| 5cce58c233 | |||
| c95784eda9 | |||
| f49dbed2d8 | |||
| 1480fa31c7 | |||
| 769dff4364 | |||
| d2e0e75e9a | |||
| 041f3b58f4 | |||
| 0f4db08883 | |||
| e0bd8f0707 | |||
| a8d4500481 | |||
| 12f7901d63 | |||
| 18ddca6c27 | |||
| b31f7e6f34 | |||
| 60fdd5d6ef | |||
| 0ee7eeaa59 | |||
| 811230fb30 | |||
| 761b7791f9 | |||
| 41e89676c1 | |||
| 484ac9a404 | |||
| c1068bbc51 | |||
| b6fb712c9f | |||
| 3cb8408207 | |||
| 20dd43c093 | |||
| 977839156b | |||
| af4f63f561 | |||
| cdff4ebb8a | |||
| 59f958b085 | |||
| 097d7a0b65 | |||
| 1273777541 | |||
| b6a960879f | |||
| 4bd153104b | |||
| e099fcf600 | |||
| 89fdf401c0 | |||
| 9cc46a1819 | |||
| f7c0e5f51a | |||
| 200581e73c | |||
| 505acf833e | |||
| 1ac5b0447a | |||
| 15a2fc2d5f | |||
| a1cf6a1156 | |||
| 481545c37b | |||
| a67df87013 | |||
| d2dbe2ea2f | |||
| 98ea5e7202 | |||
| 6b5c08e99b | |||
| 11ecad136b | |||
| 3a4dccd9f0 | |||
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 | |||
| e229c60b22 | |||
| 4966bc6a58 | |||
| 617f292516 | |||
| 8e95004022 | |||
| 93df328853 | |||
| 657e74a2e3 | |||
| ea77f7917e | |||
| f6e5a454ae | |||
| cc7ad4b2bc | |||
| 827a599102 | |||
| b9841b762c | |||
| e636cf5d56 | |||
| a896fa66c0 | |||
| 027c1090a4 | |||
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 | |||
| b20f9cc108 | |||
| 515814e1d5 | |||
| fba9838e21 | |||
| a9c2761986 | |||
| db7e5913eb | |||
| aa27d1ea79 | |||
| cf8df2780e | |||
| 419a283c62 | |||
| 1b075a4beb | |||
| 68ff93cf17 | |||
| b22a29e877 | |||
| e601311ca3 | |||
| bb00c3993b | |||
| c9c0de46bf | |||
| 5374299cd5 | |||
| 536e2b2874 | |||
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb | |||
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e | |||
| c844f634c8 | |||
| 1d94398e2d | |||
| a2551dc0ad | |||
| 32821934e6 | |||
| b9c28e52cd | |||
| c94b6268cf | |||
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d | |||
| 0a2b010332 | |||
| 6906c8bcf7 | |||
| ac48bfdd0d | |||
| ec99243b6f | |||
| 9932621e14 | |||
| 0fb17c78fa | |||
| 4bcca58a11 | |||
| 9fa5ba1cf7 | |||
| 88919fa0d3 | |||
| 5e43fc9cd9 | |||
| f053f42b6d | |||
| dc17e7d59d | |||
| f05ad87602 | |||
| 2efc74a687 | |||
| 85e7fbe73f | |||
| 0a21856a8d | |||
| 349e7c7eb1 | |||
| be07438915 | |||
| b31ae72153 | |||
| 1db08b1e9b | |||
| 237a3332cc | |||
| e2d0a83d51 | |||
| c546657285 | |||
| e8d5ee1a7b | |||
| 1761ebd531 | |||
| 5e5e98601f | |||
| 1cda80792b | |||
| 8740d5e618 | |||
| a619d52d17 | |||
| dd8cc14d30 | |||
| 3b7d723c2a | |||
| dc6252ff56 | |||
| 1d4e79a4f9 | |||
| 0b0e3e22d2 | |||
| f22e3c133f | |||
| 64c0a58f78 | |||
| 4182089c17 | |||
| cecf773fb7 | |||
| 973af17b85 | |||
| 6c3e88a588 | |||
| 97bbb3c8e1 | |||
| ac1cd96ecd | |||
| 689861f671 | |||
| 41fb575e80 | |||
| 6ebc4f3fc4 | |||
| de973f5613 | |||
| 279c70b3d6 | |||
| 5a73f6963f | |||
| 5101a05bca | |||
| c82caf6539 | |||
| ae1b508726 | |||
| 26c05912fc | |||
| d1033cb83a | |||
| b7458b5a37 | |||
| face8f1496 | |||
| ba6ee55bf7 | |||
| e343106af5 | |||
| 2f848eb602 | |||
| 1c7fa781ad | |||
| 66940c2f5b | |||
| a3638fe954 | |||
| 0934695a69 | |||
| 77ece5c5f1 | |||
| 1c2c1f177a | |||
| f38014099e | |||
| 10649691de | |||
| f439d0b318 | |||
| 4e977f7624 | |||
| 66841e8af7 | |||
| 051e21cbaf | |||
| e35ac6a41c | |||
| 0d33f21ee6 | |||
| 1cfd6a44d6 | |||
| 2b5b875dfe | |||
| f39427d9d9 | |||
| 74d85eadbb | |||
| 0b550cdaf6 | |||
| 288b0c25e6 | |||
| d065132bb7 | |||
| 52ef3e33f4 | |||
| d89a605579 | |||
| 9006b754a8 | |||
| 5328b4f73a | |||
| 6438374258 | |||
| d8fb4c9565 | |||
| f5cf757142 | |||
| 0a203d16cf | |||
| 8231f2b60d | |||
| 9c1e79b5a5 | |||
| 472445a2e5 | |||
| ec3d5a0f80 | |||
| cf32b01356 | |||
| c8cfce3e36 | |||
| 12ba91952e | |||
| 69b56ed582 | |||
| 1bc9b547c7 | |||
| d594d00227 | |||
| 2012b0bab9 | |||
| cfa0b0da69 | |||
| eac573c4a5 | |||
| ff370f6dbd | |||
| 7e3009cedc | |||
| 23ec674e05 | |||
| 971397c338 | |||
| f0768cd31b | |||
| 91601c77a4 | |||
| 53704ec92e | |||
| fece843505 | |||
| 35681d5346 | |||
| 193cadef27 | |||
| 2e7cba9e8a | |||
| 2e10374e2c | |||
| 9dd643338b | |||
| f75ee680a5 | |||
| 8dad933ff1 | |||
| 225d017718 | |||
| f38a8ef598 | |||
| 024c8eac2d | |||
| 932e5e137c | |||
| 3470120a0d | |||
| 28632e8970 | |||
| 6c82958c79 | |||
| 032bbae50d | |||
| d66ff29b3e | |||
| 0ea3ee837a | |||
| 7de326c296 | |||
| fa71cad843 | |||
| ae71834aa6 | |||
| 128452f580 | |||
| f306a2539d | |||
| 160f26f104 | |||
| 01c37098d4 | |||
| f78142cda4 | |||
| 6c84f701ca | |||
| 1802ddaebd | |||
| d1d48fb3da | |||
| 8ba72e8aa1 | |||
| feea04b1ce | |||
| c2e1d30153 | |||
| 993a132d20 | |||
| 1c374a2f82 | |||
| 576e7c5d35 | |||
| 07b93636eb | |||
| a2fca63d3a | |||
| ebf1dabbaa | |||
| 3647855163 | |||
| bdc7e73523 | |||
| f3b83829ca | |||
| 59b1b87cba | |||
| 206539f51e |
@@ -0,0 +1,286 @@
|
||||
# Security Scan Report - Wedding Photo Sharing Application
|
||||
**Date**: July 13, 2025
|
||||
**Scanner**: Claude Security Audit with --security --validate flags
|
||||
**Overall Risk Level**: MEDIUM-HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration.
|
||||
|
||||
### Security Score: 6.5/10
|
||||
|
||||
**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting
|
||||
**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL FINDINGS (Immediate Action Required)
|
||||
|
||||
### 1. Hardcoded JWT Secret in Development
|
||||
- **Location**: Backend `.env` file
|
||||
- **Risk**: Token forgery, authentication bypass
|
||||
- **Impact**: Complete authentication compromise
|
||||
- **Remediation**:
|
||||
```bash
|
||||
# Generate secure secret
|
||||
openssl rand -base64 32
|
||||
# Never commit to repository
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 2. Gallery Tokens in localStorage
|
||||
- **Location**: Frontend `api.ts` and auth contexts
|
||||
- **Risk**: XSS token theft
|
||||
- **Impact**: Gallery access compromise
|
||||
- **Remediation**: Move to httpOnly cookies:
|
||||
```typescript
|
||||
Cookies.set(`gallery_token_${slug}`, token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Weak Content Security Policy
|
||||
- **Location**: Frontend `nginx.conf`
|
||||
- **Risk**: XSS, code injection
|
||||
- **Current**: `unsafe-inline` and `unsafe-eval` allowed
|
||||
- **Remediation**: Implement strict CSP (see detailed recommendations below)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 HIGH SEVERITY FINDINGS
|
||||
|
||||
### 1. Console Logging in Production
|
||||
- **Locations**: 61 instances across frontend
|
||||
- **Risk**: Information disclosure
|
||||
- **Impact**: Leaking sensitive data, debugging info
|
||||
- **Remediation**: Implement environment-aware logging
|
||||
|
||||
### 2. Token Revocation Vulnerability
|
||||
- **Location**: Backend `tokenRevocation.js`
|
||||
- **Risk**: Token manipulation
|
||||
- **Impact**: Bypass revocation checks
|
||||
- **Remediation**: Verify token signature before decoding
|
||||
|
||||
### 3. Source Maps in Production
|
||||
- **Location**: Frontend build configuration
|
||||
- **Risk**: Source code exposure
|
||||
- **Impact**: Reveals application structure
|
||||
- **Remediation**: Disable in production builds
|
||||
|
||||
### 4. Missing Security Headers
|
||||
- **Location**: nginx configuration
|
||||
- **Missing**: HSTS, Permissions-Policy
|
||||
- **Impact**: Various client-side attacks
|
||||
- **Remediation**: Add comprehensive security headers
|
||||
|
||||
---
|
||||
|
||||
## 🟡 MEDIUM SEVERITY FINDINGS
|
||||
|
||||
### 1. Rate Limiting Bypass Potential
|
||||
- **Location**: Backend rate limiter
|
||||
- **Risk**: DoS attacks
|
||||
- **Current**: JWT validation in rate limiter
|
||||
- **Remediation**: Use IP-based limiting only
|
||||
|
||||
### 2. Incomplete SQL Injection Protection
|
||||
- **Location**: Complex dashboard queries
|
||||
- **Risk**: Potential injection in edge cases
|
||||
- **Current**: Mostly parameterized
|
||||
- **Remediation**: Use query builder exclusively
|
||||
|
||||
### 3. Session Management
|
||||
- **Issue**: No gallery token invalidation on password change
|
||||
- **Risk**: Persistent access after compromise
|
||||
- **Remediation**: Implement token revocation
|
||||
|
||||
### 4. Path Traversal in Gallery Slugs
|
||||
- **Location**: Frontend gallery routes
|
||||
- **Risk**: Directory traversal attempts
|
||||
- **Remediation**: Validate and sanitize slugs
|
||||
|
||||
---
|
||||
|
||||
## 🟢 LOW SEVERITY FINDINGS
|
||||
|
||||
### 1. Verbose Error Messages
|
||||
- **Location**: Multiple API endpoints
|
||||
- **Risk**: Information disclosure
|
||||
- **Remediation**: Generic client errors, detailed server logs
|
||||
|
||||
### 2. Weak Gallery Passwords
|
||||
- **Current**: zxcvbn score 2/4 allowed
|
||||
- **Risk**: Brute force attacks
|
||||
- **Remediation**: Increase to score 3/4
|
||||
|
||||
### 3. Missing File Size Validation
|
||||
- **Location**: Frontend upload components
|
||||
- **Risk**: DoS via large uploads
|
||||
- **Remediation**: Add client-side size checks
|
||||
|
||||
---
|
||||
|
||||
## ✅ SECURITY STRENGTHS
|
||||
|
||||
### Authentication & Authorization
|
||||
- JWT with proper expiration (24h/7d)
|
||||
- Token type validation
|
||||
- IP tracking and validation
|
||||
- Password change detection
|
||||
- Token revocation system
|
||||
- Bcrypt with 12 rounds
|
||||
- zxcvbn password strength checking
|
||||
|
||||
### Input Validation & SQL Security
|
||||
- express-validator on all endpoints
|
||||
- Parameterized queries via Knex
|
||||
- SQL injection protection utilities
|
||||
- Path traversal prevention
|
||||
- Comprehensive input sanitization
|
||||
|
||||
### File Security
|
||||
- Magic number verification
|
||||
- MIME type validation
|
||||
- Safe filename generation
|
||||
- Directory traversal protection
|
||||
- File extension whitelist
|
||||
|
||||
### Rate Limiting & DoS Protection
|
||||
- General: 100 req/15min
|
||||
- Auth endpoints: 5 req/15min
|
||||
- Account lockout after failed attempts
|
||||
- Suspicious activity detection
|
||||
|
||||
### Frontend Security
|
||||
- React's built-in XSS protection
|
||||
- DOMPurify for HTML content
|
||||
- No eval() or innerHTML usage
|
||||
- Proper error boundaries
|
||||
- ReCAPTCHA integration
|
||||
|
||||
---
|
||||
|
||||
## 📊 DEPENDENCY ANALYSIS
|
||||
|
||||
### Current Status
|
||||
- **Backend**: 0 vulnerabilities (691 packages)
|
||||
- **Frontend**: 0 vulnerabilities (434 packages)
|
||||
|
||||
### Recommended Updates
|
||||
1. **bcrypt** 5.1.1 → 6.0.0 (performance, compatibility)
|
||||
2. **helmet** 7.2.0 → 8.1.0 (new security features)
|
||||
3. **@tiptap** 2.x → 3.x (security improvements)
|
||||
|
||||
### Supply Chain Assessment
|
||||
- All major dependencies from trusted sources
|
||||
- No typosquatting detected
|
||||
- Regular maintenance observed
|
||||
- MIT/ISC/Apache licenses only
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ REMEDIATION PLAN
|
||||
|
||||
### Phase 1: Critical (Within 24 hours)
|
||||
1. Replace hardcoded JWT secret with secure random value
|
||||
2. Move gallery tokens from localStorage to httpOnly cookies
|
||||
3. Implement strict CSP without unsafe-eval
|
||||
4. Remove or wrap console.log statements
|
||||
|
||||
### Phase 2: High Priority (Within 1 week)
|
||||
1. Disable source maps in production
|
||||
2. Add missing security headers (HSTS, Permissions-Policy)
|
||||
3. Fix token revocation vulnerability
|
||||
4. Update critical dependencies (bcrypt, helmet)
|
||||
|
||||
### Phase 3: Medium Priority (Within 1 month)
|
||||
1. Implement comprehensive logging strategy
|
||||
2. Add gallery slug validation
|
||||
3. Enhance rate limiting logic
|
||||
4. Implement session invalidation on password change
|
||||
|
||||
### Phase 4: Ongoing
|
||||
1. Weekly dependency scanning
|
||||
2. Implement security testing in CI/CD
|
||||
3. Regular penetration testing
|
||||
4. Security awareness training
|
||||
|
||||
---
|
||||
|
||||
## 🔒 RECOMMENDED CSP CONFIGURATION
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://analytics.domain.com;
|
||||
frame-src https://www.google.com/recaptcha/;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
upgrade-insecure-requests;
|
||||
" always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 SECURITY IMPROVEMENTS ROADMAP
|
||||
|
||||
### Immediate Implementation
|
||||
```bash
|
||||
# 1. Generate secure secrets
|
||||
openssl rand -base64 32 > jwt-secret.txt
|
||||
|
||||
# 2. Update dependencies
|
||||
cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0
|
||||
cd ../frontend && npm update
|
||||
|
||||
# 3. Add security scanning
|
||||
npm install -D npm-audit-resolver
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
```yaml
|
||||
# Add to CI pipeline
|
||||
- name: Security Scan
|
||||
run: |
|
||||
npm audit --audit-level=moderate
|
||||
npm run test:security
|
||||
```
|
||||
|
||||
### Monitoring & Alerting
|
||||
1. Implement fail2ban for repeated auth failures
|
||||
2. Set up log analysis for suspicious patterns
|
||||
3. Configure alerts for security events
|
||||
4. Regular vulnerability scanning
|
||||
|
||||
---
|
||||
|
||||
## 📋 COMPLIANCE CHECKLIST
|
||||
|
||||
- [ ] OWASP Top 10 addressed
|
||||
- [ ] GDPR compliance (data minimization, right to erasure)
|
||||
- [ ] Security headers implemented
|
||||
- [ ] Dependency scanning automated
|
||||
- [ ] Incident response plan documented
|
||||
- [ ] Security documentation maintained
|
||||
- [ ] Regular security reviews scheduled
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONCLUSION
|
||||
|
||||
The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment.
|
||||
|
||||
Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing.
|
||||
|
||||
---
|
||||
|
||||
*Generated by Claude Security Scanner v1.0*
|
||||
*Next scan recommended: After Phase 1 remediation completion*
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
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
|
||||
|
||||
# -------- NEW: Publish Docker images to GitHub Container Registry --------
|
||||
- name: push-backend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
|
||||
- name: push-frontend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
# -------- NEW: Create GitHub Release --------
|
||||
- name: github-release
|
||||
image: plugins/github-release
|
||||
when:
|
||||
event: tag
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITHUB_TOKEN
|
||||
files: []
|
||||
title: PicPeak ${DRONE_TAG}
|
||||
note: |
|
||||
# PicPeak ${DRONE_TAG}
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
This release includes Docker images published to GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-backend:latest
|
||||
|
||||
# Frontend
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}
|
||||
docker pull ghcr.io/the-luap/picpeak-frontend:latest
|
||||
```
|
||||
|
||||
## 📦 What's New
|
||||
|
||||
See the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Use the tagged version
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
+37
-190
@@ -1,200 +1,47 @@
|
||||
# PicPeak Environment Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
# PicPeak Development Environment Configuration
|
||||
# Copy this file to .env for local development
|
||||
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
# SECURITY WARNING: This configuration is for development only!
|
||||
# For production, use .env.production.example
|
||||
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# JWT Secret (Change in production!)
|
||||
# Generate secure secret with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
# Application URLs (Docker Compose development setup)
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
BACKEND_URL=http://localhost:3001
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
# Database Configuration (SQLite for development)
|
||||
DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=./data/photo_sharing.db
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.com
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
# Email Configuration (Mailhog for development)
|
||||
# Access Mailhog UI at: http://localhost:8025
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
|
||||
# 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
|
||||
# Backend Port Configuration
|
||||
PORT=3001
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
API_URL=https://yourdomain.com/api
|
||||
# Optional: Umami Analytics Backend Config
|
||||
# NOTE: Primary configuration through Admin UI > Settings > Analytics
|
||||
# These are fallback values for server-side tracking
|
||||
# UMAMI_URL=https://analytics.example.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
# UMAMI_HASH_SALT=your-hash-salt
|
||||
|
||||
# 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
|
||||
# Development Features
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
# FRONTEND_PORT=3000
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
# 'beta' uses the :beta tag for pre-release versions
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# Update Check Configuration
|
||||
# Set to 'false' to disable update notifications in admin UI
|
||||
UPDATE_CHECK_ENABLED=true
|
||||
|
||||
# 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
|
||||
|
||||
# ─── Storage Backend ────────────────────────────────────────────────────────
|
||||
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
|
||||
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
|
||||
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
|
||||
#
|
||||
# STORAGE_BACKEND=local (default)
|
||||
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
|
||||
# existing deployment keeps working unchanged.
|
||||
#
|
||||
# STORAGE_BACKEND=s3
|
||||
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
|
||||
# disabled in this mode (S3 has no inotify) — every photo must enter via the
|
||||
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
|
||||
# existing local content to S3 before flipping the env.
|
||||
#
|
||||
# STORAGE_BACKEND=local
|
||||
#
|
||||
# STORAGE_S3_BUCKET=picpeak
|
||||
# STORAGE_S3_REGION=us-east-1
|
||||
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
|
||||
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
|
||||
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
||||
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
|
||||
# STORAGE_S3_PREFIX=picpeak
|
||||
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
|
||||
# STORAGE_S3_SSL=true
|
||||
#
|
||||
# Minimum IAM policy (AWS S3) for the bucket above:
|
||||
# {
|
||||
# "Version": "2012-10-17",
|
||||
# "Statement": [{
|
||||
# "Effect": "Allow",
|
||||
# "Action": [
|
||||
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
|
||||
# "s3:ListBucket", "s3:GetBucketLocation"
|
||||
# ],
|
||||
# "Resource": [
|
||||
# "arn:aws:s3:::picpeak",
|
||||
# "arn:aws:s3:::picpeak/*"
|
||||
# ]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
|
||||
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
|
||||
|
||||
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
|
||||
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
|
||||
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
|
||||
# per-webhook secret in the X-PicPeak-Signature header.
|
||||
#
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
|
||||
# Block URLs resolving to private IPs / loopback / .local etc. as an
|
||||
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
|
||||
# the same docker network or localhost. Production deployments must
|
||||
# leave this OFF.
|
||||
# WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
#
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
|
||||
# How often the worker polls webhook_deliveries for pending rows.
|
||||
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
|
||||
#
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
|
||||
# Maximum in-flight deliveries per worker tick. One slow consumer can
|
||||
# monopolize all 5 slots — bump this if your receivers are slow OR ship
|
||||
# a separate webhook-only deployment.
|
||||
# WEBHOOK_DELIVERY_CONCURRENCY=5
|
||||
#
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
|
||||
# Per-request timeout. Beyond this, the delivery is recorded as a
|
||||
# network error and retried.
|
||||
# WEBHOOK_HTTP_TIMEOUT_MS=10000
|
||||
#
|
||||
# WEBHOOK_MAX_ATTEMPTS (default: 5)
|
||||
# Total attempts before a delivery is marked failed. Backoff between
|
||||
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
|
||||
# WEBHOOK_MAX_ATTEMPTS=5
|
||||
|
||||
# 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.
|
||||
# Admin Setup Notes:
|
||||
# 1. Run 'npm run migrate' in backend folder
|
||||
# 2. Admin credentials will be auto-generated
|
||||
# 3. Check ADMIN_CREDENTIALS.txt for login details
|
||||
# 4. Change password on first login (required)
|
||||
@@ -0,0 +1,100 @@
|
||||
# PicPeak Production Configuration
|
||||
# Copy this file to .env and update with your production values
|
||||
|
||||
# ============================================
|
||||
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
|
||||
# ============================================
|
||||
|
||||
# JWT Secret - REQUIRED (minimum 32 characters)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
|
||||
|
||||
# Application URLs - REQUIRED (your actual domain)
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
ADMIN_URL=https://your-domain.com
|
||||
|
||||
# ============================================
|
||||
# DATABASE CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# PostgreSQL Configuration (Recommended for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres # or your database host
|
||||
DB_PORT=5432
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
|
||||
|
||||
# ============================================
|
||||
# EMAIL CONFIGURATION - REQUIRED
|
||||
# ============================================
|
||||
|
||||
# Example: Gmail with App Password
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-16-char-app-password
|
||||
# EMAIL_FROM=Your Name <your-email@gmail.com>
|
||||
|
||||
# Example: SendGrid
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=YOUR-SENDGRID-API-KEY
|
||||
EMAIL_FROM=PicPeak <noreply@your-domain.com>
|
||||
|
||||
# ============================================
|
||||
# ADMIN SETUP - AUTO-GENERATED
|
||||
# ============================================
|
||||
# NOTE: Admin credentials are automatically generated during setup
|
||||
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
|
||||
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
# Umami Analytics (Optional - Fallback values)
|
||||
# Primary config via Admin UI > Settings > Analytics
|
||||
# UMAMI_URL=https://analytics.your-domain.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
# UMAMI_HASH_SALT=your-hash-salt
|
||||
|
||||
# Frontend Analytics (Optional - Fallback values)
|
||||
# VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
|
||||
|
||||
# ============================================
|
||||
# PERFORMANCE & SECURITY TUNING
|
||||
# ============================================
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Security Settings (Defaults are secure)
|
||||
BCRYPT_ROUNDS=12
|
||||
SESSION_TIMEOUT_MINUTES=60
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS=100 # per window
|
||||
|
||||
# Connection Pool (Adjust based on load)
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=25
|
||||
|
||||
# ============================================
|
||||
# DOCKER COMPOSE SPECIFIC
|
||||
# ============================================
|
||||
|
||||
# Traefik Configuration (if using Traefik)
|
||||
DOMAIN=your-domain.com
|
||||
LETSENCRYPT_EMAIL=admin@your-domain.com
|
||||
|
||||
# Volume Paths (Docker)
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
ARCHIVE_PATH=/app/storage/events/archived
|
||||
@@ -0,0 +1,12 @@
|
||||
# Files to exclude from GitHub mirror
|
||||
.env* export-ignore
|
||||
docker-compose.prod.yml export-ignore
|
||||
.claudedocs/ export-ignore
|
||||
backend/data/ export-ignore
|
||||
backend/storage/ export-ignore
|
||||
backend/.env* export-ignore
|
||||
frontend/.env* export-ignore
|
||||
secrets/ export-ignore
|
||||
*.key export-ignore
|
||||
*.pem export-ignore
|
||||
.gitea/ export-ignore
|
||||
@@ -0,0 +1,220 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository with full history
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for finding the commit
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Debug - Show current branch and status
|
||||
run: |
|
||||
echo "Current branch:"
|
||||
git branch -a
|
||||
echo "Git status:"
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
echo "Checking target commit exists:"
|
||||
git show --oneline 7aca927937 || echo "Target commit not found!"
|
||||
|
||||
- name: Create completely new history from specific commit
|
||||
run: |
|
||||
TARGET_COMMIT="7aca927937"
|
||||
|
||||
# Verify the target commit exists
|
||||
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
|
||||
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a completely new orphan branch (no history)
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Clear the staging area completely
|
||||
git rm -rf . || true
|
||||
|
||||
# Get the file tree from the target commit and create initial commit
|
||||
echo "Creating new history starting from $TARGET_COMMIT..."
|
||||
git read-tree $TARGET_COMMIT
|
||||
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
echo "✅ Created new initial commit: $(git log --oneline -1)"
|
||||
|
||||
# Now get all commits after the target commit and apply their changes
|
||||
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
|
||||
|
||||
if [ -n "$COMMITS_AFTER_TARGET" ]; then
|
||||
echo "📋 Applying changes from commits after $TARGET_COMMIT (excluding Claude commits):"
|
||||
|
||||
for commit in $COMMITS_AFTER_TARGET; do
|
||||
# Get the commit author name
|
||||
COMMIT_AUTHOR_NAME=$(git log --format="%an" -n 1 $commit)
|
||||
|
||||
# Skip commits by Claude
|
||||
if [ "$COMMIT_AUTHOR_NAME" = "Claude" ]; then
|
||||
echo "⚠️ Skipping commit by Claude: $(git log --oneline -1 $commit)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing: $(git log --oneline -1 $commit)"
|
||||
|
||||
# Get the commit message and author info
|
||||
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
|
||||
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
|
||||
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
|
||||
|
||||
# Apply the changes from this commit
|
||||
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
|
||||
# Apply file changes
|
||||
git checkout $commit -- . || true
|
||||
|
||||
# Stage all changes
|
||||
git add -A
|
||||
|
||||
# Only commit if there are changes
|
||||
if ! git diff --cached --quiet; then
|
||||
# Create new commit with original metadata but new SHA
|
||||
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||
GIT_AUTHOR_DATE="$COMMIT_DATE"
|
||||
|
||||
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
|
||||
else
|
||||
echo "⚠️ No changes to commit for $commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Skipping problematic commit $commit"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Finished creating new history"
|
||||
else
|
||||
echo "✅ No commits after target commit - history starts fresh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== New History Summary ==="
|
||||
echo "Total commits in new history: $(git rev-list --count github-mirror)"
|
||||
echo "History starts with: $(git log --oneline --reverse | head -1)"
|
||||
echo "Latest commit: $(git log --oneline -1)"
|
||||
|
||||
- name: Remove sensitive files and directories
|
||||
run: |
|
||||
# Switch to the github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .env || true
|
||||
rm -rf backend/.env* || true
|
||||
rm -rf frontend/.env* || true
|
||||
rm -rf docker-compose.prod.yml || true
|
||||
rm -rf .claudedocs/ || true
|
||||
rm -rf backend/data/ || true
|
||||
rm -rf backend/storage/ || true
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/install-gitea-runner.sh || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf .github-mirror-exclude || true
|
||||
rm -rf .gitattributes-github || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
rm -rf logs/ || true
|
||||
rm -rf frontend/.claudedocs/ || true
|
||||
rm -rf test-maintenance.sh || true
|
||||
rm -rf storage/ || true
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
|
||||
# Add and commit the cleanup if there are changes
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||
echo "✅ Committed cleanup of sensitive files"
|
||||
else
|
||||
echo "✅ No sensitive files to remove"
|
||||
fi
|
||||
|
||||
echo "Final file structure (top level):"
|
||||
ls -la | head -10 || true
|
||||
|
||||
- name: Verify completely new history
|
||||
run: |
|
||||
git checkout github-mirror
|
||||
echo "=== Final History Verification ==="
|
||||
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
|
||||
echo ""
|
||||
echo "Complete commit history (should start from target commit content):"
|
||||
git log --oneline --reverse
|
||||
echo ""
|
||||
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
|
||||
echo "🔍 Original target commit content preserved but with new commit ID"
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Force push completely new history to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Switch to github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the completely new history to GitHub main
|
||||
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
||||
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
||||
git push github github-mirror:main --force
|
||||
echo "✅ Force push completed - GitHub now has completely new history!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
||||
echo "📊 History starts from commit content: 7aca927937"
|
||||
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
|
||||
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
|
||||
echo "🆕 All commit SHAs are NEW - no connection to previous history"
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
@@ -0,0 +1,267 @@
|
||||
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 }}
|
||||
component_changed: ${{ steps.version.outputs.component_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: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
set -e # Exit on error
|
||||
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# 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
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
|
||||
# First, ensure we have the latest changes
|
||||
echo "Fetching latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Check if we're behind and need to update
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
echo "Local is behind remote, pulling changes..."
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
# Pull latest changes before pushing to avoid conflicts
|
||||
echo "Pulling latest changes from origin/main..."
|
||||
if ! git pull --rebase origin main; then
|
||||
echo "Rebase failed, attempting to resolve..."
|
||||
# If rebase fails, abort and try a regular merge
|
||||
git rebase --abort || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
# Push the changes with retry logic
|
||||
echo "Pushing version bump..."
|
||||
PUSH_SUCCESS=false
|
||||
|
||||
for i in 1 2 3; do
|
||||
echo "Push attempt $i of 3..."
|
||||
|
||||
# Try to push
|
||||
if git push origin main 2>&1; then
|
||||
echo "Successfully pushed version bump on attempt $i"
|
||||
PUSH_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "Push failed on attempt $i"
|
||||
|
||||
if [ $i -lt 3 ]; then
|
||||
echo "Waiting 5 seconds before retry..."
|
||||
sleep 5
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Try rebase first, fall back to merge
|
||||
if ! git rebase origin/main; then
|
||||
echo "Rebase failed, trying merge..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$PUSH_SUCCESS" = "false" ]; then
|
||||
echo "ERROR: Failed to push after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
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 "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -0,0 +1,24 @@
|
||||
# Exclude patterns for GitHub mirror
|
||||
.env
|
||||
.env.*
|
||||
.env*
|
||||
docker-compose.prod.yml
|
||||
docker-compose.traefik.yml
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/storage/
|
||||
backend/.env*
|
||||
frontend/.env*
|
||||
secrets/
|
||||
*.key
|
||||
*.pem
|
||||
.gitea/
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.DS_Store
|
||||
deploy/
|
||||
certbot/
|
||||
nginx/
|
||||
photo-sharing-prd.md
|
||||
@@ -9,7 +9,7 @@ assignees: ''
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
Instead, please email security@example.com with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# 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/)
|
||||
@@ -1,503 +0,0 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
# This workflow is triggered by:
|
||||
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
|
||||
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
|
||||
# - GitHub Releases (created by Release Please)
|
||||
# - Pull requests (build verification only, no push by default)
|
||||
# - Manual workflow dispatch
|
||||
#
|
||||
# Multi-arch strategy:
|
||||
# Each image (backend, frontend) is built once per architecture on a
|
||||
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
|
||||
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
|
||||
# merge job combines the digests into a multi-arch manifest and applies
|
||||
# the human-readable tags. This is the pattern documented at
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
#
|
||||
# Native runners are used instead of QEMU because npm install under
|
||||
# QEMU was previously too slow/unreliable for regular branch builds.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, beta ]
|
||||
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
|
||||
pull_request:
|
||||
branches: [ main, beta ]
|
||||
release:
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
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 and FRONTEND_IMAGE_NAME are computed per job in the
|
||||
# "Compute image names" step. GHCR requires all-lowercase repository names,
|
||||
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
|
||||
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
|
||||
# working on forks regardless of the owner's name casing.
|
||||
|
||||
jobs:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Backend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
build-backend:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Backend (labels only)
|
||||
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 }}
|
||||
|
||||
- name: Build Backend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-backend:
|
||||
needs: build-backend
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- 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}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ 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@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
build-frontend:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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: Determine if pushing
|
||||
id: push-decision
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract metadata for Frontend (labels only)
|
||||
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 }}
|
||||
|
||||
- name: Build Frontend image (push by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
|
||||
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: steps.push-decision.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-frontend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-frontend:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-frontend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- 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}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ 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@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
summary:
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Compute image names (lowercase for GHCR)
|
||||
run: |
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
|
||||
echo "ℹ️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $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 "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $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, when push is enabled)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [beta]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: beta
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+1
-70
@@ -11,9 +11,6 @@ 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
|
||||
@@ -62,70 +59,4 @@ test-archiver/
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
# development files
|
||||
backend/.swarm/
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
# Anchored to repo root: matches the top-level runtime storage dir,
|
||||
# NOT backend/src/services/storage/ (the storage backend abstraction code).
|
||||
/storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
# Ignore local contributor guide copy
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Working/planning documents (not for release)
|
||||
BUGS_AND_FEATURES.md
|
||||
frontend/TEST_PLAN.md
|
||||
docs/REFACTORING_PLAN.md
|
||||
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
|
||||
docs/*_PLAN.md
|
||||
docs/test-*.md
|
||||
docs/feature-*.md
|
||||
|
||||
# Scaffolding documentation (local development reference)
|
||||
docs/DATABASE_SCHEMA.md
|
||||
docs/BACKEND_SERVICES.md
|
||||
docs/API_ROUTES.md
|
||||
docs/FRONTEND_ARCHITECTURE.md
|
||||
docs/DEVELOPER_ONBOARDING.md
|
||||
docs/ENVIRONMENT_VARIABLES.md
|
||||
|
||||
# Build artifact: OpenAPI spec generated locally + synced into the
|
||||
# picpeak-docs repo. Never tracked here — the docs site at
|
||||
# docs.picpeak.app is the source of truth.
|
||||
docs/openapi.json
|
||||
docs/openapi.yaml
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
|
||||
tests/e2e/local/
|
||||
playwright-local-results/
|
||||
e2e-test.log
|
||||
scripts/e2e-local.sh
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
backend/*.db
|
||||
|
||||
# Test files and artifacts
|
||||
test-images/
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
PRODUCTION_DEPLOYMENT_GUIDE.md
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
".": "3.42.1-beta.0"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{".":"3.43.1"}
|
||||
@@ -0,0 +1,152 @@
|
||||
# Backup Version Tracking Implementation
|
||||
|
||||
## Overview
|
||||
Version tracking has been added to the backup system to ensure safe restoration by tracking application versions, Node.js versions, and database schema versions at the time of backup.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Changes (Migration 034)
|
||||
|
||||
Added version tracking columns to backup tables:
|
||||
|
||||
#### `database_backup_runs` table:
|
||||
- `app_version` - Application version from package.json
|
||||
- `node_version` - Node.js runtime version
|
||||
- `db_schema_version` - Latest migration name
|
||||
- `environment_info` - JSON with additional environment details
|
||||
|
||||
#### `backup_runs` table:
|
||||
- `app_version` - Application version
|
||||
- `node_version` - Node.js version
|
||||
- `db_schema_version` - Database schema version
|
||||
- `manifest_info` - Summary of manifest information
|
||||
|
||||
#### New `restore_history` table:
|
||||
Tracks all restore attempts with comprehensive version information:
|
||||
- Backup versions vs current versions
|
||||
- Compatibility check results
|
||||
- Warnings and errors
|
||||
- Restore outcome
|
||||
|
||||
### 2. Version Information Captured
|
||||
|
||||
During each backup, the system now records:
|
||||
- **Application Version**: From `package.json` (e.g., "1.0.77")
|
||||
- **Node.js Version**: Runtime version (e.g., "v18.17.0")
|
||||
- **Database Schema**: Latest migration file (e.g., "034_add_version_to_backups.js")
|
||||
- **Environment Info**: Platform, architecture, environment mode
|
||||
|
||||
### 3. Backup Services Updated
|
||||
|
||||
#### Database Backup Service (`databaseBackup.js`):
|
||||
- Records version info when creating backups
|
||||
- Includes versions in statistics JSON
|
||||
- New method: `checkVersionCompatibility()` for restore safety
|
||||
- New method: `getCurrentSchemaVersion()` to track migrations
|
||||
|
||||
#### File Backup Service (`backupService.js`):
|
||||
- Records version info in backup_runs table
|
||||
- Integrates with manifest system
|
||||
- Stores manifest summary with version details
|
||||
|
||||
### 4. Existing Manifest System
|
||||
|
||||
The `backupManifest.js` already provides comprehensive version tracking:
|
||||
- Application version and Node.js version
|
||||
- System information (OS, platform, architecture)
|
||||
- Database schema version
|
||||
- Detailed file and database metadata
|
||||
|
||||
### 5. Version Compatibility Checking
|
||||
|
||||
When restoring, the system can now:
|
||||
- Compare backup version vs current version
|
||||
- Detect major/minor version differences
|
||||
- Identify schema mismatches
|
||||
- Provide warnings and recommendations
|
||||
|
||||
### 6. Configuration Settings
|
||||
|
||||
New backup settings for version control:
|
||||
- `backup_require_version_match` - Enforce exact version matching
|
||||
- `backup_allow_minor_version_mismatch` - Allow same major version
|
||||
- `backup_warn_on_version_mismatch` - Show warnings on mismatch
|
||||
- `backup_check_schema_compatibility` - Validate schema versions
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Backups
|
||||
Backups automatically capture version information - no changes needed to existing backup workflows.
|
||||
|
||||
### Checking Version Before Restore
|
||||
|
||||
1. **For Database Backups**:
|
||||
```javascript
|
||||
const compatibility = await databaseBackupService.checkVersionCompatibility({
|
||||
app_version: '1.0.75',
|
||||
node_version: 'v16.14.0',
|
||||
db_schema_version: '032_add_feedback.js'
|
||||
});
|
||||
|
||||
if (!compatibility.compatible) {
|
||||
console.error('Version mismatch:', compatibility.errors);
|
||||
}
|
||||
```
|
||||
|
||||
2. **For File Backups**:
|
||||
Check the manifest file which contains all version information:
|
||||
```bash
|
||||
cat /backup/path/manifest-backup-20250122-123456.json | jq '.application'
|
||||
```
|
||||
|
||||
### Restore History
|
||||
All restore attempts are logged in the `restore_history` table with:
|
||||
- Version compatibility results
|
||||
- Warnings encountered
|
||||
- Success/failure status
|
||||
- Who performed the restore
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Check Compatibility**: Before restoring, verify version compatibility
|
||||
2. **Document Version Changes**: Keep changelog updated with breaking changes
|
||||
3. **Test Restores**: Regularly test restore procedures in staging
|
||||
4. **Monitor Warnings**: Even if compatible, review warnings before proceeding
|
||||
5. **Keep Backups Organized**: Label backups with version info in filename
|
||||
|
||||
## Migration Instructions
|
||||
|
||||
1. Run the new migration:
|
||||
```bash
|
||||
cd backend
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
2. Existing backups will show "unknown" for version fields
|
||||
3. New backups will automatically include version information
|
||||
4. The system remains backward compatible with old backups
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version Mismatch Errors
|
||||
- Check current app version: `cat backend/package.json | grep version`
|
||||
- Check Node version: `node --version`
|
||||
- Check latest migration: `SELECT name FROM knex_migrations ORDER BY id DESC LIMIT 1`
|
||||
|
||||
### Restore Failures
|
||||
- Review `restore_history` table for detailed error messages
|
||||
- Check version compatibility warnings
|
||||
- Consider using same version environment for critical restores
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Automated Version Matching**: Docker containers with specific versions
|
||||
2. **Migration Rollback**: Support for downgrading schema safely
|
||||
3. **Version Matrix**: Compatibility matrix for different version combinations
|
||||
4. **Restore Wizard**: UI for guided restore with compatibility checks
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: January 2025
|
||||
**Current Version**: 1.0.77
|
||||
**Status**: Production Ready
|
||||
-1997
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## 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
|
||||
- **backupService**: Scheduled backups with checksum-based change detection
|
||||
|
||||
### 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
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## 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;
|
||||
```
|
||||
|
||||
## Backup Service
|
||||
|
||||
### Overview
|
||||
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
|
||||
|
||||
### Features
|
||||
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
|
||||
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
|
||||
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
|
||||
- **Email Notifications**: Alerts on backup failure, optional success notifications
|
||||
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
|
||||
- **Progress Tracking**: Database storage of backup history, file states, and statistics
|
||||
|
||||
### Configuration
|
||||
Backup settings are stored in `app_settings` table with `backup_` prefix:
|
||||
- `backup_enabled`: Enable/disable the service
|
||||
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
|
||||
- `backup_destination_type`: 'local', 'rsync', or 's3'
|
||||
- `backup_retention_days`: How long to keep backup history
|
||||
- `backup_include_archived`: Whether to backup archived events
|
||||
- `backup_exclude_patterns`: File patterns to exclude
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/admin/backup/config` - Get current configuration
|
||||
- `PUT /api/admin/backup/config` - Update configuration
|
||||
- `GET /api/admin/backup/status` - Get backup status and history
|
||||
- `POST /api/admin/backup/run` - Trigger manual backup
|
||||
- `POST /api/admin/backup/test-connection` - Test destination connectivity
|
||||
|
||||
### Testing
|
||||
Run backup service test: `npm run test-backup`
|
||||
|
||||
### Database Tables
|
||||
- `backup_runs`: Tracks each backup execution with statistics
|
||||
- `backup_file_states`: Stores file checksums for change detection
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
+1
-1
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
|
||||
|
||||
## 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.
|
||||
Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
+3
-3
@@ -153,8 +153,8 @@ picpeak/
|
||||
|
||||
## 📮 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
|
||||
- Create an issue for bugs or features
|
||||
- Join discussions for questions
|
||||
- Email: picpeak@example.com for security issues
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes.
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- A server with Docker and Docker Compose installed
|
||||
- A domain name (for SSL certificates)
|
||||
- SMTP credentials for sending emails
|
||||
- Basic command line knowledge
|
||||
|
||||
## 🏃 Quick Deploy (Recommended)
|
||||
|
||||
### 1. Clone and Configure
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.production.example .env
|
||||
|
||||
# Generate a secure JWT secret
|
||||
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
|
||||
# Edit configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 2. Required Environment Variables
|
||||
|
||||
Edit your `.env` file with these essential settings:
|
||||
|
||||
```env
|
||||
# Application URLs
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
BACKEND_URL=https://your-domain.com
|
||||
|
||||
# Email Configuration (Required for notifications)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=your-email@gmail.com
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Database (PostgreSQL for production)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres
|
||||
DB_NAME=picpeak
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=secure-db-password
|
||||
```
|
||||
|
||||
### 3. Deploy with Docker Compose
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Access your site at https://your-domain.com
|
||||
```
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Storage Settings
|
||||
|
||||
```env
|
||||
# Storage paths (default: ./storage)
|
||||
STORAGE_PATH=./storage
|
||||
ARCHIVE_PATH=./storage/archives
|
||||
|
||||
# Gallery expiration (days)
|
||||
DEFAULT_EXPIRATION_DAYS=30
|
||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
||||
```
|
||||
|
||||
### Security Settings
|
||||
|
||||
```env
|
||||
# Session timeout (minutes)
|
||||
SESSION_TIMEOUT=60
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
### Analytics (Optional)
|
||||
|
||||
```env
|
||||
# Umami Analytics
|
||||
VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
## 🔒 SSL/TLS Setup
|
||||
|
||||
The production Docker Compose includes automatic SSL via Let's Encrypt:
|
||||
|
||||
1. **Ensure your domain points to your server**
|
||||
2. **Update nginx configuration**:
|
||||
```bash
|
||||
nano nginx/nginx.conf
|
||||
# Replace your-domain.com with your actual domain
|
||||
```
|
||||
3. **Start services** - Certbot will automatically obtain certificates
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
After deployment, your directory structure will be:
|
||||
|
||||
```
|
||||
picpeak/
|
||||
├── backend/ # API server
|
||||
├── frontend/ # React app
|
||||
├── storage/ # Photo storage
|
||||
│ ├── events/ # Active galleries
|
||||
│ │ ├── active/ # Current photos
|
||||
│ │ └── archived/ # Expired galleries
|
||||
│ ├── thumbnails/ # Generated thumbnails
|
||||
│ └── uploads/ # User uploads
|
||||
├── data/ # Database files
|
||||
└── logs/ # Application logs
|
||||
```
|
||||
|
||||
## 🔄 Maintenance
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup database and photos
|
||||
./scripts/backup.sh
|
||||
|
||||
# Backups are stored in ./backups/
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# View all logs
|
||||
docker-compose logs
|
||||
|
||||
# View specific service
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Photos not appearing:**
|
||||
- Check storage permissions: `chmod -R 755 storage/`
|
||||
- Verify file watcher is running: `docker-compose logs backend | grep watcher`
|
||||
|
||||
**Email not sending:**
|
||||
- Test SMTP settings: Admin Panel → Settings → Email → Send Test
|
||||
- Check email queue: Admin Panel → System → Email Queue
|
||||
|
||||
**Can't access admin panel:**
|
||||
- Default login: Use email/password from `.env`
|
||||
- Reset password: `docker exec picpeak-backend npm run reset-admin`
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
|
||||
# Test backend API
|
||||
curl https://your-domain.com/api/health
|
||||
|
||||
# Check disk space
|
||||
df -h storage/
|
||||
```
|
||||
|
||||
## 🐳 Alternative Deployment Methods
|
||||
|
||||
### Using Docker Swarm
|
||||
|
||||
For high availability deployments, see [Docker Swarm Setup](deploy/README.md).
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer not to use Docker:
|
||||
|
||||
1. Install Node.js 18+
|
||||
2. Install PostgreSQL
|
||||
3. Clone repository
|
||||
4. Install dependencies: `npm install` in both `/backend` and `/frontend`
|
||||
5. Build frontend: `cd frontend && npm run build`
|
||||
6. Start services with PM2
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- 📘 [Documentation](https://github.com/the-luap/picpeak)
|
||||
- 🐛 [Report Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- 💬 [Discussions](https://github.com/the-luap/picpeak/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue on GitHub and we'll assist you!
|
||||
@@ -1,888 +0,0 @@
|
||||
# 🚀 PicPeak Deployment Guide
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#-external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, 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 script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```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
|
||||
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain name (for production)
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🔧 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 (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **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.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 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.
|
||||
|
||||
### Routing Schema
|
||||
|
||||
PicPeak consists of two services that need to be routed correctly:
|
||||
|
||||
| Path | Service | Port | Description |
|
||||
|------|---------|------|-------------|
|
||||
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||
|
||||
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||
|
||||
### 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;
|
||||
|
||||
# Backend: API endpoints
|
||||
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;
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
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;
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA)
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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"
|
||||
# API endpoints
|
||||
- "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"
|
||||
# Protected media files
|
||||
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
|
||||
Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Backend: API endpoints
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Backend: Protected media files
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /thumbnails/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## 📂 External Media Library
|
||||
|
||||
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
|
||||
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
|
||||
|
||||
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Path where your photo library is stored on the host
|
||||
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
|
||||
```
|
||||
|
||||
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- EXTERNAL_MEDIA_ROOT=/external-media
|
||||
volumes:
|
||||
- /path/to/your/photos:/external-media:ro # read-only is recommended
|
||||
```
|
||||
|
||||
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
|
||||
|
||||
### Folder Structure
|
||||
|
||||
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
|
||||
|
||||
```
|
||||
/path/to/your/photos/
|
||||
├── wedding-smith-2026/
|
||||
│ ├── individual/
|
||||
│ │ ├── IMG_0001.jpg
|
||||
│ │ ├── IMG_0002.jpg
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ ├── group-photo.jpg
|
||||
│ └── ...
|
||||
├── corporate-event/
|
||||
│ ├── individual/
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
|
||||
|
||||
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
|
||||
|
||||
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
|
||||
|
||||
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
|
||||
- Recursively scan the selected folder
|
||||
- Classify photos by subfolder name (`individual/` or `collages/`)
|
||||
- Deduplicate by filename (keeps the largest file if duplicates exist)
|
||||
- Extract image dimensions for gallery layout
|
||||
- Register the photos in the database
|
||||
|
||||
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Images only** — video files are not supported for external media.
|
||||
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
|
||||
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
|
||||
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
|
||||
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
|
||||
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
|
||||
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
|
||||
|
||||
## 🔧 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 or Channel Updates
|
||||
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,378 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This comprehensive guide addresses all production deployment scenarios and common issues.
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### 1. Environment Variables
|
||||
Create a `.env` file with ALL required variables:
|
||||
|
||||
```bash
|
||||
# CRITICAL - Must change these!
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
DB_PASSWORD=<strong-password>
|
||||
|
||||
# Application URLs (your actual domain)
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
BACKEND_URL=https://yourdomain.com
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=postgres # or external host
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration (required for notifications)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password # Use app-specific password
|
||||
EMAIL_FROM=PicPeak <noreply@yourdomain.com>
|
||||
|
||||
# Port Configuration
|
||||
PORT=3001
|
||||
|
||||
# Performance Tuning
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=25
|
||||
NODE_ENV=production
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional: Umami Analytics (configured via Admin UI)
|
||||
# UMAMI_URL=https://analytics.yourdomain.com
|
||||
# UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
### 2. Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate JWT Secret (REQUIRED)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Database Password
|
||||
openssl rand -base64 24
|
||||
```
|
||||
|
||||
## Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy:
|
||||
|
||||
### Frontend Environment
|
||||
```bash
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api # Uses relative path for reverse proxy
|
||||
|
||||
# Optional: Umami fallback (primary config via Admin UI)
|
||||
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
```
|
||||
|
||||
This ensures all API calls use the same domain/protocol as the frontend.
|
||||
|
||||
### Nginx Proxy Configuration
|
||||
|
||||
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)
|
||||
|
||||
All static assets are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/the-luap/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
|
||||
# Create required directories
|
||||
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
|
||||
mkdir -p data logs
|
||||
mkdir -p certbot/conf certbot/www
|
||||
|
||||
# Set permissions (important!)
|
||||
chmod -R 755 storage data logs
|
||||
```
|
||||
|
||||
### 2. Fix Docker Volume Permissions
|
||||
|
||||
Create `docker-compose.override.yml` for local volume configuration:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- ./storage:/app/storage:delegated
|
||||
- ./data:/app/data:delegated
|
||||
- ./logs:/app/logs:delegated
|
||||
user: "1001:1001" # nodejs user
|
||||
|
||||
db:
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
### 3. Build and Deploy
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### 4. Initial Admin Setup
|
||||
|
||||
The admin user is automatically created during database migration:
|
||||
|
||||
```bash
|
||||
# Run migrations (this creates admin user)
|
||||
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
|
||||
|
||||
# Admin credentials will be displayed in console and saved to ADMIN_CREDENTIALS.txt
|
||||
# Example output:
|
||||
# ========================================
|
||||
# ✅ Admin user created successfully!
|
||||
# ========================================
|
||||
# Username: admin
|
||||
# Password: SwiftEagle3847!
|
||||
#
|
||||
# ⚠️ IMPORTANT: Change password on first login
|
||||
# ========================================
|
||||
|
||||
# Retrieve credentials if needed
|
||||
docker-compose -f docker-compose.prod.yml exec backend cat ADMIN_CREDENTIALS.txt
|
||||
```
|
||||
|
||||
**Important**: You MUST change the auto-generated password on first login.
|
||||
|
||||
### 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
|
||||
|
||||
### Option 1: Using Traefik (Recommended)
|
||||
|
||||
Add these labels to your docker-compose override:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
### Option 2: Using Certbot
|
||||
|
||||
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
|
||||
- [ ] Admin password changed from auto-generated one
|
||||
- [ ] SSL/HTTPS enabled
|
||||
- [ ] Firewall configured (only 80/443 open)
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Access logs monitored
|
||||
- [ ] Rate limiting enabled (built-in)
|
||||
- [ ] File upload restrictions configured
|
||||
- [ ] Password complexity requirements configured (Admin > Settings)
|
||||
- [ ] Session timeout configured (default 60 min)
|
||||
- [ ] Umami analytics configured (if using)
|
||||
- [ ] SMTP credentials secured with app-specific password
|
||||
|
||||
## 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
|
||||
@@ -1,152 +1,213 @@
|
||||
# PicPeak
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
**Self-hosted photo sharing for event photographers.**
|
||||
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## Demo
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
|
||||
- **💰 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)
|
||||
|
||||
> The demo resets periodically.
|
||||
## ✨ Key Features
|
||||
|
||||
## Features
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
|
||||
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
|
||||
### 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
|
||||
|
||||
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
|
||||
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
|
||||
## 🚀 Quick Start
|
||||
|
||||
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
|
||||
|
||||
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
|
||||
|
||||
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
|
||||
|
||||
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
|
||||
|
||||
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
|
||||
|
||||
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
|
||||
|
||||
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
|
||||
|
||||
## 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 .env — set at least JWT_SECRET and passwords
|
||||
docker compose up -d
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:3005
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` and log in with the credentials from your `.env`.
|
||||
## 📖 Documentation
|
||||
|
||||
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
|
||||
## 🎯 Use Cases
|
||||
|
||||
## Screenshots
|
||||
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 email security@example.com
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### 🎛️ **Admin Dashboard**
|
||||
Get a complete overview of your photo galleries, analytics, and system status.
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 **Analytics & Insights**
|
||||
Track gallery performance, view statistics, and monitor user engagement.
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 **Event Management**
|
||||
Organize and manage your photo galleries with intuitive event management tools.
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
### ✨ **Key Interface Highlights**
|
||||
|
||||
<details>
|
||||
<summary>Admin Dashboard</summary>
|
||||
<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
|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Event Management</summary>
|
||||
## 🗺️ Roadmap
|
||||
|
||||
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
|
||||
</details>
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary>Analytics</summary>
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | 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 | 🔄 Open |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
|
||||
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
|
||||
</details>
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
## Comparison
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
| | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---|---|---|---|
|
||||
| Self-hosted | Yes | No | No |
|
||||
| Monthly cost | $0 | $29-199 | 19-99 EUR |
|
||||
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
|
||||
| Custom branding | Full | Limited | Limited |
|
||||
| Open source | Yes | No | No |
|
||||
| API | Yes | Paid | No |
|
||||
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.
|
||||
|
||||
## Tech Stack
|
||||
## 📄 License
|
||||
|
||||
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
|
||||
- **Frontend:** React, TypeScript, Tailwind CSS
|
||||
- **Infrastructure:** Docker, Nginx, Redis
|
||||
- **Processing:** Sharp (images), FFmpeg (video)
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
## Release Channels
|
||||
## 🚀 Ready to Get Started?
|
||||
|
||||
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
|
||||
|
||||
**Beta** (`beta`) — Early access to new features. May have rough edges.
|
||||
|
||||
```bash
|
||||
# Set in .env
|
||||
PICPEAK_CHANNEL=stable # or beta
|
||||
|
||||
# Update
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard notifies you when updates are available.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
|
||||
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
|
||||
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
|
||||
- [Security Policy](SECURITY.md)
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it for personal or commercial projects.
|
||||
1. ⭐ **Star this repository** to show your support
|
||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
||||
3. 🐛 Report issues or request features
|
||||
4. 🤝 Join our community and contribute!
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
|
||||
</p>
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
+9
-12
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -15,14 +15,11 @@ We take the security of PicPeak seriously. If you have discovered a security vul
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
### 2. Email us at security@example.com with:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
### 3. You can expect:
|
||||
- Acknowledgment within 48 hours
|
||||
@@ -82,7 +79,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- Security issues: security@example.com
|
||||
- General support: https://github.com/the-luap/picpeak/issues
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
-553
@@ -1,553 +0,0 @@
|
||||
# 🚀 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
|
||||
|
||||
#### Via Admin Panel
|
||||
1. Login to admin panel at `/admin`
|
||||
2. Click "Create New Event"
|
||||
3. Configure settings (name, date, password, customer email)
|
||||
4. Upload photos via drag & drop in the Photos tab
|
||||
5. Publish the gallery when ready
|
||||
|
||||
#### Adding Photos via File System
|
||||
|
||||
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
|
||||
|
||||
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
|
||||
|
||||
```bash
|
||||
# Docker installation — copy photos into an existing event's folder
|
||||
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
|
||||
|
||||
# Native installation
|
||||
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
|
||||
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
|
||||
```
|
||||
|
||||
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
|
||||
|
||||
### Gallery Structure
|
||||
```
|
||||
<event-slug>/
|
||||
├── collages/ # Group photos (optional subfolder)
|
||||
├── individual/ # Individual photos (optional subfolder)
|
||||
└── photo.jpg # Photos at root level also work
|
||||
```
|
||||
|
||||
## 🔧 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
|
||||
```
|
||||
|
||||
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
|
||||
|
||||
### 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://docs.picpeak.app)
|
||||
- [Deployment Guide](https://docs.picpeak.app/deployment)
|
||||
|
||||
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)
|
||||
@@ -9,43 +9,9 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP
|
||||
#
|
||||
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
|
||||
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
|
||||
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
|
||||
# req.secure from Express, which respects the X-Forwarded-Proto header
|
||||
# when the proxy is in the trust list.
|
||||
#
|
||||
# Requirements for auto mode:
|
||||
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
|
||||
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
|
||||
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
|
||||
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
|
||||
# 192.168.x, link-local). Proxies outside those ranges need custom
|
||||
# trust proxy configuration.
|
||||
# COOKIE_SECURE=auto
|
||||
|
||||
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
|
||||
# COOKIE_SAMESITE=Lax
|
||||
|
||||
# Cookie Domain — set this if serving auth cookies across subdomains.
|
||||
# Leave unset for same-origin setups.
|
||||
# COOKIE_DOMAIN=.example.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
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
@@ -72,7 +38,6 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
|
||||
+9
-29
@@ -1,46 +1,26 @@
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
# Add build argument for cache busting
|
||||
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
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
|
||||
RUN npm install -g npm@10
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
|
||||
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
|
||||
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
|
||||
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
|
||||
# pipeline calls via fluent-ffmpeg.ffprobe()).
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
@@ -49,8 +29,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Ensure all source files are readable and wait script is executable
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
# Make wait script executable
|
||||
RUN chmod +x wait-for-db.sh
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:18-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 ffmpeg for video uploads.
|
||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||
RUN apk add --no-cache dumb-init ffmpeg
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, jest, 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;
|
||||
@@ -7,15 +7,12 @@ const crypto = require('crypto');
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initializeDatabase: initDb } = require('../../src/database/db');
|
||||
const { db, initialize: initDb } = require('../../src/database/db');
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
// Test configuration
|
||||
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
|
||||
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
|
||||
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
|
||||
const TEST_CONFIG = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
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(),
|
||||
@@ -59,17 +56,9 @@ describe('S3 Backup Integration Tests', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Schema is expected to already be applied by `npm run migrate` against
|
||||
// the dev database. db.migrate.latest() can't be used here because
|
||||
// PicPeak's custom run-migrations.js tracks state in the `migrations`
|
||||
// table (not knex's `knex_migrations`), so knex would try to re-apply
|
||||
// every migration and crash on duplicate-table errors.
|
||||
const ok = await db.schema.hasTable('events')
|
||||
&& await db.schema.hasTable('app_settings')
|
||||
&& await db.schema.hasTable('backup_runs');
|
||||
if (!ok) {
|
||||
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
|
||||
}
|
||||
// Initialize database
|
||||
await initDb();
|
||||
await db.migrate.latest();
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
@@ -80,12 +69,10 @@ describe('S3 Backup Integration Tests', () => {
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
if (process.env.UNMOCK_LOGGER !== 'true') {
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
}
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -178,9 +165,8 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
// pg driver returns bigint columns as strings; coerce for the size assertion.
|
||||
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
|
||||
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
@@ -283,16 +269,13 @@ describe('S3 Backup Integration Tests', () => {
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
|
||||
expect(secondRun.files_backed_up).toBe(1); // Only modified file
|
||||
|
||||
// Check manifest indicates incremental. The current manifest schema
|
||||
// groups counts under `incremental.changes.*` (added/modified/deleted/
|
||||
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
|
||||
// Check manifest indicates incremental
|
||||
if (secondRun.manifest_path) {
|
||||
const manifest = await backupService.getBackupManifest(secondRun.id);
|
||||
expect(manifest.manifest.incremental).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes).toBeDefined();
|
||||
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
|
||||
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -485,16 +468,15 @@ describe('S3 Backup Integration Tests', () => {
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
// Schema drift: app_settings has no created_at column anymore and the
|
||||
// unique constraint is on setting_key alone, not (setting_type, key).
|
||||
for (const setting of settings) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_type: 'backup',
|
||||
...setting,
|
||||
updated_at: new Date(),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.onConflict(['setting_type', 'setting_key'])
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
@@ -521,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
const storageModule = require('../../src/services/storage');
|
||||
|
||||
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
throw new Error('db disabled in this test');
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function makeSourceJpeg(targetDir, name) {
|
||||
const localPath = path.join(targetDir, name);
|
||||
// 800x600 random RGB image so sharp has something realistic to thumbnail.
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
const buf = Buffer.alloc(width * height * 3);
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||||
await sharp(buf, { raw: { width, height, channels: 3 } })
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(localPath);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let imageProcessor;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
storageModule.setStorageForTesting(storage);
|
||||
// Require AFTER setStorageForTesting so the module sees our injection.
|
||||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
storageModule.resetStorage();
|
||||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('generateThumbnail writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(key).toBe('thumbnails/thumb_sample.jpg');
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat.size).toBeGreaterThan(100);
|
||||
|
||||
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
|
||||
if (storage.kind() === 'local') {
|
||||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||||
expect(meta.format).toBe('jpeg');
|
||||
expect(meta.width).toBeLessThanOrEqual(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('generateHeroImage writes through storage and returns a relative key', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
|
||||
const key = await imageProcessor.generateHeroImage(src);
|
||||
expect(key).toBe('heroes/hero_hero-source.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
|
||||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||||
const key = await imageProcessor.generateThumbnail(src);
|
||||
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
|
||||
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
|
||||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
|
||||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
});
|
||||
|
||||
test('withLocalCopy yields a usable local path on both backends', async () => {
|
||||
const sourceKey = 'fixture/withlocal.jpg';
|
||||
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
|
||||
const buf = await fs.readFile(src);
|
||||
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
|
||||
|
||||
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
|
||||
const meta = await sharp(localPath).metadata();
|
||||
return meta.width;
|
||||
});
|
||||
expect(seenSize).toBe(800);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { Readable } = require('stream');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||||
|
||||
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
|
||||
const TEST_S3 = {
|
||||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||||
|
||||
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
|
||||
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
|
||||
// every consumer can rely on identical semantics.
|
||||
function backendCases() {
|
||||
const cases = [
|
||||
{
|
||||
name: 'LocalFsStorage',
|
||||
async setup() {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
|
||||
const storage = new LocalFsStorage({ root });
|
||||
await storage.init();
|
||||
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!skipS3) {
|
||||
cases.push({
|
||||
name: 'S3StorageBackend (MinIO)',
|
||||
async setup() {
|
||||
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
const s3Client = new S3Client({
|
||||
endpoint: TEST_S3.endpoint,
|
||||
region: TEST_S3.region,
|
||||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
const storage = new S3StorageBackend({
|
||||
bucket,
|
||||
region: TEST_S3.region,
|
||||
endpoint: TEST_S3.endpoint,
|
||||
accessKeyId: TEST_S3.accessKeyId,
|
||||
secretAccessKey: TEST_S3.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false,
|
||||
});
|
||||
await storage.init();
|
||||
return {
|
||||
storage,
|
||||
async cleanup() {
|
||||
// Empty bucket then delete it.
|
||||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||||
if (list.Contents?.length) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||||
}));
|
||||
}
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
async function readToString(stream) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
|
||||
let storage;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ storage, cleanup } = await setup());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
|
||||
const key = 'photos/event-a/IMG_0001.jpg';
|
||||
const body = Buffer.from('hello picpeak');
|
||||
|
||||
await storage.put(key, body, { contentType: 'image/jpeg' });
|
||||
|
||||
expect(await storage.exists(key)).toBe(true);
|
||||
|
||||
const stat = await storage.stat(key);
|
||||
expect(stat).not.toBeNull();
|
||||
expect(stat.size).toBe(body.length);
|
||||
|
||||
const stream = await storage.get(key);
|
||||
const text = await readToString(stream);
|
||||
expect(text).toBe('hello picpeak');
|
||||
|
||||
await storage.delete(key);
|
||||
expect(await storage.exists(key)).toBe(false);
|
||||
expect(await storage.stat(key)).toBeNull();
|
||||
});
|
||||
|
||||
test('put accepts a Readable stream body', async () => {
|
||||
const key = 'photos/event-b/streamed.bin';
|
||||
const body = Readable.from(Buffer.from('streamed payload'));
|
||||
|
||||
await storage.put(key, body);
|
||||
|
||||
const got = await readToString(await storage.get(key));
|
||||
expect(got).toBe('streamed payload');
|
||||
});
|
||||
|
||||
test('putFromFile + getToFile round-trip', async () => {
|
||||
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
|
||||
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
|
||||
await fsp.writeFile(tmpIn, 'file payload');
|
||||
|
||||
const key = 'thumbnails/thumb_x.jpg';
|
||||
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
|
||||
|
||||
await storage.getToFile(key, tmpOut);
|
||||
const text = await fsp.readFile(tmpOut, 'utf-8');
|
||||
expect(text).toBe('file payload');
|
||||
|
||||
await fsp.unlink(tmpIn).catch(() => {});
|
||||
await fsp.unlink(tmpOut).catch(() => {});
|
||||
});
|
||||
|
||||
test('list returns entries under a prefix with size + key', async () => {
|
||||
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
|
||||
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
|
||||
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
|
||||
|
||||
const entries = await storage.list('events/active/a');
|
||||
const keys = entries.map((e) => e.key).sort();
|
||||
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
|
||||
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
|
||||
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
|
||||
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
|
||||
});
|
||||
|
||||
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
|
||||
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
|
||||
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
|
||||
|
||||
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
|
||||
expect(await storage.exists('uploads/final.jpg')).toBe(true);
|
||||
const text = await readToString(await storage.get('uploads/final.jpg'));
|
||||
expect(text).toBe('rename-me');
|
||||
});
|
||||
|
||||
test('copy duplicates an object without removing the source', async () => {
|
||||
await storage.put('events/source.jpg', Buffer.from('src'));
|
||||
await storage.copy('events/source.jpg', 'events/copied.jpg');
|
||||
|
||||
expect(await storage.exists('events/source.jpg')).toBe(true);
|
||||
expect(await storage.exists('events/copied.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
test('delete on a missing key is a no-op (does not throw)', async () => {
|
||||
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('stat on a missing key returns null', async () => {
|
||||
expect(await storage.stat('still/not/here.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects path traversal attempts', async () => {
|
||||
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
|
||||
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
|
||||
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
|
||||
// the SSRF check by default.
|
||||
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
|
||||
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
|
||||
|
||||
const http = require('http');
|
||||
const { db } = require('../../src/database/db');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
|
||||
|
||||
// Local-only test stub: matches what dev/webhook-receiver/server.js does
|
||||
// in the docker-compose flow but spun up inside the Jest process so the
|
||||
// suite is self-contained.
|
||||
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
||||
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function insertWebhook(url, events = ['event.published'], extras = {}) {
|
||||
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
|
||||
// route layer's allowlist check is bypassed here since we insert
|
||||
// straight into the DB.
|
||||
const { plaintext, preview } = webhookService.generateSecret();
|
||||
const insert = await db('webhooks').insert({
|
||||
name: extras.name || 'test',
|
||||
url,
|
||||
secret: plaintext,
|
||||
secret_preview: preview,
|
||||
events: JSON.stringify(events),
|
||||
active: extras.active !== false,
|
||||
created_by: 1,
|
||||
}).returning('id');
|
||||
const id = insert[0]?.id || insert[0];
|
||||
return { id, secret: plaintext };
|
||||
}
|
||||
|
||||
async function clearWebhooks() {
|
||||
await db('webhook_deliveries').del();
|
||||
await db('webhooks').del();
|
||||
}
|
||||
|
||||
describe('webhook delivery worker (#327)', () => {
|
||||
beforeAll(async () => {
|
||||
// Schema is expected to already be applied by `npm run migrate`. We
|
||||
// just verify the webhooks tables exist; if not, the test harness has
|
||||
// missed running migration 082.
|
||||
const ok = await db.schema.hasTable('webhooks');
|
||||
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
stopWebhookDeliveryWorker();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearWebhooks();
|
||||
});
|
||||
|
||||
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id, secret } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(1);
|
||||
const got = stub.requests[0];
|
||||
const sig = got.headers['x-picpeak-signature'];
|
||||
expect(sig).toBeTruthy();
|
||||
// Receiver-side verification using the SAME helper we ship in the README.
|
||||
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
|
||||
// Tampering must fail.
|
||||
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(200);
|
||||
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('headers include event type and a unique delivery id', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
await insertWebhook(stub.url, ['photo.uploaded']);
|
||||
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
|
||||
await __test.tick();
|
||||
|
||||
const got = stub.requests[0];
|
||||
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
|
||||
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
|
||||
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: { id: 2 } });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('pending');
|
||||
expect(row.attempt_count).toBe(1);
|
||||
expect(row.response_status).toBe(500);
|
||||
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
|
||||
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
|
||||
expect(dueIn).toBeGreaterThan(50_000);
|
||||
expect(dueIn).toBeLessThan(70_000);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
|
||||
const stub = await makeStub({ status: 500 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
// Pre-seed a delivery already at attempt_count = 4 so a single tick
|
||||
// takes it to 5 → failed (avoids waiting through backoffs).
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 4,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.attempt_count).toBe(5);
|
||||
expect(row.completed_at).toBeTruthy();
|
||||
expect(row.next_retry_at).toBeNull();
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('truncates response body to 1KB before storing', async () => {
|
||||
const big = 'x'.repeat(5000);
|
||||
const stub = await makeStub({ status: 200, bodyOverride: big });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url);
|
||||
await webhookService.fire('event.published', { event: {} });
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('success');
|
||||
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
|
||||
const stub = await makeStub({ status: 200 });
|
||||
try {
|
||||
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
|
||||
// fire enqueues regardless of active state at fire-time, but we
|
||||
// disabled BEFORE firing so nothing is enqueued. Direct insert to
|
||||
// exercise the worker's mid-flight disable check:
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
expect(stub.requests).toHaveLength(0);
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/disabled/i);
|
||||
} finally {
|
||||
await stub.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
|
||||
__test.setAllowPrivateUrls(false);
|
||||
try {
|
||||
const { id } = await insertWebhook('http://127.0.0.1:9/');
|
||||
await db('webhook_deliveries').insert({
|
||||
webhook_id: id,
|
||||
event_type: 'event.published',
|
||||
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
|
||||
attempt_count: 0,
|
||||
status: 'pending',
|
||||
next_retry_at: new Date(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await __test.tick();
|
||||
|
||||
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.last_error).toMatch(/private|internal/i);
|
||||
} finally {
|
||||
__test.setAllowPrivateUrls(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('worker can be started + stopped without leaking timers', async () => {
|
||||
startWebhookDeliveryWorker();
|
||||
startWebhookDeliveryWorker(); // idempotent
|
||||
stopWebhookDeliveryWorker();
|
||||
stopWebhookDeliveryWorker(); // idempotent
|
||||
// If timers leaked the test runner would warn after force-exit; assertion
|
||||
// is just "no throw".
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Unit test for the non-mutating isSessionExpired() helper added to
|
||||
* middleware/sessionTimeout.js. Used by GET /auth/session to mirror the
|
||||
* timeout enforcement that sessionTimeoutMiddleware applies to /api/admin
|
||||
* endpoints — closing the asymmetry that surfaced as the redirect-loop
|
||||
* recurrence on v3.39.1-beta.0 (issue #350).
|
||||
*
|
||||
* The helper has two branches:
|
||||
* 1. In-memory `lastActivity` exists for this token → expired iff
|
||||
* now - lastActivity > timeout.
|
||||
* 2. No in-memory entry (post-restart, or first request) → expired
|
||||
* iff token's iat is older than the timeout (post-restart guard
|
||||
* that the existing middleware already implements at line ~101).
|
||||
*
|
||||
* Both branches must NOT mutate the in-memory `sessions` Map — the
|
||||
* middleware is the only place that tracks activity. We assert that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
where: () => ({
|
||||
first: () => ({
|
||||
timeout: () => Promise.resolve(null),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Speed up the cached-timeout reads. The module reads
|
||||
// `security_session_timeout_minutes` from app_settings and falls back to
|
||||
// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null.
|
||||
const SIXTY_MINUTES_MS = 60 * 60 * 1000;
|
||||
|
||||
const sessionTimeout = require('../../src/middleware/sessionTimeout');
|
||||
const { isSessionExpired } = sessionTimeout;
|
||||
|
||||
function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) {
|
||||
return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) };
|
||||
}
|
||||
|
||||
describe('isSessionExpired (sessionTimeout helper)', () => {
|
||||
it('returns false for a freshly-issued token with no in-memory record', async () => {
|
||||
const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 });
|
||||
expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when iat is older than the timeout (post-restart guard)', async () => {
|
||||
const decoded = makeDecodedToken({
|
||||
id: 2,
|
||||
// 90 minutes > 60 minute default timeout
|
||||
iatSecondsAgo: 90 * 60,
|
||||
});
|
||||
expect(await isSessionExpired('stale-token-2', decoded)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false / true based on lastActivity when one exists', async () => {
|
||||
// Drive the in-memory map by running the actual middleware once to
|
||||
// record activity for the token, then check the helper.
|
||||
const decoded = makeDecodedToken({ id: 3 });
|
||||
|
||||
// Drive the actual middleware once with a real signed token so it
|
||||
// records this token in the in-memory `sessions` Map. Then check the
|
||||
// helper sees that recent activity and reports "not expired".
|
||||
const res = { status: jest.fn(() => res), json: jest.fn() };
|
||||
const jwt = require('jsonwebtoken');
|
||||
process.env.JWT_SECRET = 'session-timeout-helper-test-secret';
|
||||
const realToken = jwt.sign(decoded, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
});
|
||||
const realReq = {
|
||||
headers: { authorization: `Bearer ${realToken}` },
|
||||
cookies: {},
|
||||
};
|
||||
await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {});
|
||||
|
||||
const decodedReal = jwt.decode(realToken);
|
||||
// Just-recorded → not expired
|
||||
expect(await isSessionExpired(realToken, decodedReal)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when token / decoded is missing (defensive)', async () => {
|
||||
expect(await isSessionExpired(null, { id: 1 })).toBe(false);
|
||||
expect(await isSessionExpired('tok', null)).toBe(false);
|
||||
expect(await isSessionExpired('tok', {})).toBe(false);
|
||||
});
|
||||
|
||||
// Sanity: the helper must not poke the `sessions` Map. Indirectly check
|
||||
// by counting active sessions before/after a call with a never-seen
|
||||
// token — should not change.
|
||||
it('does not mutate the in-memory sessions map', async () => {
|
||||
const before = sessionTimeout.getActiveSessions();
|
||||
await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 }));
|
||||
const after = sessionTimeout.getActiveSessions();
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('uses the default 60-minute timeout when no DB setting exists', async () => {
|
||||
// 59 minutes → not expired
|
||||
const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 });
|
||||
expect(await isSessionExpired('fresh-4', fresh)).toBe(false);
|
||||
|
||||
// 61 minutes → expired (just past the default)
|
||||
const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 });
|
||||
expect(await isSessionExpired('stale-5', stale)).toBe(true);
|
||||
});
|
||||
|
||||
// Document the constant the test relies on so a future timeout change
|
||||
// makes this assertion explicit rather than mysterious.
|
||||
it('default timeout is 60 minutes (constant under test)', () => {
|
||||
expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) out[c] = row[c];
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
// Mock sessionTimeout's isSessionExpired so each test controls the return.
|
||||
// Default: not expired (so existing tests keep passing without setup).
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({
|
||||
endSession: jest.fn(),
|
||||
isSessionExpired: jest.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
|
||||
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
|
||||
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
|
||||
// The new isSessionExpired helper closes that asymmetry.
|
||||
describe('session-timeout symmetry', () => {
|
||||
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
|
||||
|
||||
beforeEach(() => {
|
||||
isSessionExpired.mockReset();
|
||||
// Default to "active session" so the other admin checks above also
|
||||
// pass when this branch runs.
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(true);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
expect(res.body.error).toBe('Session expired');
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token (helper says not expired)', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockResolvedValue(false);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call isSessionExpired for gallery tokens', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(isSessionExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through (treats as valid) if the helper itself throws', async () => {
|
||||
// Defensive: the require() in auth.js is wrapped in try/catch so a
|
||||
// missing/broken helper doesn't fail-closed during early bootstrap.
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
isSessionExpired.mockRejectedValue(new Error('boom'));
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Unit tests for backgroundProcessor.claimNextPhoto.
|
||||
*
|
||||
* Mocks the db so we don't need a live postgres/sqlite — focuses on
|
||||
* the claim contract: returns null when no rows, returns row + flips
|
||||
* status to 'processing' when one is available, returns null when a
|
||||
* race loses the UPDATE-with-guard.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/services/photoProcessor', () => ({
|
||||
processPhoto: jest.fn(),
|
||||
processUploadedPhotos: jest.fn(),
|
||||
queueFilesForProcessing: jest.fn(),
|
||||
}));
|
||||
|
||||
// Build a fake knex instance whose .transaction() takes a callback we can
|
||||
// drive from the test, and whose query-builder records calls.
|
||||
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
|
||||
const queries = [];
|
||||
|
||||
const builder = () => {
|
||||
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
|
||||
queries.push(recorded);
|
||||
const chain = {
|
||||
where: jest.fn(function (...args) {
|
||||
recorded.wheres.push(args);
|
||||
return chain;
|
||||
}),
|
||||
orderBy: jest.fn(function () {
|
||||
recorded.ordered = true;
|
||||
return chain;
|
||||
}),
|
||||
forUpdate: jest.fn(function () {
|
||||
recorded.locked = true;
|
||||
return chain;
|
||||
}),
|
||||
skipLocked: jest.fn(function () {
|
||||
recorded.skipped = true;
|
||||
return chain;
|
||||
}),
|
||||
first: jest.fn(async function () {
|
||||
// Only the SELECT chain returns the pending row; the UPDATE chain
|
||||
// never calls .first().
|
||||
return pendingRow ? { ...pendingRow } : null;
|
||||
}),
|
||||
update: jest.fn(async function (data) {
|
||||
recorded.updates = data;
|
||||
return updateResult;
|
||||
}),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
const trxFn = (table) => builder(table);
|
||||
trxFn.client = { config: { client: clientName } };
|
||||
trxFn.transaction = async (cb) => cb(trxFn);
|
||||
|
||||
// Top-level db('photos') returns same builder for the janitor test path.
|
||||
const db = trxFn;
|
||||
return { db, queries };
|
||||
}
|
||||
|
||||
describe('backgroundProcessor.claimNextPhoto', () => {
|
||||
function loadProcessor(db) {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
return require('../../src/services/backgroundProcessor');
|
||||
}
|
||||
|
||||
it('returns null when there are no pending photos (postgres path)', async () => {
|
||||
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the claimed row and flips status (postgres path)', async () => {
|
||||
const pendingRow = { id: 42, processing_status: 'pending' };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(true);
|
||||
expect(queries[0].skipped).toBe(true);
|
||||
// The second query is the status update.
|
||||
expect(queries[1].updates.processing_status).toBe('processing');
|
||||
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// SQLite path: no FOR UPDATE / SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(false);
|
||||
expect(queries[0].skipped).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.htmlToText.
|
||||
*
|
||||
* Regression: when a template ships without a body_text, sendTemplateEmail
|
||||
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
|
||||
* That regex strips angle-bracket tags but leaves the *contents* of <style>
|
||||
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
|
||||
* embeds a 100+ line <style> block) produced a "plain-text" email starting
|
||||
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
expect(htmlToText(null)).toBe('');
|
||||
expect(htmlToText(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('strips <style> blocks and their contents', () => {
|
||||
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
|
||||
const out = htmlToText(html);
|
||||
expect(out).toBe('Hello');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/color/);
|
||||
});
|
||||
|
||||
it('strips <script> blocks and their contents', () => {
|
||||
const html = '<body><script>alert("x")</script>Hi</body>';
|
||||
expect(htmlToText(html)).toBe('Hi');
|
||||
});
|
||||
|
||||
it('converts <br> tags to newlines', () => {
|
||||
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
|
||||
});
|
||||
|
||||
it('keeps a paragraph break between adjacent <p> tags', () => {
|
||||
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
|
||||
});
|
||||
|
||||
it('decodes the common HTML entities', () => {
|
||||
expect(htmlToText('Tom & Jerry <3 "hi"'))
|
||||
.toBe('Tom & Jerry <3 "hi"');
|
||||
});
|
||||
|
||||
it('handles a fully-wrapped email body without leaking CSS rules', () => {
|
||||
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
|
||||
// CSS rules followed by the actual content.
|
||||
const wrapped = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
|
||||
.email-container { max-width: 600px; }
|
||||
.button { background-color: #5C8762; color: white !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) Natalie,</p>
|
||||
</body>
|
||||
</html>`;
|
||||
const out = htmlToText(wrapped);
|
||||
expect(out).toContain('Galerie erfolgreich erstellt');
|
||||
expect(out).toContain('Liebe(r) Natalie');
|
||||
expect(out).not.toMatch(/margin/);
|
||||
expect(out).not.toMatch(/font-family/);
|
||||
expect(out).not.toMatch(/background-color/);
|
||||
expect(out).not.toMatch(/\.button/);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Unit tests for emailProcessor.safeTemplateReplace.
|
||||
*
|
||||
* Covers the two regressions that hit picpeak.nothaft.cloud on the
|
||||
* 3.32.x betas:
|
||||
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
|
||||
* because the renderer only handled {{var}} substitution and the
|
||||
* shipped templates use Handlebars-style conditionals.
|
||||
* - {{var}} substitution inside a kept conditional block.
|
||||
*
|
||||
* The publish-from-draft password localisation lives inside the wider
|
||||
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
|
||||
* sentinel string '(set at creation)' is asserted only at the i18n-map
|
||||
* level by integration in adminEvents.js.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
|
||||
|
||||
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
|
||||
|
||||
describe('safeTemplateReplace', () => {
|
||||
describe('flat variable substitution', () => {
|
||||
it('replaces {{var}} with the variable value', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
|
||||
.toBe('Hello Paul!');
|
||||
});
|
||||
|
||||
it('leaves unknown variables untouched', () => {
|
||||
expect(safeTemplateReplace('Hello {{name}}!', {}))
|
||||
.toBe('Hello {{name}}!');
|
||||
});
|
||||
|
||||
it('coerces non-string values to string', () => {
|
||||
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
|
||||
.toBe('Count: 42');
|
||||
});
|
||||
|
||||
it('handles empty templates and missing variables map', () => {
|
||||
expect(safeTemplateReplace('', { x: 1 })).toBe('');
|
||||
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
|
||||
expect(safeTemplateReplace(null, {})).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('{{#if VAR}}…{{/if}} blocks', () => {
|
||||
it('strips the block when the variable is missing', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, {})).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is an empty string', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
|
||||
});
|
||||
|
||||
it('strips the block when the variable is null', () => {
|
||||
const tpl = '{{#if x}}kept{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the block and substitutes inside it when truthy', () => {
|
||||
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
|
||||
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
|
||||
.toBe('before HELLO world after');
|
||||
});
|
||||
|
||||
it('handles multi-line conditional blocks', () => {
|
||||
const tpl = [
|
||||
'Liebe(r) {{host_name}},',
|
||||
'',
|
||||
'{{#if welcome_message}}',
|
||||
'Persönliche Nachricht:',
|
||||
'{{welcome_message}}',
|
||||
'{{/if}}',
|
||||
'Galerie-Details:',
|
||||
].join('\n');
|
||||
|
||||
const withMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: 'Schön, dass ihr da seid!',
|
||||
});
|
||||
expect(withMsg).toContain('Persönliche Nachricht:');
|
||||
expect(withMsg).toContain('Schön, dass ihr da seid!');
|
||||
expect(withMsg).not.toContain('{{#if');
|
||||
expect(withMsg).not.toContain('{{/if');
|
||||
|
||||
const withoutMsg = safeTemplateReplace(tpl, {
|
||||
host_name: 'Natalie',
|
||||
welcome_message: '',
|
||||
});
|
||||
expect(withoutMsg).not.toContain('Persönliche Nachricht');
|
||||
expect(withoutMsg).not.toContain('{{#if');
|
||||
expect(withoutMsg).not.toContain('{{/if');
|
||||
expect(withoutMsg).toContain('Liebe(r) Natalie,');
|
||||
expect(withoutMsg).toContain('Galerie-Details:');
|
||||
});
|
||||
|
||||
it('handles multiple sibling conditionals independently', () => {
|
||||
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
|
||||
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
|
||||
});
|
||||
|
||||
it('treats numeric 0 as falsy', () => {
|
||||
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping (escapeHtml: true)', () => {
|
||||
it('does not escape by default', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
|
||||
.toBe('Welcome to Test <script>');
|
||||
});
|
||||
|
||||
it('escapes admin-supplied values when opted in', () => {
|
||||
const tpl = 'Welcome to {{event_name}}';
|
||||
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
|
||||
.toBe('Welcome to Test <script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('escapes both the < > and & characters and quotes', () => {
|
||||
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
|
||||
.toBe('<a href="evil">A & B's</a>');
|
||||
});
|
||||
|
||||
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
|
||||
const tpl = '<p>{{welcome_message}}</p>';
|
||||
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
|
||||
.toBe('<p>Hi<br />there</p>');
|
||||
});
|
||||
|
||||
it('passes server-generated URLs through unescaped', () => {
|
||||
const tpl = '<a href="{{gallery_link}}">link</a>';
|
||||
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
|
||||
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,320 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Silence the logger so test output stays clean. Capture calls so the
|
||||
// "warning logged" assertions can still verify behaviour.
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn()
|
||||
}));
|
||||
|
||||
const logger = require('../../src/utils/logger');
|
||||
// Required ONCE at module top so the jest.mock factory above applies to
|
||||
// the logger reference that fontsService captures. A previous version
|
||||
// re-required it inside beforeEach() with jest.resetModules() — that
|
||||
// silently bypassed the mock (logger calls went to the real logger),
|
||||
// so the "warning logged" assertions would resolve as 0 calls and
|
||||
// silently pass-as-noop. Module-level state in fontsService is just
|
||||
// the cache, which clearFontsCache() resets between tests.
|
||||
const fontsService = require('../../src/services/fontsService');
|
||||
|
||||
// Probe at load time: is the host filesystem case-sensitive?
|
||||
// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same
|
||||
// directory entry, which means the "two folders, same lowercase key"
|
||||
// dedup test below can't be set up via real folders on those platforms —
|
||||
// the second mkdir is a no-op. Skip that one test conditionally.
|
||||
const FS_IS_CASE_SENSITIVE = (() => {
|
||||
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-'));
|
||||
fs.writeFileSync(path.join(probeDir, 'casetest'), '');
|
||||
let sensitive = true;
|
||||
try {
|
||||
fs.accessSync(path.join(probeDir, 'CASETEST'));
|
||||
sensitive = false;
|
||||
} catch { /* file not found → case-sensitive FS */ }
|
||||
fs.rmSync(probeDir, { recursive: true, force: true });
|
||||
return sensitive;
|
||||
})();
|
||||
const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip;
|
||||
|
||||
let bundledRoot;
|
||||
let userRoot;
|
||||
|
||||
/**
|
||||
* Create a font family folder with the given weights (and optional meta.json).
|
||||
* @param {string} root absolute path to the bundled or user root
|
||||
* @param {string} folderName e.g. "Inter" or "Playfair-Display"
|
||||
* @param {Array<number>|Array<string>} weights numeric weights (creates `<w>.woff2`)
|
||||
* or filenames to create directly
|
||||
* @param {Object|null} meta optional meta.json contents (object) or null
|
||||
*/
|
||||
async function makeFamily(root, folderName, weights, meta = null) {
|
||||
const dir = path.join(root, folderName);
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
for (const w of weights) {
|
||||
const fname = typeof w === 'number' ? `${w}.woff2` : w;
|
||||
await fsPromises.writeFile(path.join(dir, fname), Buffer.from([]));
|
||||
}
|
||||
if (meta !== null) {
|
||||
await fsPromises.writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-'));
|
||||
userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-'));
|
||||
|
||||
process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot;
|
||||
// The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must
|
||||
// point at the parent of userRoot — we name the leaf "fonts" ourselves.
|
||||
const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-'));
|
||||
await fsPromises.rename(userRoot, path.join(storageParent, 'fonts'));
|
||||
userRoot = path.join(storageParent, 'fonts');
|
||||
process.env.STORAGE_PATH = storageParent;
|
||||
|
||||
// Reset the module-level cache so each test sees a fresh scan.
|
||||
// (Both getBundledFontsRoot and getUserFontsRoot read process.env at
|
||||
// call-time, so the env vars set above are picked up without needing
|
||||
// to re-require the module — see fontsService.js getBundledFontsRoot /
|
||||
// getUserFontsRoot.)
|
||||
fontsService.clearFontsCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fontsService.clearFontsCache();
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {});
|
||||
// userRoot's parent is the actual mkdtemp; remove it.
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.PICPEAK_BUNDLED_FONTS_ROOT;
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
describe('fontsService.listFonts', () => {
|
||||
describe('roots', () => {
|
||||
test('empty bundled root + missing user root → []', async () => {
|
||||
// delete user root so it triggers ENOENT
|
||||
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing bundled root (ENOENT) → [], does not throw', async () => {
|
||||
await fsPromises.rm(bundledRoot, { recursive: true, force: true });
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toEqual([]);
|
||||
});
|
||||
|
||||
test('non-directory entries at the root are skipped', async () => {
|
||||
await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi');
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
|
||||
test('hidden folders are skipped', async () => {
|
||||
await makeFamily(bundledRoot, '.git', [400]);
|
||||
await makeFamily(bundledRoot, '.DS_Store', [400]);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weight parsing', () => {
|
||||
test('three weight files → sorted ascending', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [700, 400, 600]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 600, 700]);
|
||||
});
|
||||
|
||||
test('non-numeric filenames are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('non-.woff2 files are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400]);
|
||||
});
|
||||
|
||||
test('weight values out of range (sub-1 / over-1000) are ignored', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 700]);
|
||||
});
|
||||
|
||||
test('family folder with no usable .woff2 files is silently skipped', async () => {
|
||||
await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']);
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipping NoWeights')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder name → display family', () => {
|
||||
test('hyphens become spaces', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400]);
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.family).toBe('Playfair Display');
|
||||
});
|
||||
|
||||
test('case is preserved', async () => {
|
||||
await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]);
|
||||
const [ibm] = await fontsService.listFonts();
|
||||
expect(ibm.family).toBe('IBM Plex Sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user-overrides-bundled', () => {
|
||||
test('user folder of the same family wins; weights come from user', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 600, 700]);
|
||||
await makeFamily(userRoot, 'Inter', [400, 900]); // different weights
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.weights).toEqual([400, 900]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('overrides bundled default')
|
||||
);
|
||||
});
|
||||
|
||||
test('user-only family is included', async () => {
|
||||
await makeFamily(userRoot, 'Lobster', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Lobster']);
|
||||
});
|
||||
|
||||
testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => {
|
||||
// Two folder names whose lowercase keys collide. On a case-sensitive
|
||||
// FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a
|
||||
// case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir
|
||||
// resolves to the same directory as the first and the dedup branch
|
||||
// is unreachable from this test setup — see testCaseSensitiveFS above.
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await makeFamily(bundledRoot, 'INTER', [700]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Duplicate family')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta.json — generic fallback', () => {
|
||||
test('valid generic="serif"', async () => {
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const [pd] = await fontsService.listFonts();
|
||||
expect(pd.generic).toBe('serif');
|
||||
});
|
||||
|
||||
test('valid generic="cursive"', async () => {
|
||||
await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' });
|
||||
const [cn] = await fontsService.listFonts();
|
||||
expect(cn.generic).toBe('cursive');
|
||||
});
|
||||
|
||||
test('valid generic="monospace"', async () => {
|
||||
await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' });
|
||||
const [fm] = await fontsService.listFonts();
|
||||
expect(fm.generic).toBe('monospace');
|
||||
});
|
||||
|
||||
test('missing meta.json → defaults to sans-serif (no warning)', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
// No warning for the missing-file case (it's the normal path).
|
||||
const noisy = (logger.warn.mock.calls || []).filter((c) =>
|
||||
String(c[0]).includes('meta.json')
|
||||
);
|
||||
expect(noisy).toEqual([]);
|
||||
});
|
||||
|
||||
test('invalid generic value → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' });
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('invalid generic "bogus"')
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed JSON → defaults to sans-serif, warning logged', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json');
|
||||
const [inter] = await fontsService.listFonts();
|
||||
expect(inter.generic).toBe('sans-serif');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not valid JSON')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('result shape', () => {
|
||||
test('every family is { family, weights, generic }', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400, 700]);
|
||||
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
|
||||
const fonts = await fontsService.listFonts();
|
||||
for (const f of fonts) {
|
||||
expect(f).toEqual({
|
||||
family: expect.any(String),
|
||||
weights: expect.any(Array),
|
||||
generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/)
|
||||
});
|
||||
expect(f.weights.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('output sorted alphabetically by family', async () => {
|
||||
await makeFamily(bundledRoot, 'Zilla-Slab', [400]);
|
||||
await makeFamily(bundledRoot, 'Alpha-Sans', [400]);
|
||||
await makeFamily(bundledRoot, 'Mid-Pack', [400]);
|
||||
const fonts = await fontsService.listFonts();
|
||||
expect(fonts.map((f) => f.family)).toEqual([
|
||||
'Alpha Sans',
|
||||
'Mid Pack',
|
||||
'Zilla Slab'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache', () => {
|
||||
test('cache hit: second call within TTL does not re-readdir', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
const spy = jest.spyOn(fsPromises, 'readdir');
|
||||
await fontsService.listFonts();
|
||||
const callsAfterFirst = spy.mock.calls.length;
|
||||
await fontsService.listFonts();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterFirst);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('clearFontsCache forces a fresh scan on the next call', async () => {
|
||||
await makeFamily(bundledRoot, 'Inter', [400]);
|
||||
await fontsService.listFonts();
|
||||
|
||||
// Add a new family AFTER the cache was populated.
|
||||
await makeFamily(bundledRoot, 'Roboto', [400]);
|
||||
|
||||
// Without clearing, listFonts returns the stale cache.
|
||||
const stale = await fontsService.listFonts();
|
||||
expect(stale.map((f) => f.family)).toEqual(['Inter']);
|
||||
|
||||
// After clear, the new family appears.
|
||||
fontsService.clearFontsCache();
|
||||
const fresh = await fontsService.listFonts();
|
||||
expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
|
||||
* point that runs after a row has been claimed by the background
|
||||
* processor. Mocks every external dependency and validates the
|
||||
* happy-path DB updates and side-effect ordering.
|
||||
*
|
||||
* jest.mock factories are evaluated before any local variables exist,
|
||||
* so collaborators are kept inside the mock factories themselves and
|
||||
* the test reaches into them via require() once they're set up.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const recorded = { whereCalls: [], updateCalls: [] };
|
||||
let pendingWhere = null;
|
||||
const photosState = { row: null };
|
||||
const eventsState = { row: null };
|
||||
|
||||
function makePhotoQuery() {
|
||||
return {
|
||||
where(args) {
|
||||
pendingWhere = args;
|
||||
recorded.whereCalls.push(args);
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return photosState.row;
|
||||
},
|
||||
async update(data) {
|
||||
recorded.updateCalls.push({ where: pendingWhere, data });
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEventsQuery() {
|
||||
return {
|
||||
where() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return eventsState.row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dbFn(table) {
|
||||
if (table === 'photos') return makePhotoQuery();
|
||||
if (table === 'events') return makeEventsQuery();
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
dbFn.client = { config: { client: 'pg' } };
|
||||
|
||||
return {
|
||||
db: dbFn,
|
||||
__setPhoto: (row) => { photosState.row = row; },
|
||||
__setEvent: (row) => { eventsState.row = row; },
|
||||
__reset: () => {
|
||||
recorded.whereCalls = [];
|
||||
recorded.updateCalls = [];
|
||||
photosState.row = null;
|
||||
eventsState.row = null;
|
||||
},
|
||||
__recorded: () => recorded,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => {
|
||||
const mockGenerateThumbnail = jest.fn();
|
||||
const mockExtractCaptureDate = jest.fn();
|
||||
return {
|
||||
generateThumbnail: mockGenerateThumbnail,
|
||||
extractCaptureDate: mockExtractCaptureDate,
|
||||
withLocalCopy: jest.fn(async (key, fn) =>
|
||||
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/videoProcessor', () => ({
|
||||
processUploadedVideo: jest.fn(),
|
||||
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
|
||||
|
||||
jest.mock('../../src/services/photoResolver', () => ({
|
||||
resolvePhotoStorageKey: jest.fn(
|
||||
(event, photo) => `events/active/${event.slug}/${photo.filename}`
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/filenameSanitizer', () => ({
|
||||
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/watermarkGeneratorService', () => ({
|
||||
generateForPhoto: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/webhookService', () => ({
|
||||
fire: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub sharp so we don't actually read any image off disk.
|
||||
jest.mock('sharp', () => {
|
||||
const mock = jest.fn(() => ({
|
||||
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
|
||||
}));
|
||||
return mock;
|
||||
});
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
const imageProcessor = require('../../src/services/imageProcessor');
|
||||
const videoProcessor = require('../../src/services/videoProcessor');
|
||||
const watermarkService = require('../../src/services/watermarkGeneratorService');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
|
||||
beforeEach(() => {
|
||||
dbModule.__reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('photoProcessor.processPhoto', () => {
|
||||
it('marks an image complete with thumbnail and dimensions', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 101,
|
||||
event_id: 5,
|
||||
filename: 'wedding-001.jpg',
|
||||
original_filename: 'IMG_0001.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
media_type: 'image',
|
||||
size_bytes: 12345,
|
||||
captured_at: null,
|
||||
processing_status: 'processing',
|
||||
});
|
||||
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
|
||||
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(101);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.processing_error).toBeNull();
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
|
||||
expect(finalUpdate.data.width).toBe(1920);
|
||||
expect(finalUpdate.data.height).toBe(1080);
|
||||
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
|
||||
|
||||
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
|
||||
expect(webhookService.fire).toHaveBeenCalledWith(
|
||||
'photo.uploaded',
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({ slug: 'wedding' }),
|
||||
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles videos with ffmpeg metadata path', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 202,
|
||||
event_id: 9,
|
||||
filename: 'wedding-video-001.mp4',
|
||||
original_filename: 'movie.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
media_type: 'video',
|
||||
size_bytes: 99999,
|
||||
captured_at: null,
|
||||
});
|
||||
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
|
||||
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
|
||||
metadata: {
|
||||
duration: 12.5,
|
||||
videoCodec: 'h264',
|
||||
audioCodec: 'aac',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
});
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(202);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.duration).toBe(12.5);
|
||||
expect(finalUpdate.data.video_codec).toBe('h264');
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
|
||||
|
||||
// Watermark queue is image-only.
|
||||
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the photo row no longer exists', async () => {
|
||||
dbModule.__setPhoto(null);
|
||||
dbModule.__setEvent({ id: 1 });
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
|
||||
});
|
||||
});
|
||||
|
||||
void path; // referenced indirectly via mocks
|
||||
@@ -1,85 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
|
||||
* so admin-supplied welcome messages can't inject markup into customer mail.
|
||||
*/
|
||||
|
||||
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes the five HTML metacharacters', () => {
|
||||
expect(escapeHtml('& < > " \'')).toBe('& < > " '');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('coerces non-string values', () => {
|
||||
expect(escapeHtml(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('escapes & before introducing new entities', () => {
|
||||
expect(escapeHtml('<&>')).toBe('<&>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nl2br', () => {
|
||||
it('joins non-empty lines with <br />', () => {
|
||||
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('normalises CRLF and CR', () => {
|
||||
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
|
||||
});
|
||||
|
||||
it('drops empty lines', () => {
|
||||
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(nl2br('')).toBe('');
|
||||
expect(nl2br(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWelcomeMessage', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(formatWelcomeMessage('')).toBe('');
|
||||
expect(formatWelcomeMessage(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes HTML metacharacters before nl2br', () => {
|
||||
expect(formatWelcomeMessage('Hello <b>world</b>'))
|
||||
.toBe('Hello <b>world</b>');
|
||||
});
|
||||
|
||||
it('renders newlines as <br /> while keeping content escaped', () => {
|
||||
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
|
||||
.toBe('line 1<br /><script>x</script><br />line 3');
|
||||
});
|
||||
|
||||
it('escapes ampersands and quotes that would otherwise break HTML', () => {
|
||||
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
|
||||
.toBe('Tom & Jerry's "show"');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"generic": "cursive"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,114 +0,0 @@
|
||||
This directory bundles the following typefaces, each licensed under the
|
||||
SIL Open Font License v1.1.
|
||||
------------------------------------------------------------
|
||||
Per-font copyright notices
|
||||
------------------------------------------------------------
|
||||
Inter
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Noto Sans
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
Poppins
|
||||
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||
Jost
|
||||
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type/Jost)
|
||||
Montserrat
|
||||
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
|
||||
Playfair Display
|
||||
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair)
|
||||
IBM Plex Sans
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
Comic Neue
|
||||
Copyright (c) 2014 by Craig Rozynski. All rights reserved.
|
||||
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"generic": "serif"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
+25
-102
@@ -3,54 +3,6 @@ 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',
|
||||
@@ -61,7 +13,7 @@ const config = {
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
migrations: {
|
||||
@@ -72,65 +24,36 @@ 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',
|
||||
// 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,
|
||||
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: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
},
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
acquireConnectionTimeout: 60000
|
||||
}
|
||||
};
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = config[env] || config.development;
|
||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||
@@ -0,0 +1,101 @@
|
||||
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 };
|
||||
@@ -0,0 +1,28 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
// Initialize counters based on existing photos
|
||||
const categories = await knex('photo_categories').select('id');
|
||||
|
||||
for (const category of categories) {
|
||||
const photoCount = await knex('photos')
|
||||
.where('category_id', category.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount && photoCount.count > 0) {
|
||||
await knex('photo_categories')
|
||||
.where('id', category.id)
|
||||
.update({ photo_counter: photoCount.count });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.dropColumn('photo_counter');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language-specific columns to email_templates
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
// Add English versions (rename existing columns for consistency)
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
|
||||
// Add German versions
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
|
||||
// Copy existing values to German columns as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
// Remove German columns
|
||||
table.dropColumn('subject_de');
|
||||
table.dropColumn('body_html_de');
|
||||
table.dropColumn('body_text_de');
|
||||
|
||||
// Rename columns back
|
||||
table.renameColumn('subject_en', 'subject');
|
||||
table.renameColumn('body_html_en', 'body_html');
|
||||
table.renameColumn('body_text_en', 'body_text');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
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
-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,19 @@
|
||||
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');
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
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');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
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');
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user