Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+1
-32
@@ -5,10 +5,7 @@
|
||||
.env.*
|
||||
docker-compose*.yml
|
||||
.DS_Store
|
||||
# **/ so backend/node_modules and frontend/node_modules are excluded too —
|
||||
# the root-context Dockerfile.aio COPYs those directories and must get its
|
||||
# deps from its builder stages, never from the host checkout.
|
||||
**/node_modules
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
.nyc_output
|
||||
@@ -21,31 +18,3 @@ storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
|
||||
# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this
|
||||
# file — backend/.dockerignore is never consulted — so the unprefixed rules
|
||||
# above miss backend/data, backend/logs and backend/storage. A checkout that has
|
||||
# been used to run PicPeak would otherwise bake its database, photos, logs and
|
||||
# SETUP_TOKEN into a published image layer.
|
||||
# backend/data wholesale, not a suffix list. It holds only runtime state and is
|
||||
# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real
|
||||
# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside
|
||||
# the database, plus -journal files and any DATABASE_PATH that does not end in
|
||||
# .db. Any of those in a published layer is a credential leak.
|
||||
backend/data
|
||||
backend/logs
|
||||
backend/storage
|
||||
|
||||
# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db`
|
||||
# rules above are unanchored only in appearance — Docker matches them against the
|
||||
# path from the build context, so they catch `./.env` and never `backend/.env`.
|
||||
# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET,
|
||||
# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at
|
||||
# /app/.env in the published layer. Match at any depth instead, the way
|
||||
# **/node_modules above already does.
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/*.db
|
||||
**/*.db-journal
|
||||
**/*.sqlite*
|
||||
frontend/dist
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
steps:
|
||||
# Build Backend Release
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# Build Frontend Release
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
- feature/*
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- tag
|
||||
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
steps:
|
||||
# Frontend Tests
|
||||
- name: frontend-test
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm ci --legacy-peer-deps
|
||||
- npm run lint
|
||||
- npm run build
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
# Backend Tests
|
||||
- name: backend-test
|
||||
image: node:18-alpine
|
||||
commands:
|
||||
- cd backend
|
||||
- npm ci
|
||||
- npm run lint
|
||||
- npm test
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_TAG}
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend
|
||||
registry: registry.local.nothaft.cloud
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_TAG}
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend
|
||||
registry: registry.local.nothaft.cloud
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Security Scan
|
||||
- name: security-scan
|
||||
image: aquasec/trivy:latest
|
||||
commands:
|
||||
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-frontend:${DRONE_COMMIT_SHA:0:8}
|
||||
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-backend:${DRONE_COMMIT_SHA:0:8}
|
||||
environment:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
volumes:
|
||||
- name: docker
|
||||
path: /var/run/docker.sock
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
|
||||
# Deploy to Staging
|
||||
- name: deploy-staging
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: staging_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: staging_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: staging_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
VERSION: ${DRONE_COMMIT_SHA:0:8}
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=$VERSION
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing
|
||||
EOF
|
||||
when:
|
||||
branch:
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
|
||||
# Deploy to Production
|
||||
- name: deploy-production
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: prod_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: prod_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: prod_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
VERSION: ${DRONE_TAG:-latest}
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=$VERSION
|
||||
|
||||
# Backup database before deployment
|
||||
docker exec \$(docker ps -q -f name=wedding-photo-sharing_db) pg_dump -U postgres wedding_photo_sharing > /backup/db-backup-\$(date +%Y%m%d-%H%M%S).sql
|
||||
|
||||
# Deploy stack
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
|
||||
|
||||
# Wait for services to be ready
|
||||
sleep 30
|
||||
|
||||
# Run migrations if needed
|
||||
docker exec \$(docker ps -q -f name=wedding-photo-sharing_backend) npm run migrate
|
||||
EOF
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
# Health Check
|
||||
- name: health-check
|
||||
image: alpine:latest
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sleep 30
|
||||
- curl -f https://${FRONTEND_HOST}/health || exit 1
|
||||
- curl -f https://${BACKEND_HOST}/api/health || exit 1
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
# Notification - Success
|
||||
- name: notify-success
|
||||
image: plugins/slack
|
||||
settings:
|
||||
webhook:
|
||||
from_secret: slack_webhook
|
||||
channel: deployments
|
||||
template: |
|
||||
✅ *Build {{build.number}} succeeded* for {{repo.name}}
|
||||
|
||||
Branch: {{build.branch}}
|
||||
Commit: {{build.commit}}
|
||||
Author: {{build.author}}
|
||||
|
||||
{{#if build.tag}}
|
||||
🏷️ Tag: {{build.tag}}
|
||||
🚀 Deployed to *PRODUCTION*
|
||||
{{else}}
|
||||
📦 Deployed to *{{build.branch}}*
|
||||
{{/if}}
|
||||
|
||||
🔗 {{build.link}}
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
|
||||
# Notification - Failure
|
||||
- name: notify-failure
|
||||
image: plugins/slack
|
||||
settings:
|
||||
webhook:
|
||||
from_secret: slack_webhook
|
||||
channel: deployments
|
||||
template: |
|
||||
❌ *Build {{build.number}} failed* for {{repo.name}}
|
||||
|
||||
Branch: {{build.branch}}
|
||||
Commit: {{build.commit}}
|
||||
Author: {{build.author}}
|
||||
|
||||
🔗 {{build.link}}
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: rollback
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- rollback
|
||||
|
||||
steps:
|
||||
- name: rollback-production
|
||||
image: alpine:latest
|
||||
environment:
|
||||
SWARM_HOST:
|
||||
from_secret: prod_swarm_host
|
||||
SWARM_USER:
|
||||
from_secret: prod_swarm_user
|
||||
SWARM_KEY:
|
||||
from_secret: prod_swarm_key
|
||||
REGISTRY_URL:
|
||||
from_secret: docker_registry
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
|
||||
- |
|
||||
ssh $SWARM_USER@$SWARM_HOST << EOF
|
||||
cd /opt/wedding-photo-sharing
|
||||
export REGISTRY_URL=registry.local.nothaft.cloud
|
||||
export VERSION=${DRONE_ROLLBACK_TO}
|
||||
|
||||
# Deploy previous version
|
||||
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
|
||||
EOF
|
||||
|
||||
---
|
||||
kind: secret
|
||||
name: slack_webhook
|
||||
get:
|
||||
path: drone/slack
|
||||
name: webhook
|
||||
+28
-360
@@ -1,366 +1,34 @@
|
||||
# PicPeak Environment Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
# Environment Configuration Template
|
||||
# Copy this file to .env and adjust values for your environment
|
||||
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
# Development: Use docker-compose.dev.yml
|
||||
# Production: Use docker-compose.prod.yml with .env.production.example
|
||||
|
||||
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker: the secrets-init service writes it to a private volume and reuses it
|
||||
# across restarts). Set it explicitly only to pin your own value.
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# JWT Secret (CRITICAL for production)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# How long a gallery guest stays recognised (#1210). Default 30d. It was 24h,
|
||||
# which meant a client reviewing a gallery across two weekends registered again
|
||||
# in between — and each re-registration is a separate guest whose likes and
|
||||
# favourites no longer join up with the first visit's. Takes any jsonwebtoken
|
||||
# duration ('7d', '12h'); shorten it if your galleries hold sensitive work.
|
||||
#GUEST_TOKEN_TTL=30d
|
||||
# Application URLs
|
||||
ADMIN_URL=http://localhost:3005
|
||||
FRONTEND_URL=http://localhost:3005
|
||||
|
||||
# OIDC SSO for admins (#798) — configured in the admin UI; only these two
|
||||
# values live in the environment:
|
||||
# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET).
|
||||
#OIDC_ENCRYPTION_KEY=
|
||||
# Break-glass: 'true' re-enables local password login even while the SSO
|
||||
# settings disable it (recovery when the IdP is down or misconfigured).
|
||||
#OIDC_BREAK_GLASS=false
|
||||
# Database Configuration
|
||||
# SQLite is used for development by default
|
||||
# For production PostgreSQL config, see .env.production.example
|
||||
DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=./data/photo_sharing.db
|
||||
|
||||
# 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
|
||||
# Email Configuration
|
||||
# Development: Uses Mailhog (included in docker-compose.dev.yml)
|
||||
# Production: Configure real SMTP server
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM=noreply@localhost
|
||||
|
||||
# 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
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
||||
# 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
|
||||
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
#REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# written to data/SETUP_TOKEN with mode 0600 — read it with
|
||||
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
|
||||
# unless that write fails, so it never sits in `docker logs`.
|
||||
# The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume,
|
||||
# in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD
|
||||
# below instead: it needs no file at all.
|
||||
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
||||
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
||||
#ADMIN_USERNAME=admin
|
||||
#ADMIN_EMAIL=admin@yourdomain.com
|
||||
#ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration — OPTIONAL, and normally left alone.
|
||||
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
|
||||
# database (email_configs); that is what the mail queue actually sends with.
|
||||
# These variables are a legacy path kept for config-as-code deployments: when
|
||||
# SMTP_HOST is set, the initial migration seeds the database row from it.
|
||||
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
|
||||
# that seed points at the mailhog container.
|
||||
# For Gmail: use app-specific password
|
||||
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||
#SMTP_HOST=smtp.gmail.com
|
||||
#SMTP_PORT=587
|
||||
#SMTP_SECURE=false
|
||||
#SMTP_USER=your-email@gmail.com
|
||||
#SMTP_PASS=your-app-specific-password
|
||||
#EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
|
||||
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
|
||||
# each composed message as JSON to that URL instead; something downstream
|
||||
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
|
||||
# you cannot get working — app passwords, blocked ports, a NAS with no
|
||||
# outbound 25.
|
||||
#
|
||||
# Deliberately environment-only, not an admin setting: it redirects every
|
||||
# outbound message including password resets, so it should not be changeable
|
||||
# from a compromised admin session.
|
||||
#
|
||||
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
|
||||
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
|
||||
# webhooks, so a receiver verifies both the same way. Set the URL without a
|
||||
# secret and the transport stays OFF and says so in the log, rather than
|
||||
# posting unauthenticated mail to the internet.
|
||||
#
|
||||
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
|
||||
# attachment is { filename, content_type, content_base64 }. Attachments are
|
||||
# included rather than dropped; a message whose attachments exceed 10 MB fails
|
||||
# and stays in the queue instead of arriving without its invoice.
|
||||
#
|
||||
# The receiver must be a public https:// address unless you opt in — a container or LAN
|
||||
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
|
||||
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
|
||||
#
|
||||
# Webhook and email-webhook deliveries connect to the DNS answer they just
|
||||
# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress
|
||||
# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy
|
||||
# without pinning.
|
||||
#
|
||||
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
|
||||
# sending through it; this replaces the global transport only.
|
||||
#
|
||||
# Set EMAIL_FROM above as well. A webhook-only install never gets an
|
||||
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
|
||||
# is where the sender address comes from.
|
||||
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
|
||||
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
|
||||
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
|
||||
|
||||
# Application URLs — OPTIONAL. Leave unset for the normal install.
|
||||
# The public origin is captured by the setup wizard (it proposes the address
|
||||
# you opened the browser at) and stored as the `general_site_url` setting, so
|
||||
# you can change it later in Settings -> General without touching this file.
|
||||
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
|
||||
# read-only in the admin UI - use it only for config-as-code deployments.
|
||||
# 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
|
||||
|
||||
# Static HTML title + description used for social link previews when the
|
||||
# fetcher doesn't trigger the per-event OG endpoint — most notably the
|
||||
# WhatsApp Business API and various 3rd-party preview-service caches
|
||||
# (#521). Set these to your brand so link previews aren't generic.
|
||||
# Substituted into index.html at frontend-container start, so changes
|
||||
# take effect on the next `docker compose up -d frontend` — no rebuild
|
||||
# required.
|
||||
BRAND_TITLE=PicPeak
|
||||
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# OPTIONAL: when unset this is derived from the resolved public origin + /api,
|
||||
# so the wizard's answer covers it. Set it only for split-origin deployments
|
||||
# where the API lives on a different host than the gallery.
|
||||
#API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
# FRONTEND_PORT=3000
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# File watcher (watch-folder auto-import, local storage only)
|
||||
# Max photos processed in parallel — raise on hosts with memory headroom,
|
||||
# lower to 1 on very small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Face recognition — "People in this gallery" (#1074, optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Requires the optional picpeak-ml sidecar container:
|
||||
# docker compose --profile faces up -d
|
||||
#
|
||||
# NONE of these variables do anything until the `faces` feature flag is
|
||||
# enabled in Admin → Settings, AND the per-event "Detect people in this
|
||||
# gallery" toggle is switched on. Both default to OFF. With the flag off the
|
||||
# backend never contacts the sidecar, so leaving these at their defaults on an
|
||||
# install without the container is completely inert.
|
||||
#
|
||||
# Face embeddings are biometric data (GDPR Art. 9 special category in the EU).
|
||||
# The photographer is the controller and needs a lawful basis for the people
|
||||
# in their photos — read https://docs.picpeak.app/features/face-recognition
|
||||
# before enabling.
|
||||
#
|
||||
# NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets
|
||||
# PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face
|
||||
# recognition there regardless of these variables or the feature flag: that
|
||||
# image runs the backend, frontend, database and every worker in one
|
||||
# container, with no ML sidecar to talk to, and face detection would compete
|
||||
# with image processing for the same CPU and memory. Use the standard
|
||||
# multi-container deployment if you want this feature.
|
||||
#
|
||||
# FACE_ML_TOKEN (no default — REQUIRED to run the sidecar)
|
||||
# Shared secret between the backend and the sidecar. The sidecar refuses to
|
||||
# start without it rather than serving anonymously, so an accidentally
|
||||
# published port is never a free face-detection API. Generate with:
|
||||
# openssl rand -hex 32
|
||||
# FACE_ML_TOKEN=
|
||||
#
|
||||
# FACE_ML_URL (default: http://picpeak-ml:8000)
|
||||
# Defaults to the sidecar's compose service name, so the standard
|
||||
# deployment needs no configuration here. Only change it if you run the
|
||||
# sidecar outside the default compose network.
|
||||
# FACE_ML_URL=http://picpeak-ml:8000
|
||||
#
|
||||
# FACE_PROCESSOR_CONCURRENCY (default: 1)
|
||||
# Face-detection workers in the backend. Defaults to 1 deliberately: face
|
||||
# scanning shares a host with Sharp image processing, which is the real
|
||||
# memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts
|
||||
# with headroom to spare.
|
||||
# FACE_PROCESSOR_CONCURRENCY=1
|
||||
#
|
||||
# FACE_ORT_THREADS (default: 1)
|
||||
# ONNX Runtime threads inside the sidecar. More threads mean faster
|
||||
# per-photo inference and higher RSS.
|
||||
# FACE_ORT_THREADS=1
|
||||
|
||||
# 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.
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||
# Keep this value stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
|
||||
# Graceful shutdown budget in milliseconds. On SIGTERM the server stops
|
||||
# accepting requests, drains workers and closes the pool; whatever is still
|
||||
# running after this long is abandoned so the process exits before Docker's
|
||||
# 10 s stop grace period (raise stop_grace_period together with this value).
|
||||
#SHUTDOWN_TIMEOUT_MS=8000
|
||||
# Optional: Umami Analytics
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_HASH_SALT=
|
||||
@@ -0,0 +1,32 @@
|
||||
# Production Environment Configuration Template
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Application URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Security - CRITICAL: Generate a secure random JWT secret
|
||||
# You can generate one with: openssl rand -base64 32
|
||||
JWT_SECRET=your-secure-random-jwt-secret-here
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=your-random-hash-salt
|
||||
|
||||
# First Admin User (for initial setup)
|
||||
# Run: docker-compose exec backend node scripts/create-admin.js --email admin@yourdomain.com
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
@@ -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,88 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Bump version
|
||||
id: version
|
||||
run: |
|
||||
# Get current version from backend package.json
|
||||
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update version in package.json files
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
git push
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
needs: version-bump
|
||||
if: needs.version-bump.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve PicPeak
|
||||
title: '[BUG] '
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS and version:
|
||||
- Browser and version:
|
||||
- PicPeak version and Docker image tag (if applicable):
|
||||
- Deployment method: [Docker Compose, all-in-one container, manual]
|
||||
- Database and version: [PostgreSQL, SQLite]
|
||||
|
||||
**Logs**
|
||||
Please include relevant logs:
|
||||
```
|
||||
# Backend logs (Docker Compose)
|
||||
docker compose logs --tail=50 backend
|
||||
|
||||
# Or all-in-one container logs (replace picpeak if your container has another name)
|
||||
docker logs --tail=50 picpeak
|
||||
|
||||
# Frontend console errors
|
||||
[paste any browser console errors]
|
||||
```
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible Solution**
|
||||
If you have an idea how to fix the issue, please describe it here.
|
||||
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://docs.picpeak.app
|
||||
about: Installation, configuration and feature guides
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/PicPeak/picpeak/discussions
|
||||
about: Ask questions and discuss with the community
|
||||
- name: 🔒 Security Issues
|
||||
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
|
||||
about: Please review our security policy for reporting vulnerabilities
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: Documentation
|
||||
about: Report issues or improvements needed in documentation
|
||||
title: '[DOCS] '
|
||||
labels: 'documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What documentation needs improvement?**
|
||||
Please specify which document or section needs attention:
|
||||
- [ ] Documentation website (https://docs.picpeak.app)
|
||||
- [ ] README.md
|
||||
- [ ] DEPLOYMENT.md
|
||||
- [ ] CONTRIBUTING.md
|
||||
- [ ] API Documentation
|
||||
- [ ] Code Comments
|
||||
- [ ] Other: ___________
|
||||
|
||||
Link to the affected page or file:
|
||||
|
||||
**Describe the issue**
|
||||
What's wrong or missing in the documentation?
|
||||
|
||||
**Suggested improvement**
|
||||
How would you improve this documentation?
|
||||
|
||||
**Target audience**
|
||||
Who is this documentation for?
|
||||
- [ ] New users setting up PicPeak
|
||||
- [ ] Developers contributing to the project
|
||||
- [ ] System administrators
|
||||
- [ ] End users (photographers/clients)
|
||||
|
||||
**Additional context**
|
||||
Add any other context, examples, or references here.
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for PicPeak
|
||||
title: '[FEATURE] '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use Case**
|
||||
Please describe how this feature would be used:
|
||||
- Who would use it? (photographers, clients, admins)
|
||||
- When would they use it?
|
||||
- Why is it important?
|
||||
|
||||
**Similar Features**
|
||||
Are there similar features in:
|
||||
- PicDrop
|
||||
- Scrapbook.de
|
||||
- Other photo sharing platforms
|
||||
|
||||
**Mockups or Examples**
|
||||
If applicable, add mockups, diagrams, or links to similar implementations.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Implementation Ideas**
|
||||
If you have technical ideas about how this could be implemented, please share them.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question about PicPeak
|
||||
title: '[QUESTION] '
|
||||
labels: 'question'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Question**
|
||||
What would you like to know about PicPeak?
|
||||
|
||||
**Context**
|
||||
Please provide context to help us answer your question better:
|
||||
- What are you trying to achieve?
|
||||
- What have you already tried?
|
||||
- Which documentation have you consulted?
|
||||
|
||||
**Environment**
|
||||
If relevant to your question:
|
||||
- PicPeak Version:
|
||||
- Deployment Method:
|
||||
- Operating System:
|
||||
|
||||
**Related Issues or Discussions**
|
||||
Link to any related issues, discussions, or documentation.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Security Vulnerability
|
||||
about: Report security issues privately
|
||||
title: '[SECURITY] '
|
||||
labels: 'security'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
**Type of Security Issue**
|
||||
- [ ] Authentication/Authorization
|
||||
- [ ] Data Exposure
|
||||
- [ ] Input Validation
|
||||
- [ ] Configuration Issue
|
||||
- [ ] Dependency Vulnerability
|
||||
- [ ] Other: ___________
|
||||
|
||||
**Description**
|
||||
Brief description of the security concern.
|
||||
|
||||
**Impact**
|
||||
What could an attacker potentially do?
|
||||
|
||||
**Steps to Reproduce**
|
||||
If applicable, how can this be reproduced?
|
||||
|
||||
**Suggested Fix**
|
||||
If you have ideas on how to fix this issue.
|
||||
|
||||
**References**
|
||||
Any relevant security advisories, CVEs, or documentation.
|
||||
@@ -1,47 +0,0 @@
|
||||
# picpeak — All-in-one
|
||||
|
||||
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**, with an optional CRM / accounting suite. This image is the **all-in-one** build: the backend, the built web UI and SQLite in **one container, one process** — no compose file, no separate database, no reverse proxy to wire up.
|
||||
|
||||
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
|
||||
- 🧩 **Multi-container images:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
|
||||
|
||||
## Supported tags
|
||||
- `latest` / `stable` — latest stable release
|
||||
- `x.y.z` — a pinned release (**recommended for production**)
|
||||
- `beta` / `main` — latest build from `main` (may be unstable)
|
||||
- **Architectures:** `linux/amd64`, `linux/arm64` (x86 and ARM NAS)
|
||||
|
||||
## Quick start
|
||||
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
-e JWT_SECRET="$(openssl rand -base64 48)" \
|
||||
picpeak/aio:stable
|
||||
|
||||
Then open **http://localhost:3000/admin** and complete the setup wizard. Read the one-time setup token with:
|
||||
|
||||
docker exec picpeak cat /data/db/SETUP_TOKEN
|
||||
|
||||
> 🔗 Share links need to know your address. The image defaults `FRONTEND_URL` to `http://localhost:3000`; pass `-e FRONTEND_URL=https://photos.example.com` (or set the site URL in Settings) before you send a gallery to a client.
|
||||
|
||||
## Ports & volumes
|
||||
- Container port **3000** (HTTP; put your own TLS terminator in front for public use).
|
||||
- **One volume: `/data`** — back it up and you have backed up the install.
|
||||
- `/data/db` — `picpeak.db` and `SETUP_TOKEN`
|
||||
- `/data/storage` — originals, thumbnails, archives
|
||||
- `/data/logs`, `/data/backup`
|
||||
|
||||
## External Postgres
|
||||
SQLite is this image's default, not its only option. Point it at an existing database exactly like the backend image:
|
||||
|
||||
-e DATABASE_CLIENT=pg -e DB_HOST=… -e DB_USER=… -e DB_PASSWORD=…
|
||||
|
||||
## How it differs from the compose stack
|
||||
- **SQLite takes one writer at a time** — right for a home server, a NAS or a single studio; the compose stack with PostgreSQL is what scales.
|
||||
- **No Redis** — background jobs run in-process.
|
||||
- **Face recognition is unavailable** here. It needs the separate [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) sidecar, and a second image-processing pipeline competing with thumbnailing for one container's CPU would just make the install slow. Run the multi-container deployment for that feature.
|
||||
|
||||
You can move to the full stack later without reinstalling: take a `.picpeak` backup and restore it there.
|
||||
|
||||
## Docs
|
||||
Volume layout, the external-Postgres variant, TLS, updates and the limits: **https://docs.picpeak.app/deployment/single-container**
|
||||
@@ -1,56 +0,0 @@
|
||||
# picpeak — ML sidecar (face detection)
|
||||
|
||||
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
|
||||
|
||||
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
|
||||
|
||||
If you don't run this container, the feature does not exist.
|
||||
|
||||
- 📦 **Source, docs & issues:** https://github.com/PicPeak/picpeak
|
||||
- 🧩 **Runs with:** [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) + [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend)
|
||||
|
||||
## Supported tags
|
||||
- `latest` / `stable` — latest stable release
|
||||
- `x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
|
||||
- `beta` / `main` — latest build from `main` (may be unstable)
|
||||
- **Architectures:** `linux/amd64`, `linux/arm64`
|
||||
|
||||
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
|
||||
|
||||
## Turning it on
|
||||
The maintained compose file already contains this service behind a profile — you do not write it by hand:
|
||||
|
||||
docker compose --profile faces up -d
|
||||
|
||||
Then two deliberate actions in the app, neither of which is installing this container:
|
||||
|
||||
1. Enable the **`faces`** feature flag in admin settings.
|
||||
2. Enable **"Detect people in this gallery"** per event.
|
||||
|
||||
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
|
||||
|
||||
## Configuration
|
||||
| | |
|
||||
|---|---|
|
||||
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
|
||||
| `FACE_ORT_THREADS` | ONNX Runtime threads (default `1`). |
|
||||
|
||||
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
|
||||
|
||||
## API
|
||||
All endpoints except `/health` require the `X-Face-ML-Token` header.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
|
||||
| `GET /info` | `{detector, embedder, model_version, dim}` |
|
||||
| `POST /faces` | multipart `image` → `{model_version, faces: [...]}` |
|
||||
|
||||
## Models
|
||||
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
|
||||
|
||||
## Not available on the all-in-one image
|
||||
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
|
||||
|
||||
## Docs
|
||||
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
|
||||
@@ -1,49 +0,0 @@
|
||||
## Description
|
||||
|
||||
Please include a summary of the changes and which issue is fixed. Include relevant motivation and context.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
|
||||
|
||||
- [ ] Unit tests pass (`npm test`)
|
||||
- [ ] Manual testing completed
|
||||
- [ ] Tested on Docker deployment
|
||||
- [ ] Tested on production-like environment
|
||||
|
||||
**Test Configuration**:
|
||||
* PicPeak Version:
|
||||
* Node.js Version:
|
||||
* Database: PostgreSQL / SQLite
|
||||
* Browser:
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
- [ ] I have updated the CHANGELOG.md file
|
||||
|
||||
## Screenshots (if appropriate):
|
||||
|
||||
## Additional Notes:
|
||||
|
||||
Add any additional notes, concerns, or discussion points here.
|
||||
@@ -1,221 +0,0 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
|
||||
|
||||
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
|
||||
|
||||
## 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
|
||||
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
|
||||
|
||||
## 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/picpeak/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/picpeak/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
|
||||
|
||||
# The same images on Docker Hub (identical tags, identical digests)
|
||||
docker pull picpeak/backend:latest
|
||||
docker pull picpeak/aio:stable
|
||||
docker pull picpeak/ml:stable
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/picpeak/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/picpeak/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/picpeak/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/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/orgs/PicPeak/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,70 +0,0 @@
|
||||
name: Bypass size gate
|
||||
|
||||
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
|
||||
# self-merge without a maintainer review. The branch-protection bypass list
|
||||
# alone is binary — once a user is on it they can merge anything without
|
||||
# review. This workflow reports a REQUIRED status check that fails when a
|
||||
# bypass user's PR exceeds the configured size threshold, which blocks the
|
||||
# merge even with bypass enabled. Other contributors are unaffected (the
|
||||
# check reports success for them so the required-check gate doesn't trip).
|
||||
#
|
||||
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
|
||||
#
|
||||
# Trigger note: uses `pull_request_target` so the workflow has the elevated
|
||||
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
|
||||
# checks). The script never executes code FROM the PR — it only reads
|
||||
# metadata via the API — so this is safe against fork-PR attacks.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
size-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Compute PR size and report check status
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// Tune these two constants if the policy shifts.
|
||||
const LINE_LIMIT = 300;
|
||||
const BYPASS_USERS = ['Luca-Timo'];
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const author = pr.user.login;
|
||||
const linesChanged = pr.additions + pr.deletions;
|
||||
const filesChanged = pr.changed_files;
|
||||
|
||||
let conclusion, title, summary;
|
||||
|
||||
if (!BYPASS_USERS.includes(author)) {
|
||||
// Not a bypass user — this gate doesn't apply to them. They
|
||||
// go through normal review. Report success so the required
|
||||
// check doesn't block their merge.
|
||||
conclusion = 'success';
|
||||
title = 'Not applicable';
|
||||
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
|
||||
} else if (linesChanged <= LINE_LIMIT) {
|
||||
conclusion = 'success';
|
||||
title = `OK — within bypass limit (${linesChanged} lines)`;
|
||||
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
|
||||
} else {
|
||||
conclusion = 'failure';
|
||||
title = `Too large for bypass (${linesChanged} lines)`;
|
||||
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
|
||||
}
|
||||
|
||||
await github.rest.checks.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'bypass-size-gate',
|
||||
head_sha: pr.head.sha,
|
||||
status: 'completed',
|
||||
conclusion,
|
||||
output: { title, summary }
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,221 +0,0 @@
|
||||
name: Fresh-install smoke
|
||||
|
||||
# Verifies that a clean Postgres install boots cleanly under the same
|
||||
# conditions a new user hits on their first `docker compose up -d`. The
|
||||
# specific scenarios this guards against — see #484 for the original
|
||||
# reproduction:
|
||||
#
|
||||
# 1. Bind-mounted host directories owned by a UID other than 1001
|
||||
# (the container's nodejs user). The entrypoint must self-chown
|
||||
# and drop privileges via su-exec.
|
||||
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
|
||||
# in #494, the index/created_at error fixed in #511, and any
|
||||
# future migration-order issue that only surfaces on an empty DB).
|
||||
#
|
||||
# Triggers only on changes that touch the install path so unrelated PRs
|
||||
# don't pay the build cost.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
|
||||
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
|
||||
# required check "missing" forever and block the merge. Better to
|
||||
# pay the boot cost on every PR than maintain a per-path allowlist
|
||||
# that drifts as the install surface evolves. (Branches also updated
|
||||
# post-#669 rename: beta → main, old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
fresh-install:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Build for the runner's arch only — we just need a runnable image.
|
||||
# The full multi-arch build is the docker-build workflow's job.
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
load: true
|
||||
tags: picpeak-backend:smoke
|
||||
cache-from: type=gha,scope=install-smoke
|
||||
# ignore-error: a flaky GHA cache write must not fail the build.
|
||||
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create picpeak-smoke
|
||||
|
||||
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
|
||||
# common mismatch case on Linux hosts). The entrypoint must chown
|
||||
# this to 1001 itself — that's the regression we're guarding.
|
||||
- name: Prepare host bind-mount dirs owned by UID 1000
|
||||
run: |
|
||||
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
|
||||
chmod 755 smoke-mounts smoke-mounts/*
|
||||
ls -ld smoke-mounts/*
|
||||
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
|
||||
-e POSTGRES_USER=picpeak \
|
||||
-e POSTGRES_PASSWORD=smokepass \
|
||||
-e POSTGRES_DB=picpeak_prod \
|
||||
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
|
||||
--health-interval=2s --health-timeout=2s --health-retries=30 \
|
||||
postgres:15-alpine
|
||||
|
||||
- name: Wait for Postgres healthy
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "postgres healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "postgres did not become healthy in 60s"
|
||||
docker logs picpeak-smoke-pg
|
||||
exit 1
|
||||
|
||||
- name: Start backend with mismatched-UID bind mounts (fresh install)
|
||||
run: |
|
||||
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
|
||||
-e NODE_ENV=production \
|
||||
-e JWT_SECRET=smoketestsecretvalueof32characters \
|
||||
-e DB_HOST=picpeak-smoke-pg \
|
||||
-e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass \
|
||||
-e DB_NAME=picpeak_prod \
|
||||
-e ADMIN_EMAIL=admin@smoke.local \
|
||||
-e ADMIN_PASSWORD=smokeAdminPass12345 \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke
|
||||
|
||||
- name: Wait for backend healthy
|
||||
run: |
|
||||
for i in $(seq 1 120); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
|
||||
if [ "$status" = "exited" ]; then
|
||||
echo "FAIL: backend exited during cold-start (restart loop scenario)"
|
||||
docker logs picpeak-smoke-bk
|
||||
echo "--- error.log ---"
|
||||
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$health" = "healthy" ]; then
|
||||
echo "backend healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "FAIL: backend did not become healthy in 120s"
|
||||
docker ps -a
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
|
||||
- name: Verify chown happened (container view)
|
||||
run: |
|
||||
# All three dirs should now be owned by nodejs (UID 1001).
|
||||
# If the entrypoint's self-chown branch didn't fire, they'd
|
||||
# still be owned by the runner UID and node would have hit
|
||||
# EACCES creating storage subdirs.
|
||||
for d in /app/storage /app/data /app/logs; do
|
||||
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
|
||||
if [ "$owner_uid" != "1001" ]; then
|
||||
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: $d owned by UID $owner_uid"
|
||||
done
|
||||
|
||||
- name: Verify app is actually serving
|
||||
run: |
|
||||
# /health is what docker's HEALTHCHECK polls, but hit it
|
||||
# directly to confirm the response shape matches what the
|
||||
# frontend + reverse proxy expect.
|
||||
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
|
||||
echo "/health => $body"
|
||||
echo "$body" | grep -q '"status":"ok"' || {
|
||||
echo "FAIL: /health did not return status:ok"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Verify node runs as nodejs (not root)
|
||||
run: |
|
||||
# dumb-init runs as root (PID 1), node must be running as
|
||||
# nodejs (UID 1001) — if su-exec drop didn't happen the app
|
||||
# would be running as root which is the security regression
|
||||
# we're guarding against. Alpine ships BusyBox ps, which
|
||||
# doesn't support `-p PID` or pgrep, so list + awk instead.
|
||||
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
|
||||
if [ "$user" != "nodejs" ]; then
|
||||
echo "FAIL: node running as '$user' (expected nodejs)"
|
||||
docker exec picpeak-smoke-bk ps -o pid,user,comm
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: node running as $user"
|
||||
|
||||
- name: Verify no restart loop
|
||||
run: |
|
||||
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
|
||||
if [ "$restart_count" -gt 0 ]; then
|
||||
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
|
||||
docker logs picpeak-smoke-bk
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: 0 restarts"
|
||||
|
||||
# Restart with `--user 5005:5005` (no root, can't chown) against
|
||||
# bind mounts owned by 1000 — entrypoint must fail loud with the
|
||||
# actionable preflight error, not silently restart-loop.
|
||||
- name: Verify preflight fails loud on unwritable mounts
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
|
||||
set +e
|
||||
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
|
||||
-e NODE_ENV=production -e JWT_SECRET=x \
|
||||
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
|
||||
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
|
||||
-e STORAGE_PATH=/app/storage \
|
||||
-v "$PWD/smoke-mounts/storage:/app/storage" \
|
||||
-v "$PWD/smoke-mounts/data:/app/data" \
|
||||
-v "$PWD/smoke-mounts/logs:/app/logs" \
|
||||
picpeak-backend:smoke 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
echo "$out"
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "FAIL: preflight should have exited non-zero"
|
||||
exit 1
|
||||
fi
|
||||
echo "$out" | grep -q "is not writable by UID 5005" || {
|
||||
echo "FAIL: preflight error message missing or wrong"
|
||||
exit 1
|
||||
}
|
||||
echo "ok: preflight failed loud with actionable error"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
|
||||
docker network rm picpeak-smoke 2>/dev/null || true
|
||||
@@ -1,36 +0,0 @@
|
||||
name: PR Title Lint
|
||||
|
||||
# Release Please derives version bumps and the changelog from Conventional
|
||||
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
|
||||
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
|
||||
# without a version bump or a changelog entry. This check fails a PR whose
|
||||
# title is not a valid Conventional Commit so the release stays automated.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR title is a Conventional Commit
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
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.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# A dedicated token (fine-grained PAT) makes the release PR run CI
|
||||
# automatically (no "workflows awaiting approval") and lets it be
|
||||
# merged without a manual review. Falls back to GITHUB_TOKEN so the
|
||||
# workflow still works before the secret is added (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: main
|
||||
|
||||
# Auto-approve + enable auto-merge on the open release PR so betas publish
|
||||
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
|
||||
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
|
||||
# a valid review (requires the org's "Allow GitHub Actions to approve pull
|
||||
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
|
||||
# set: without it the PR is bot-authored and can't be self-approved, so we
|
||||
# skip and leave today's manual flow. Best-effort — never blocks the run.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# This job has no checkout, so gh can't infer the repo from a git
|
||||
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
|
||||
# than the PR author (the PAT) — so it counts as a valid review.
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
# Enable auto-merge as the PAT so the eventual merge commit is
|
||||
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
|
||||
# push is suppressed by recursion prevention and the follow-up run that
|
||||
# cuts the tag/release never fires (#719).
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- 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
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [stable]
|
||||
|
||||
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:
|
||||
# Dedicated token so the release PR runs CI + can auto-merge without a
|
||||
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# NOTE: stable release PRs are intentionally NOT auto-merged here
|
||||
# anymore. Fixes accumulate in the rolling release PR and are cut as
|
||||
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
|
||||
# or on demand via workflow_dispatch / a manual merge of the release
|
||||
# PR). Beta keeps instant releases — see release-please-beta.yml —
|
||||
# because same-day reporter verification depends on it.
|
||||
|
||||
- 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
|
||||
|
||||
# Best-effort "What's New" highlights on the freshly-created release. Runs in
|
||||
# this same workflow run (not a `release:` trigger) because release-please
|
||||
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
|
||||
whatsnew:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write # edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
uses: ./.github/workflows/whatsnew-highlights.yml
|
||||
with:
|
||||
tag: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Cut Stable Release (daily batch)
|
||||
|
||||
# Stable fixes accumulate in release-please's rolling release PR instead of
|
||||
# each cutting its own patch version (the old per-merge auto-merge produced
|
||||
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
|
||||
# stable release PR once a day, so a day of N bugfixes ships as ONE version
|
||||
# with all N changelog entries — and one Docker build instead of N.
|
||||
#
|
||||
# - schedule only fires from the default branch (main); the stable copy of
|
||||
# this file is inert and exists to keep the branches in sync.
|
||||
# - Need a release NOW? Run this via workflow_dispatch, or merge the
|
||||
# release PR by hand — the schedule is a default, not a gate.
|
||||
# - Approval/merge mechanics mirror the old inline step (#719): approve as
|
||||
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
|
||||
# auto-merge as the PAT so the merge attributes to a real identity and
|
||||
# triggers the tag-cutting run. --auto waits for green checks.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
merge-stable-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Approve and enable auto-merge on the open stable release PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout — set the repo explicitly so gh works without a
|
||||
# git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
|
||||
exit 0
|
||||
fi
|
||||
# Strict selection (review P1): this job runs daily even without a
|
||||
# stable push, and `gh pr list --head` matches the branch NAME only
|
||||
# — a fork PR can spoof `release-please--branches--stable`. Pin the
|
||||
# base to stable AND require a same-repo head (isCrossRepository
|
||||
# == false); a fork PR is cross-repository, so it can never be
|
||||
# picked and auto-merged with the privileged PAT.
|
||||
pr=$(gh pr list \
|
||||
--base stable \
|
||||
--head release-please--branches--stable \
|
||||
--state open \
|
||||
--json number,isCrossRepository \
|
||||
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open same-repo stable release PR — nothing to cut today."
|
||||
exit 0
|
||||
fi
|
||||
# Approve is tolerant — a pre-existing approval already satisfies
|
||||
# branch protection and re-approving can return non-zero.
|
||||
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
|
||||
# But the auto-merge enable is the load-bearing step: this scheduled
|
||||
# job is the ONLY automatic stable cut, so DON'T swallow its failure
|
||||
# (review P2) — an expired/under-scoped PAT would otherwise stop
|
||||
# releases while the workflow stays green.
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
|
||||
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
|
||||
# are already green — the normal case at 18:00, since the fixes
|
||||
# merged hours earlier and CI passed. So success is EITHER the PR is
|
||||
# already merged OR an auto-merge request is now pending; only a PR
|
||||
# that is still open with no auto-merge request is a real failure
|
||||
# (expired/under-scoped PAT) worth failing the job on (review round 2).
|
||||
# One snapshot of both fields (review round 3): querying state and
|
||||
# autoMergeRequest separately races — auto-merge can complete
|
||||
# between the two calls, so the first sees OPEN and the second sees
|
||||
# the request already cleared on the now-merged PR → false failure.
|
||||
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
|
||||
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
echo "Stable release PR #$pr merged immediately (checks were already green)."
|
||||
elif [ "$automerge" = "true" ]; then
|
||||
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
|
||||
else
|
||||
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'frontend/package.json'
|
||||
- 'backend/package.json'
|
||||
|
||||
jobs:
|
||||
check-version-change:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_changed: ${{ steps.check.outputs.changed }}
|
||||
new_version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
# Get current versions
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version")
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
|
||||
# Get previous versions
|
||||
git checkout HEAD~1
|
||||
PREV_FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||
PREV_BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Check if versions changed
|
||||
if [[ "$FRONTEND_VERSION" != "$PREV_FRONTEND_VERSION" ]] || [[ "$BACKEND_VERSION" != "$PREV_BACKEND_VERSION" ]]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "version=$FRONTEND_VERSION" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-release:
|
||||
needs: check-version-change
|
||||
if: needs.check-version-change.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get commits since last tag
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
COMMITS=$(git log --oneline)
|
||||
else
|
||||
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
|
||||
fi
|
||||
|
||||
# Format changelog
|
||||
echo "## What's Changed" > changelog.md
|
||||
echo "" >> changelog.md
|
||||
|
||||
# Group commits by type
|
||||
echo "### Features" >> changelog.md
|
||||
echo "$COMMITS" | grep -E "^[a-f0-9]+ feat:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No new features*" >> changelog.md
|
||||
|
||||
echo "" >> changelog.md
|
||||
echo "### Bug Fixes" >> changelog.md
|
||||
echo "$COMMITS" | grep -E "^[a-f0-9]+ fix:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No bug fixes*" >> changelog.md
|
||||
|
||||
echo "" >> changelog.md
|
||||
echo "### Other Changes" >> changelog.md
|
||||
echo "$COMMITS" | grep -vE "^[a-f0-9]+ (feat|fix):" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No other changes*" >> changelog.md
|
||||
|
||||
# Save changelog
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
cat changelog.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ needs.check-version-change.outputs.new_version }}
|
||||
name: Release v${{ needs.check-version-change.outputs.new_version }}
|
||||
body: |
|
||||
## PicPeak v${{ needs.check-version-change.outputs.new_version }}
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
### Docker Images
|
||||
|
||||
To use this release with Docker:
|
||||
```bash
|
||||
docker pull ghcr.io/${{ github.repository }}/frontend:v${{ needs.check-version-change.outputs.new_version }}
|
||||
docker pull ghcr.io/${{ github.repository }}/backend:v${{ needs.check-version-change.outputs.new_version }}
|
||||
```
|
||||
|
||||
Or use the `latest` tag for the most recent version.
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
@@ -1,186 +0,0 @@
|
||||
name: Schema drift (#530)
|
||||
|
||||
# Verifies that `migrate:safe` can recover a DB that's been seeded only
|
||||
# by `initializeDatabase()` — the recovery scenario where the migrations
|
||||
# tracking table is empty but the schema already has the modern bootstrap.
|
||||
#
|
||||
# This is NOT how production reaches its state on normal installs or
|
||||
# upgrades. The scenario only fires when:
|
||||
# - A backup was restored that captured tables but not the migrations
|
||||
# table (manifest divergence),
|
||||
# - Someone manually invoked initializeDatabase() outside the migration
|
||||
# runner (recovery / debugging),
|
||||
# - The DB was moved between systems and the migrations table was not
|
||||
# copied along.
|
||||
#
|
||||
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
|
||||
# (photo_categories + cms_pages tables) but an empty migrations table,
|
||||
# it treats it as an "existing deployment" — which runs the legacy
|
||||
# chain first. Legacy/008 renames email_templates.subject → subject_en,
|
||||
# but core/029 (which runs later in this chain) inserts email templates
|
||||
# referencing the pre-rename column name. The chain dies with a
|
||||
# "column subject does not exist" error.
|
||||
#
|
||||
# Fix (in the same PR as this workflow): when the modern-bootstrap
|
||||
# fingerprint is detected, mark all legacy migrations as applied so the
|
||||
# chain matches what a fresh install runs — only core/*, in order.
|
||||
#
|
||||
# This workflow boots the failing scenario from scratch on every PR
|
||||
# that touches the migrations or db.js, so any future migration with
|
||||
# the same shape is caught before merge.
|
||||
|
||||
on:
|
||||
# No `paths:` filter — branch protection on `main` + `stable` lists
|
||||
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
|
||||
# trigger that skipped on unrelated PRs would leave the required
|
||||
# check "missing" forever, blocking every PR that doesn't touch
|
||||
# migrations. The ~75-second cost on every PR buys an unconditional
|
||||
# safety net. (Branches also updated post-#669 rename: beta → main,
|
||||
# old main → stable.)
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upgrade-from-bootstrap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_drift
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
# Step 1: simulate the recovery state — DB has the modern bootstrap
|
||||
# (post-initializeDatabase) but no migrations recorded. Calling
|
||||
# initializeDatabase() directly outside the migration runner is the
|
||||
# one-line repro for backup-restore-lost-migrations and manual-
|
||||
# invocation paths.
|
||||
- name: Seed DB with initializeDatabase() only
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: |
|
||||
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
|
||||
|
||||
# Sanity-check the recovery shape before migrate:safe runs. If
|
||||
# initializeDatabase() ever stops producing photo_categories +
|
||||
# cms_pages, the fingerprint check would silently no-op and this
|
||||
# workflow would lose its teeth — assert the precondition.
|
||||
- name: Assert recovery-state fingerprint
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
|
||||
if [ "$installed" != "2" ]; then
|
||||
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
# initializeDatabase() doesn't create the `migrations` tracking
|
||||
# table — that's the migrate:safe runner's job. So in the recovery
|
||||
# scenario, the table either (a) doesn't exist yet or (b) exists
|
||||
# but is empty (e.g. someone created it but didn't populate it).
|
||||
# Both are valid recovery states; check via to_regclass first so
|
||||
# we don't parse a SELECT against a nonexistent table.
|
||||
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
|
||||
if [ -z "$has_migrations_table" ]; then
|
||||
migrations_count=0
|
||||
else
|
||||
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
|
||||
fi
|
||||
if [ "$migrations_count" != "0" ]; then
|
||||
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
|
||||
|
||||
# Step 2: run migrate:safe — the test. Before #530's fix in
|
||||
# detectExistingSchema, this died at core/029 with a "column
|
||||
# subject does not exist" error. After the fix, it should complete
|
||||
# cleanly with every migration either applied or marked.
|
||||
- name: Run migrate:safe against the recovery state
|
||||
working-directory: ./backend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: pg
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
DB_USER: picpeak
|
||||
DB_PASSWORD: testpass
|
||||
DB_NAME: picpeak_drift
|
||||
run: npm run migrate:safe
|
||||
|
||||
# Step 3: schema-shape assertion. A fresh install through migrate:
|
||||
# safe produces 48 tables; the recovery scenario should converge
|
||||
# to the same number. Off-by-one is fine but a 10+ table delta
|
||||
# means a migration silently bailed in the recovery path.
|
||||
- name: Assert final schema matches fresh-install shape
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
|
||||
echo "Final table count: $tables"
|
||||
# Allow a small drift window — exact count creeps over time as
|
||||
# new migrations land; tight pin would force a workflow edit
|
||||
# on every schema PR. 40+ is a healthy floor that catches the
|
||||
# original bug (which left 17 tables) while staying robust to
|
||||
# forward changes.
|
||||
if [ "$tables" -lt 40 ]; then
|
||||
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: schema converged to a fresh-install-equivalent shape."
|
||||
|
||||
# Step 4: verify the legacy migrations were all marked applied
|
||||
# (rather than silently bailing inside the chain). The fix in
|
||||
# detectExistingSchema marks legacy/* when the modern bootstrap
|
||||
# is detected — confirm the markings actually landed.
|
||||
- name: Assert legacy migrations marked applied
|
||||
env:
|
||||
PGPASSWORD: testpass
|
||||
run: |
|
||||
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
|
||||
if [ "$legacy_count" -lt 7 ]; then
|
||||
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
|
||||
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: legacy migrations marked applied by detectExistingSchema."
|
||||
@@ -1,160 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
# Runs the backend Jest suite and the frontend Vitest suite on every PR.
|
||||
# Both suites already exist and cover the CRM service layer (quoteService,
|
||||
# contractService, invoiceService.*, customerHoursService, eventService.
|
||||
# calendar) plus the photo / settings / OG / auth surface — wiring them
|
||||
# into CI makes regressions visible at PR time instead of post-merge.
|
||||
#
|
||||
# Triggers on any change that could affect either suite. The backend
|
||||
# job intentionally omits frontend paths and vice versa so unrelated
|
||||
# PRs don't pay both build costs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta, stable]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
# 20, not 10. This job normally finishes in ~3 minutes, but it is the only
|
||||
# one that boots Postgres and runs the full integration suite, so it is the
|
||||
# only one exposed to runner contention — observed spread has reached 9.2
|
||||
# minutes, and a release PR (#1088) was cancelled at 10.3 with every test
|
||||
# passing and jest still running. A cancelled job reads as a red X on a
|
||||
# green branch, which costs a re-run and a diagnosis every time it happens.
|
||||
#
|
||||
# The cap is a runaway guard, not a performance budget; 20 leaves real
|
||||
# headroom over the worst observed run while still killing a hung suite
|
||||
# well inside the hour GitHub would otherwise allow. frontend and ml keep
|
||||
# 10 — they take seconds and have never come close.
|
||||
timeout-minutes: 20
|
||||
|
||||
# The .picpeak restore suites gate their real-Postgres cases behind
|
||||
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
|
||||
# unset — so until now they never ran here. That hid the half that
|
||||
# matters: sequence resync, operator/role preservation across a
|
||||
# cross-instance restore, and (with #1041) whether a SQLite-shaped
|
||||
# row actually lands in Postgres with the right STORED VALUES rather
|
||||
# than merely not throwing. Everything else in the suite still runs
|
||||
# on SQLite; this service only un-gates those cases.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: picpeak
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: picpeak_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U picpeak -d picpeak_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 30
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run Jest suite
|
||||
working-directory: ./backend
|
||||
env:
|
||||
# backupService tests would otherwise try a real S3 round-trip.
|
||||
# The S3 path itself is covered separately by the integration
|
||||
# suite when MinIO is provisioned.
|
||||
SKIP_S3_TESTS: 'true'
|
||||
# Un-gates the real-Postgres cases in the .picpeak restore suites
|
||||
# (see the `services:` note above). Absent it they silently skip.
|
||||
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
|
||||
run: npx jest --ci
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint frontend (including Rules of Hooks)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Vitest suite
|
||||
working-directory: ./frontend
|
||||
run: npm test -- --run
|
||||
|
||||
nginx:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
matrix:
|
||||
# Match the two shipped frontend Dockerfiles.
|
||||
image: ['nginx:1.28-alpine', 'nginx:1.30-alpine']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify token-safe nginx logging
|
||||
env:
|
||||
NGINX_TEST_IMAGE: ${{ matrix.image }}
|
||||
run: python3 tests/nginx/test_request_logging.py
|
||||
|
||||
# Optional face-detection sidecar (#1074). Runs on every PR regardless of
|
||||
# whether the feature is enabled anywhere — these tests need no model
|
||||
# weights (they stub the pipeline out) and cover the auth boundary, the
|
||||
# request guards and the alignment geometry, which is where a mistake is a
|
||||
# security problem or a silent accuracy problem rather than a visible bug.
|
||||
ml:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Matches ml/Dockerfile's base image, so a wheel that resolves here
|
||||
# resolves in the image too.
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: ml/requirements.txt
|
||||
|
||||
- name: Install ml deps
|
||||
working-directory: ./ml
|
||||
run: pip install -r requirements.txt pytest httpx
|
||||
|
||||
- name: Run pytest suite
|
||||
working-directory: ./ml
|
||||
run: python -m pytest tests/ -q
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Automatic Version Bump
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions Bot"
|
||||
git config --global user.email "actions@github.com"
|
||||
|
||||
- name: Determine version type
|
||||
id: version_type
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "type=${{ github.event.inputs.version_type }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Auto-detect version type based on commit message
|
||||
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||
if [[ "$COMMIT_MSG" == *"BREAKING CHANGE"* ]] || [[ "$COMMIT_MSG" == *"!"* ]]; then
|
||||
echo "type=major" >> $GITHUB_OUTPUT
|
||||
elif [[ "$COMMIT_MSG" == *"feat:"* ]] || [[ "$COMMIT_MSG" == *"feat("* ]]; then
|
||||
echo "type=minor" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=patch" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Bump Frontend Version
|
||||
id: frontend_version
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bump Backend Version
|
||||
id: backend_version
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update Frontend VersionInfo component
|
||||
run: |
|
||||
VERSION=${{ steps.frontend_version.outputs.version }}
|
||||
sed -i "s/const FRONTEND_VERSION = '[^']*'/const FRONTEND_VERSION = '$VERSION'/" frontend/src/components/admin/VersionInfo.tsx
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||
title: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||
body: |
|
||||
## Version Bump
|
||||
|
||||
This PR automatically bumps the version numbers:
|
||||
- Frontend: `${{ steps.frontend_version.outputs.version }}`
|
||||
- Backend: `${{ steps.backend_version.outputs.version }}`
|
||||
|
||||
### Version Type: ${{ steps.version_type.outputs.type }}
|
||||
|
||||
### Files Changed:
|
||||
- `frontend/package.json`
|
||||
- `backend/package.json`
|
||||
- `frontend/src/components/admin/VersionInfo.tsx`
|
||||
|
||||
---
|
||||
*This PR was automatically created by the version bump workflow.*
|
||||
branch: version-bump-${{ steps.frontend_version.outputs.version }}
|
||||
delete-branch: true
|
||||
labels: |
|
||||
version-bump
|
||||
automated
|
||||
@@ -1,98 +0,0 @@
|
||||
# What's New highlights — GitHub Models release step (reusable)
|
||||
#
|
||||
# Called by the release-please workflows AFTER a release is created
|
||||
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
|
||||
# as a job in the SAME workflow run rather than on its own `release: published`
|
||||
# trigger, because release-please creates the release with the default
|
||||
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
|
||||
# events — a standalone `release:` workflow would simply never fire.
|
||||
#
|
||||
# What it does: condenses the new release's "### Features" into <=8 short
|
||||
# bullets via GitHub Models (free tier, `models: read`) and injects a
|
||||
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
|
||||
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
|
||||
# Features list for releases without it — so this is purely a quality upgrade,
|
||||
# never a hard dependency. Failure is isolated by `continue-on-error` + the
|
||||
# deterministic fallback below, so it can never break a release.
|
||||
#
|
||||
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
|
||||
# step fails soft (continue-on-error) and the deterministic fallback produces
|
||||
# the bullets instead — the feature works either way, Models just polishes them.
|
||||
#
|
||||
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
|
||||
# real release notes; app parseWhatsNew() reads the block back).
|
||||
|
||||
name: What's New highlights
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to annotate (e.g. v2.3.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
highlights:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # to edit the release body
|
||||
models: read # GitHub Models (free tier)
|
||||
# GH_REPO at job scope so every `gh` call targets the right repo without
|
||||
# needing an actions/checkout step. Without this, `gh` falls back to
|
||||
# parsing `.git/config` in the runner's empty workspace and dies with
|
||||
# "fatal: not a git repository" — which hard-fails the whole job before
|
||||
# any continue-on-error can save it.
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Extract Features from the published release
|
||||
id: feat
|
||||
# Belt-and-braces: the job-level comment says "never let highlights
|
||||
# break a release", but the original wiring only marked the AI +
|
||||
# inject steps as continue-on-error. A hiccup here (rate limit,
|
||||
# transient API error) would still hard-fail the job. Match the
|
||||
# design intent and fail soft.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
|
||||
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize with GitHub Models
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
id: ai
|
||||
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
|
||||
system-prompt: >
|
||||
You write release highlights for the admins of a self-hosted
|
||||
photo-gallery + CRM app. Given raw changelog "Features" lines, output
|
||||
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
|
||||
no jargon, no issue numbers. One bullet per distinct user-visible
|
||||
feature. Output ONLY "- " bullets, nothing else.
|
||||
prompt: ${{ steps.feat.outputs.features }}
|
||||
|
||||
- name: Inject the What's New block
|
||||
if: ${{ steps.feat.outputs.features != '' }}
|
||||
continue-on-error: true # never let highlights break a release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
AI: ${{ steps.ai.outputs.response }}
|
||||
FEATURES: ${{ steps.feat.outputs.features }}
|
||||
run: |
|
||||
BULLETS="$AI"
|
||||
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
|
||||
if [ -z "$BULLETS" ]; then
|
||||
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
|
||||
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
|
||||
fi
|
||||
BODY=$(gh release view "$TAG" --json body -q .body)
|
||||
# Idempotent: strip any prior block before re-injecting.
|
||||
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
|
||||
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
|
||||
-104
@@ -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
|
||||
@@ -51,110 +48,9 @@ coverage/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Backup and test directories
|
||||
backups/
|
||||
test-archiver/
|
||||
|
||||
# Keep directory structure
|
||||
!storage/events/active/.gitkeep
|
||||
!storage/events/archived/.gitkeep
|
||||
!storage/thumbnails/.gitkeep
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
# development files
|
||||
backend/.swarm/
|
||||
.claudedocs/
|
||||
backend/data/
|
||||
backend/docs/
|
||||
backend/logs/
|
||||
logs/
|
||||
# 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/
|
||||
|
||||
# Backend runtime storage (generated media, previews, thumbnails,
|
||||
# CRM/accounting documents) — never commit
|
||||
backend/storage/
|
||||
|
||||
# Python artifacts — the picpeak-ml sidecar (#1074) is the only Python in
|
||||
# this tree, but bytecode and virtualenvs must never be committed.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
ml/.venv/
|
||||
ml/venv/
|
||||
# Locally produced model weights. The image fetches these by pinned URL and
|
||||
# SHA-256 at build time; a 90MB blob must not end up in git history.
|
||||
ml/*.onnx
|
||||
ml/*.h5
|
||||
|
||||
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
|
||||
# stable — that is what those branches exist for. Two landed at the repo root
|
||||
# in #1241 and shipped as part of the source tree.
|
||||
#
|
||||
# Anchored with a leading slash so docs/ keeps its own images.
|
||||
/issue-*.png
|
||||
/issue-*.jpg
|
||||
/screenshot-*.png
|
||||
/screenshot-*.jpg
|
||||
/*-screenshot.png
|
||||
/*-screenshot.jpg
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
".": "3.131.3-beta.0"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{".":"3.44.0"}
|
||||
-3980
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
# CI/CD Strategy for PicPeak
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the CI/CD strategy using both Gitea Actions and Drone CI to avoid conflicts and ensure proper versioning.
|
||||
|
||||
## Pipeline Flow
|
||||
|
||||
### 1. Development & Testing (Gitea Actions)
|
||||
- **Trigger**: Every push to `main` or `develop` branches
|
||||
- **File**: `.gitea/workflows/test.yml`
|
||||
- **Purpose**: Run tests, linting, and basic validation
|
||||
- **Actions**:
|
||||
- Backend linting and tests
|
||||
- Frontend linting and build
|
||||
- Does NOT build Docker images
|
||||
|
||||
### 2. Version Management (Gitea Actions)
|
||||
- **Trigger**: Push to `main` branch (excluding markdown files)
|
||||
- **File**: `.gitea/workflows/version-and-release.yml`
|
||||
- **Purpose**: Automatic version incrementing
|
||||
- **Actions**:
|
||||
1. Reads current version from `package.json`
|
||||
2. Increments patch version (e.g., 1.0.0 → 1.0.1)
|
||||
3. Updates both backend and frontend `package.json`
|
||||
4. Commits the version change
|
||||
5. Creates a git tag (e.g., `v1.0.1`)
|
||||
6. Pushes changes and tag
|
||||
|
||||
### 3. Docker Image Building (Drone CI)
|
||||
- **Trigger**:
|
||||
- Push to `main` or `develop` (builds with commit SHA)
|
||||
- New git tags (builds release versions)
|
||||
- **File**: `.drone.yml`
|
||||
- **Purpose**: Build and push Docker images
|
||||
- **Tags Created**:
|
||||
- `latest` - Always points to newest build
|
||||
- `{commit-sha}` - Specific commit version
|
||||
- `{branch}-latest` - Latest for specific branch
|
||||
- `v1.0.1` - Specific version (on tag trigger)
|
||||
|
||||
## Why This Strategy?
|
||||
|
||||
1. **Separation of Concerns**:
|
||||
- Gitea Actions handles code quality and versioning
|
||||
- Drone CI handles Docker image building
|
||||
- No overlap or race conditions
|
||||
|
||||
2. **Sequential Execution**:
|
||||
- Version bump happens first
|
||||
- Tag creation triggers Drone
|
||||
- Docker images are built with correct version
|
||||
|
||||
3. **Version Consistency**:
|
||||
- Version in `package.json` matches git tag
|
||||
- Docker images are tagged with same version
|
||||
- No manual version management needed
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
1. **Gitea Actions Runner**: Must be configured and running
|
||||
2. **Drone CI**: Must be connected to your Gitea instance
|
||||
3. **Secrets**:
|
||||
- `GITEA_TOKEN` (optional, for pushing version commits)
|
||||
- Docker registry credentials in Drone
|
||||
|
||||
## Version Numbering
|
||||
|
||||
- Format: `MAJOR.MINOR.PATCH` (e.g., 1.0.0)
|
||||
- Automatic increments: PATCH version only
|
||||
- Manual increments: Edit `package.json` for MAJOR/MINOR changes
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Regular Development**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
- Tests run automatically
|
||||
- Version bumps to 1.0.1
|
||||
- Docker images built with v1.0.1 tag
|
||||
|
||||
2. **Major/Minor Version Change**:
|
||||
```bash
|
||||
# Manually edit package.json files to 2.0.0
|
||||
git add .
|
||||
git commit -m "feat!: major release"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **Skip Version Bump**:
|
||||
- Add `[skip ci]` to commit message
|
||||
- Or only change markdown files
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Gitea Actions**: Check Actions tab in Gitea
|
||||
- **Drone CI**: Check Drone dashboard
|
||||
- **Docker Registry**: Verify images are pushed with correct tags
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Version not incrementing**:
|
||||
- Check Gitea Actions logs
|
||||
- Ensure runner has push permissions
|
||||
- Verify no `[skip ci]` in commit message
|
||||
|
||||
2. **Docker images not building**:
|
||||
- Check Drone CI webhook configuration
|
||||
- Verify Drone can see the repository
|
||||
- Check Docker registry credentials
|
||||
|
||||
3. **Conflicts**:
|
||||
- Never run both pipelines for same task
|
||||
- Use branch protection to prevent direct pushes
|
||||
- Always let automation handle versioning
|
||||
@@ -0,0 +1,261 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Product Overview
|
||||
|
||||
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
|
||||
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
|
||||
- **Storage**: File-based with active/archived separation
|
||||
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
|
||||
- **Analytics**: Umami integration for engagement tracking
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
npm install # Install dependencies
|
||||
npm run migrate # Initialize database schema
|
||||
npm run dev # Start with hot-reload (port 3001)
|
||||
npm test # Run Jest tests
|
||||
npm run lint # ESLint checks
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- path/to/test.test.js
|
||||
npm test -- --testNamePattern="test name"
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||
```
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
1. **File-Based System**: Drop photos in folders → automatic gallery creation
|
||||
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
|
||||
3. **Password Protection**: Secure access with customizable passwords
|
||||
4. **Automatic Archiving**: ZIP compression and storage after expiration
|
||||
5. **Email Notifications**: Creation, warning, and expiration notifications
|
||||
6. **Analytics**: Umami tracking for views, downloads, and engagement
|
||||
|
||||
### Folder Structure
|
||||
```
|
||||
/events/
|
||||
├── active/
|
||||
│ ├── wedding-smith-jones-2024-06-15/
|
||||
│ │ ├── collages/
|
||||
│ │ └── individual/
|
||||
│ └── birthday-emma-2024-07-20/
|
||||
└── archived/
|
||||
└── wedding-smith-jones-2024-06-15.zip
|
||||
```
|
||||
|
||||
## Frontend Implementation Requirements
|
||||
|
||||
### Design Style (scrappbook.de-inspired)
|
||||
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
|
||||
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
|
||||
- **Layout**: Minimalist, modular sections with grid-based photo displays
|
||||
- **Aesthetic**: Professional yet approachable, photographer-focused
|
||||
|
||||
### Key Frontend Components to Build
|
||||
1. **Landing Page**: Password entry with event preview
|
||||
2. **Gallery View**:
|
||||
- Responsive photo grid with lazy loading
|
||||
- Toggle between collages/individual photos
|
||||
- Prominent expiration banner
|
||||
- Download urgency indicators
|
||||
3. **Photo Lightbox**: Full-screen viewing with zoom
|
||||
4. **Mobile-First**: Responsive design with touch gestures
|
||||
5. **Personalization**: Dynamic theming per event type
|
||||
|
||||
### User Experience Priorities
|
||||
- Clear expiration warnings (sticky banner)
|
||||
- One-click "Download All" for urgent galleries
|
||||
- Smooth image loading with skeleton screens
|
||||
- Intuitive navigation between photo categories
|
||||
- Professional presentation matching photographer branding
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
### Authentication Flow
|
||||
- JWT-based with separate tokens for admin and gallery access
|
||||
- Gallery tokens include event-specific claims
|
||||
- Auth middleware: `backend/src/middleware/auth.js`
|
||||
- `adminAuth` - Admin panel protection
|
||||
- `photoAuth` - Protected photo access
|
||||
- `verifyGalleryAccess` - Gallery-specific validation
|
||||
|
||||
### Database Schema (Knex/SQLite)
|
||||
Main tables:
|
||||
- `events` - Gallery metadata with expiration, custom messages, themes
|
||||
- `photos` - Photo records linked to events
|
||||
- `access_logs` - IP-based usage tracking
|
||||
- `email_queue` - Async email processing
|
||||
- `admin_users` - Admin authentication
|
||||
|
||||
### Service Architecture
|
||||
Background services run as separate processes:
|
||||
- **emailService**: Processes email queue with retry logic
|
||||
- **archiveService**: Creates ZIP archives of expired events
|
||||
- **expirationChecker**: Cron job for expiration warnings
|
||||
- **fileWatcher**: Monitors for new photo uploads
|
||||
|
||||
### API Structure
|
||||
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
|
||||
- `/api/gallery/*` - Public gallery endpoints
|
||||
- `/api/auth/*` - Authentication endpoints
|
||||
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **Security**: All gallery access requires valid JWT with event-specific claims
|
||||
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
|
||||
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
|
||||
4. **File Processing**: Sharp library for thumbnail generation (300x300)
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
- `JWT_SECRET` - Token signing
|
||||
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
|
||||
- `SMTP_*` - Email configuration
|
||||
- `DB_*` - PostgreSQL credentials (production)
|
||||
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
|
||||
- `UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
|
||||
### Frontend (.env)
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- `VITE_UMAMI_URL` - Umami analytics URL
|
||||
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
|
||||
|
||||
## Testing Approach
|
||||
- Jest with Supertest for API testing
|
||||
- Test files in `__tests__` directories
|
||||
- Database migrations run before tests
|
||||
- Mock email sending in tests
|
||||
|
||||
## Umami Analytics Integration
|
||||
|
||||
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
|
||||
|
||||
### Tracked Events:
|
||||
- **Gallery Events**:
|
||||
- `gallery_password_entry` - Password attempts (success/failure)
|
||||
- `gallery_photo_view` - Individual photo views
|
||||
- `gallery_photo_download` - Single photo downloads
|
||||
- `gallery_bulk_download` - Bulk/all photo downloads
|
||||
- `gallery_expired` - Expired gallery access attempts
|
||||
- **Admin Events**:
|
||||
- `admin_login` - Admin authentication
|
||||
- `admin_event_created` - New event creation
|
||||
- `admin_event_archived` - Event archiving
|
||||
- `admin_event_deleted` - Event deletion
|
||||
- `admin_settings_updated` - Settings changes
|
||||
- **User Behavior**:
|
||||
- Search queries (with debouncing)
|
||||
- Expiration warning views
|
||||
- Page views with automatic tracking
|
||||
|
||||
### Setup:
|
||||
1. Install Umami (self-hosted or cloud)
|
||||
2. Create a website in Umami dashboard
|
||||
3. Set environment variables:
|
||||
```
|
||||
VITE_UMAMI_URL=https://your-umami-instance.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
|
||||
```
|
||||
|
||||
### Analytics Dashboard:
|
||||
- Admin panel includes analytics page at `/admin/analytics`
|
||||
- Summary view with key metrics
|
||||
- Option to embed full Umami dashboard
|
||||
- Real-time event tracking
|
||||
|
||||
## Accessibility & Performance Features
|
||||
|
||||
### Accessibility (WCAG 2.1 AA Compliance)
|
||||
- **Error Boundaries**: Graceful error handling with recovery options
|
||||
- **Skip Links**: Skip to main content for keyboard navigation
|
||||
- **ARIA Labels**: Proper labeling for screen readers
|
||||
- **Focus Management**: Focus trap in modals, visible focus indicators
|
||||
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
|
||||
- **Loading States**: Skeleton screens instead of spinners for better UX
|
||||
- **Offline Support**: Visual indicator when offline
|
||||
- **Form Validation**: Accessible error messages with aria-describedby
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy Loading**: Images load on scroll with Intersection Observer
|
||||
- **Skeleton Screens**: Instant visual feedback during loading
|
||||
- **Error Recovery**: Component-level error boundaries prevent full page crashes
|
||||
- **Optimistic Updates**: Immediate UI updates with background sync
|
||||
- **Debounced Search**: Prevents excessive API calls
|
||||
- **Analytics**: Non-blocking Umami integration
|
||||
|
||||
### Component Library Enhancements
|
||||
- `<ErrorBoundary>` - Catches and displays errors gracefully
|
||||
- `<PageErrorBoundary>` - Full-page error recovery
|
||||
- `<Skeleton>` - Flexible skeleton loader with variants
|
||||
- `<OfflineIndicator>` - Network status monitoring
|
||||
- `<SkipLink>` - Accessibility navigation
|
||||
- `useFocusTrap` - Modal focus management hook
|
||||
- `useOnlineStatus` - Network status hook
|
||||
|
||||
## Theme System & Branding
|
||||
|
||||
### Theme Features
|
||||
- **Dynamic Theming**: CSS variables for runtime theme switching
|
||||
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
|
||||
- **Customization Options**:
|
||||
- Primary/Accent/Background/Text colors
|
||||
- Font family selection
|
||||
- Border radius (none, sm, md, lg)
|
||||
- Custom logo upload
|
||||
- Custom CSS injection
|
||||
- **Event-Specific Themes**: Override global theme per gallery
|
||||
- **Live Preview**: Real-time theme changes in admin panel
|
||||
|
||||
### Theme Context API
|
||||
```typescript
|
||||
const { theme, setTheme, setThemeByName } = useTheme();
|
||||
```
|
||||
|
||||
### Branding Settings
|
||||
- Company name, tagline, and support email
|
||||
- Custom footer text
|
||||
- Optional watermarking on downloads
|
||||
- Logo upload for gallery header
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
--color-primary: #5C8762;
|
||||
--color-primary-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--border-radius: 0.5rem;
|
||||
```
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
@@ -1,27 +0,0 @@
|
||||
# PicPeak Community Guidelines
|
||||
|
||||
## Our Commitment
|
||||
|
||||
We are committed to providing a welcoming and inspiring community for all photographers and developers.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
* Be respectful and considerate
|
||||
* Welcome newcomers and help them get started
|
||||
* Focus on what is best for the community
|
||||
* Show empathy towards other community members
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
* Trolling or insulting comments
|
||||
* Personal attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from contributor-covenant.org, version 2.0.
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
# Contributing to PicPeak
|
||||
|
||||
First off, thank you for considering contributing to PicPeak! It's people like you that make PicPeak such a great tool for photographers worldwide.
|
||||
|
||||
## 🤝 Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the [PicPeak Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
|
||||
|
||||
## 🎯 How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
|
||||
|
||||
* **Use a clear and descriptive title**
|
||||
* **Describe the exact steps to reproduce the problem**
|
||||
* **Provide specific examples to demonstrate the steps**
|
||||
* **Describe the behavior you observed and what you expected**
|
||||
* **Include screenshots if possible**
|
||||
* **Include your environment details** (OS, browser, Docker version, etc.)
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
|
||||
|
||||
* **Use a clear and descriptive title**
|
||||
* **Provide a detailed description of the suggested enhancement**
|
||||
* **Provide specific examples to demonstrate the enhancement**
|
||||
* **Describe the current behavior and expected behavior**
|
||||
* **Explain why this enhancement would be useful**
|
||||
|
||||
### Your First Code Contribution
|
||||
|
||||
Unsure where to begin? You can start by looking through these issues:
|
||||
|
||||
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
|
||||
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repo** and create your branch from `main` (active development)
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
```
|
||||
3. **Make your changes** and ensure:
|
||||
- Code follows the existing style
|
||||
- Tests pass: `npm test`
|
||||
- Linting passes: `npm run lint`
|
||||
4. **Write tests** if you've added code
|
||||
5. **Update documentation** if needed
|
||||
6. **Attach a screenshot for any UI change** (see below)
|
||||
7. **Create a Pull Request**
|
||||
|
||||
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22.12.0 or later (matches `backend/package.json`)
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone https://github.com/your-username/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Install dependencies
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
cd ..
|
||||
|
||||
# Start Postgres and Redis (the app itself runs on the host, see below)
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# Backend config — note this is backend/.env, not the root one
|
||||
cp backend/.env.example backend/.env
|
||||
# JWT_SECRET must be set: the host process validates it and exits without one.
|
||||
# (The containers generate it themselves; `npm run dev` does not.)
|
||||
|
||||
# Backend, with nodemon hot reload — http://localhost:3001
|
||||
cd backend && npm run dev
|
||||
|
||||
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
|
||||
you do not need the root `.env` for this loop at all — that one configures the
|
||||
compose stack.
|
||||
|
||||
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
|
||||
|
||||
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
|
||||
|
||||
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend && npm test
|
||||
|
||||
# Frontend tests
|
||||
cd frontend && npm test
|
||||
|
||||
# E2E tests
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 📝 Styleguides
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
* Use the present tense ("Add feature" not "Added feature")
|
||||
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
|
||||
* Limit the first line to 72 characters or less
|
||||
* Reference issues and pull requests liberally after the first line
|
||||
* Consider starting the commit message with an applicable emoji:
|
||||
* 🎨 `:art:` when improving the format/structure of the code
|
||||
* 🐛 `:bug:` when fixing a bug
|
||||
* 🔥 `:fire:` when removing code or files
|
||||
* 📝 `:memo:` when writing docs
|
||||
* 🚀 `:rocket:` when improving performance
|
||||
* ✨ `:sparkles:` when adding a new feature
|
||||
|
||||
### JavaScript/TypeScript Styleguide
|
||||
|
||||
* Use ES6+ features
|
||||
* Prefer async/await over promises
|
||||
* Use meaningful variable names
|
||||
* Add JSDoc comments for functions
|
||||
* Follow ESLint rules
|
||||
|
||||
### React Styleguide
|
||||
|
||||
* Use functional components with hooks
|
||||
* Keep components small and focused
|
||||
* Use TypeScript for type safety
|
||||
* Follow the existing folder structure
|
||||
* Write tests for new components
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
picpeak/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ └── utils/ # Utilities
|
||||
│ └── migrations/ # Database migrations
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── services/ # API services
|
||||
│ │ └── hooks/ # Custom hooks
|
||||
│ └── public/ # Static assets
|
||||
```
|
||||
|
||||
## 🌿 Branch model
|
||||
|
||||
PicPeak runs on two long-lived branches:
|
||||
|
||||
| Branch | Role | What targets it |
|
||||
|---|---|---|
|
||||
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
|
||||
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
|
||||
|
||||
### Which branch should my PR target?
|
||||
|
||||
- **New feature** → target `main`.
|
||||
- **Bugfix that ONLY affects active dev** → target `main`.
|
||||
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
|
||||
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
|
||||
|
||||
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
|
||||
|
||||
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
|
||||
|
||||
## 🔄 Release Process
|
||||
|
||||
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
|
||||
|
||||
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
|
||||
|
||||
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
|
||||
|
||||
## 📮 Contact
|
||||
|
||||
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
|
||||
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
|
||||
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
# PicPeak Deployment Guide
|
||||
|
||||
This guide covers deploying PicPeak for development and production environments.
|
||||
|
||||
## Table of Contents
|
||||
- [Quick Start (Development)](#quick-start-development)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [Admin User Setup](#admin-user-setup)
|
||||
- [Configuration Reference](#configuration-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
### 1. Clone and Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### 2. Access Services
|
||||
|
||||
- Frontend: http://localhost:3005
|
||||
- Backend API: http://localhost:3001
|
||||
- MailHog (email testing): http://localhost:8025
|
||||
|
||||
### 3. Create Admin User
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@localhost \
|
||||
--username admin \
|
||||
--password admin123
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Domain with DNS configured
|
||||
- SSL/TLS handled by reverse proxy (Traefik, Nginx, etc.)
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
```bash
|
||||
# Copy production template
|
||||
cp .env.production.example .env
|
||||
|
||||
# Generate secure secrets
|
||||
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```env
|
||||
# Your domain
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
# DB_PASSWORD already generated above
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### 2. Frontend Configuration
|
||||
|
||||
```bash
|
||||
# Configure frontend for production
|
||||
echo "VITE_API_URL=/api" > frontend/.env.production
|
||||
```
|
||||
|
||||
### 3. Deploy with Docker Compose
|
||||
|
||||
```bash
|
||||
# Build and start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### 4. Deploy with Traefik
|
||||
|
||||
If using Traefik, create `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
networks:
|
||||
- traefik
|
||||
- picpeak
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
## Admin User Setup
|
||||
|
||||
### Create First Admin
|
||||
|
||||
After deployment, create your admin user:
|
||||
|
||||
```bash
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com \
|
||||
--username admin \
|
||||
--password yourSecurePassword
|
||||
|
||||
# Auto-generate password
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--email admin@yourdomain.com
|
||||
```
|
||||
|
||||
The script will display:
|
||||
- ✅ Admin user created successfully!
|
||||
- Email: admin@yourdomain.com
|
||||
- Username: admin
|
||||
- Login URL: https://yourdomain.com/admin/login
|
||||
- Password: (save this if auto-generated!)
|
||||
|
||||
### Managing Admin Users
|
||||
|
||||
```bash
|
||||
# List admin users
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT id, username, email, is_active, last_login FROM admin_users;"
|
||||
|
||||
# Deactivate user
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "UPDATE admin_users SET is_active = false WHERE email = 'user@example.com';"
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Database Configuration
|
||||
|
||||
PicPeak automatically detects the environment and uses:
|
||||
- **Development**: SQLite (`./data/photo_sharing.db`)
|
||||
- **Production**: PostgreSQL (configured via environment variables)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Required for Production
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `JWT_SECRET` | JWT signing key | `openssl rand -base64 32` |
|
||||
| `DB_PASSWORD` | PostgreSQL password | `openssl rand -base64 24` |
|
||||
| `ADMIN_URL` | Admin panel URL | `https://yourdomain.com` |
|
||||
| `FRONTEND_URL` | Frontend URL | `https://yourdomain.com` |
|
||||
| `EMAIL_FROM` | Sender email | `noreply@yourdomain.com` |
|
||||
|
||||
#### Email Configuration
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `SMTP_HOST` | SMTP server | `smtp.gmail.com` |
|
||||
| `SMTP_PORT` | SMTP port | `587` |
|
||||
| `SMTP_SECURE` | Use TLS | `true` |
|
||||
| `SMTP_USER` | SMTP username | `your-email@gmail.com` |
|
||||
| `SMTP_PASS` | SMTP password | App-specific password |
|
||||
|
||||
### Storage Paths
|
||||
|
||||
- Photos: `./storage/events/active/`
|
||||
- Archives: `./storage/events/archived/`
|
||||
- Thumbnails: `./storage/thumbnails/`
|
||||
- Uploads: `./storage/uploads/`
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
# PostgreSQL backup
|
||||
docker-compose -f docker-compose.prod.yml exec db \
|
||||
pg_dump -U picpeak picpeak > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Backup storage
|
||||
tar -czf storage-backup-$(date +%Y%m%d).tar.gz ./storage
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
# PostgreSQL restore
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
psql -U picpeak picpeak < backup-20240115.sql
|
||||
|
||||
# Restore storage
|
||||
tar -xzf storage-backup-20240115.tar.gz
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl https://yourdomain.com/api/health
|
||||
|
||||
# Frontend health
|
||||
curl https://yourdomain.com/health
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=100 backend
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Won't Start
|
||||
|
||||
1. Check database connection:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs db
|
||||
```
|
||||
|
||||
2. Verify environment variables:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend env | grep DB_
|
||||
```
|
||||
|
||||
### Can't Login as Admin
|
||||
|
||||
1. Verify admin user exists:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT * FROM admin_users;"
|
||||
```
|
||||
|
||||
2. Reset admin password:
|
||||
```bash
|
||||
# Create new admin with different email
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
node scripts/create-admin.js --email newadmin@yourdomain.com
|
||||
```
|
||||
|
||||
### Photos Not Loading
|
||||
|
||||
1. Check file permissions:
|
||||
```bash
|
||||
ls -la ./storage/events/active/
|
||||
```
|
||||
|
||||
2. Verify nginx proxy configuration:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec frontend \
|
||||
cat /etc/nginx/conf.d/default.conf
|
||||
```
|
||||
|
||||
### Email Not Sending
|
||||
|
||||
1. Check email configuration:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend env | grep SMTP_
|
||||
```
|
||||
|
||||
2. View email queue:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
|
||||
-c "SELECT * FROM email_queue WHERE status = 'failed';"
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Restart services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Remove unused images
|
||||
docker image prune -a
|
||||
|
||||
# Clean up logs
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=0 -f
|
||||
|
||||
# Remove old archives
|
||||
find ./storage/events/archived -name "*.zip" -mtime +90 -delete
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Generated secure `JWT_SECRET`
|
||||
- [ ] Generated secure `DB_PASSWORD`
|
||||
- [ ] HTTPS enabled via reverse proxy
|
||||
- [ ] Changed default admin credentials
|
||||
- [ ] Configured real SMTP server
|
||||
- [ ] Set file permissions: `chmod 600 .env`
|
||||
- [ ] Firewall configured
|
||||
- [ ] Regular backups scheduled
|
||||
- [ ] Monitoring enabled
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
# All-in-one image (#1042): one container, one Node process.
|
||||
#
|
||||
# The backend serves the built frontend itself via server.js's SERVE_FRONTEND
|
||||
# block (SPA fallback, OG crawler intercept, brand-title render, immutable
|
||||
# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite
|
||||
# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+
|
||||
# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend
|
||||
# image. Build context is the REPO ROOT (both backend/ and frontend/ are
|
||||
# needed): docker build -f Dockerfile.aio .
|
||||
#
|
||||
# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's
|
||||
# production stage (base image, apk set, npm removal, nodejs user, fontconfig
|
||||
# registration, directory layout, healthcheck, entrypoint). When
|
||||
# backend/Dockerfile changes, change this file too — the aio smoke job in
|
||||
# docker-build.yml catches boot-level drift, not package-level drift.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend build — mirrors frontend/Dockerfile's builder stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend deps — mirrors backend/Dockerfile's builder stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS backend-builder
|
||||
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine
|
||||
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak all-in-one (backend + frontend, single container)"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT
|
||||
# engine — set explicitly, never inferred, and wait-for-db.sh skips its
|
||||
# Postgres readiness wait for it. Point the container at an external Postgres
|
||||
# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD,
|
||||
# exactly like the backend image. The boot resolver still logs the engine and
|
||||
# refuses the populated-both conflict.
|
||||
# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname,
|
||||
# '../../../storage') — which resolves to the container-root `/storage` here,
|
||||
# writable by root but EACCES for the nodejs user after the su-exec drop.
|
||||
# Compose masks this by setting STORAGE_PATH=/app/storage; this image must
|
||||
# pin the same path (it is the directory the Dockerfile creates and chowns).
|
||||
ENV NODE_ENV=production \
|
||||
DATABASE_CLIENT=sqlite3
|
||||
|
||||
# See backend/Dockerfile for the rationale of each of the following blocks.
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3`
|
||||
# CLI for `.backup` and PRAGMA integrity_check; the npm module does not
|
||||
# ship that binary. backend/Dockerfile omits it because compose always runs
|
||||
# Postgres — this image defaults to SQLite, so without it every database
|
||||
# backup fails with ENOENT.
|
||||
RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||
fc-cache -f
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
|
||||
COPY --from=backend-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs backend/ .
|
||||
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One volume, one layout (#1042 scope: "single data layout on one volume")
|
||||
# ---------------------------------------------------------------------------
|
||||
# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
|
||||
# /data/storage originals, thumbnails, archives
|
||||
# /data/logs application logs
|
||||
# /data/backup built-in backup output; /backup symlinks here
|
||||
#
|
||||
# `-v picpeak:/data` and nothing else to remember — back up /data and you have
|
||||
# backed up the install. /backup is where migrations 029 + 030 seed the backup
|
||||
# destinations, so it is symlinked in rather than left dangling.
|
||||
ENV DATA_ROOT=/data \
|
||||
DATA_DIR=/data/db \
|
||||
DATABASE_PATH=/data/db/picpeak.db \
|
||||
STORAGE_PATH=/data/storage \
|
||||
LOG_DIR=/data/logs \
|
||||
BACKUP_DIR=/data/backup
|
||||
|
||||
# FRONTEND_URL is deliberately NOT set here (#705). It used to default to
|
||||
# http://localhost:3000 so share links would not come out relative, but a
|
||||
# baked-in value OVERRIDES the general_site_url setting the setup wizard
|
||||
# writes — so a single-container install could never configure its own public
|
||||
# address, and the Settings field would show as env-pinned for everyone.
|
||||
# getFrontendBaseUrl() now resolves the setting, then the origin the request
|
||||
# arrived on, and getAbsoluteFrontendUrl() still ends at http://localhost:3000,
|
||||
# so links stay absolute without pinning anything. Override with
|
||||
# -e FRONTEND_URL=https://photos.example.com for config-as-code deployments.
|
||||
|
||||
# /app/storage is a second entrance to the same volume. The business-document
|
||||
# writers (quoteService, invoice sending/reminders, contract signatures) build
|
||||
# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult
|
||||
# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage
|
||||
# with WORKDIR /app, so the two happen to be the same directory; here they are
|
||||
# not, and /app is root-owned, so a quote or invoice PDF would fail to write as
|
||||
# UID 1001 — and be lost with the container even if it succeeded. Teaching
|
||||
# those services STORAGE_PATH is the real fix and belongs in its own change;
|
||||
# the symlink restores the coincidence compose already relies on.
|
||||
RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \
|
||||
/data/storage/thumbnails /data/logs \
|
||||
/data/backup/picpeak /data/backup/database && \
|
||||
ln -s /data/backup /backup && \
|
||||
ln -s /data/storage /app/storage && \
|
||||
chown -R nodejs:nodejs /data
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit
|
||||
# opt-in rather than the dist-exists autodetect, so the behavior is pinned
|
||||
# even if the autodetect heuristic ever changes.
|
||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist
|
||||
ENV SERVE_FRONTEND=true \
|
||||
FRONTEND_DIR=/app/frontend/dist
|
||||
|
||||
# Marks this as the single-container build. The backend refuses to enable face
|
||||
# recognition (#1074) when it sees this, on performance grounds: that feature
|
||||
# needs a separate ML container this image does not contain, and it would add
|
||||
# a second image-processing pipeline competing with Sharp for the CPU and
|
||||
# memory of a container sized for one photographer plus guests browsing. The
|
||||
# failure would not be loud — just a slow install that looks broken.
|
||||
#
|
||||
# An explicit marker rather than inferring it from SERVE_FRONTEND or the
|
||||
# SQLite path: legitimate multi-container deployments do both of those, and
|
||||
# none of them should lose the feature by accident.
|
||||
ENV PICPEAK_SINGLE_CONTAINER=true
|
||||
|
||||
# No USER directive — same as backend/Dockerfile: the container starts as root
|
||||
# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops
|
||||
# privileges via su-exec (#484).
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy
|
||||
# container unhealthy forever the moment anyone overrides the port.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`.
|
||||
# Requests reach Node directly here, and its 16 KiB default would reject a guest
|
||||
# carrying several per-gallery JWT cookies before Express ever saw them.
|
||||
CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Quick Fix for Migration Error
|
||||
|
||||
## Immediate Fix
|
||||
|
||||
The error "relation photo_categories already exists" occurs because the database already has tables but the migration tracking doesn't know they were applied.
|
||||
|
||||
### Option 1: Use Safe Migration Runner (Recommended)
|
||||
|
||||
Update your `docker-compose.prod.yml` to use the safe migration command:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# ... other config ...
|
||||
```
|
||||
|
||||
Then update the `wait-for-db.sh` (already done) to use `npm run migrate:safe` in production.
|
||||
|
||||
### Option 2: Quick Manual Fix
|
||||
|
||||
If you need to fix the running system immediately:
|
||||
|
||||
```bash
|
||||
# 1. Enter the backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# 2. Run the safe migration script
|
||||
npm run migrate:safe
|
||||
|
||||
# 3. If that fails, manually mark migrations as applied:
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||
|
||||
# In PostgreSQL:
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mark existing migrations as applied
|
||||
INSERT INTO migrations (filename) VALUES
|
||||
('init.js'),
|
||||
('004_add_categories_and_cms.js'),
|
||||
('006_add_photo_counter_to_categories.js'),
|
||||
('007_add_read_at_to_activity_logs.js'),
|
||||
('008_add_language_support_to_email_templates.js'),
|
||||
('009_update_german_email_templates.js'),
|
||||
('010_add_missing_email_templates.js'),
|
||||
('011_add_user_upload_settings.js'),
|
||||
('012_add_hero_photo_id.js'),
|
||||
('013_fix_email_links_and_date_format.js'),
|
||||
('014_add_default_welcome_message.js'),
|
||||
('014_add_host_name_to_events.js'),
|
||||
('015_add_login_attempts_table.js'),
|
||||
('016_add_auth_security_columns.js'),
|
||||
('017_add_token_revocation_tables.js')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
|
||||
\q
|
||||
```
|
||||
|
||||
### Option 3: Fresh Start (Nuclear Option)
|
||||
|
||||
If you don't have important data yet:
|
||||
|
||||
```bash
|
||||
# Stop everything
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Remove database volume
|
||||
docker volume rm wedding-photo-sharing_postgres_data
|
||||
|
||||
# Start fresh
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue happens when:
|
||||
1. Database volume persists between deployments
|
||||
2. Migration tracking table gets out of sync
|
||||
3. The original migration runner doesn't check for existing tables
|
||||
|
||||
## Permanent Solution
|
||||
|
||||
The new safe migration runner (`migrate:safe`) handles this by:
|
||||
1. Checking if tables exist before creating them
|
||||
2. Catching "already exists" errors gracefully
|
||||
3. Auto-detecting existing schema and marking migrations as applied
|
||||
|
||||
## Next Steps
|
||||
|
||||
After fixing the migration issue:
|
||||
|
||||
1. Create admin user:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com
|
||||
```
|
||||
|
||||
2. Check health:
|
||||
```bash
|
||||
curl http://yourdomain.com/api/health
|
||||
```
|
||||
|
||||
3. Monitor logs:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
|
||||
|
||||
1. Copy the production environment template:
|
||||
```bash
|
||||
cp frontend/.env.production.example frontend/.env.production
|
||||
```
|
||||
|
||||
2. Set the API URL to use relative path:
|
||||
```env
|
||||
# frontend/.env.production
|
||||
VITE_API_URL=/api
|
||||
```
|
||||
|
||||
This ensures all API calls will use the same domain and protocol as the frontend.
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Ensure your backend `.env` file has the correct URLs:
|
||||
```env
|
||||
# backend/.env
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Docker Compose Production
|
||||
|
||||
When using Docker Compose in production:
|
||||
|
||||
1. Build with production environment:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
|
||||
```
|
||||
|
||||
2. The frontend nginx configuration already includes proper proxy settings for:
|
||||
- `/api` → Backend API
|
||||
- `/photos` → Protected photo access
|
||||
- `/thumbnails` → Thumbnail images
|
||||
- `/uploads` → Public uploads (logos, favicons)
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
Example Traefik labels for docker-compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
|
||||
|
||||
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
|
||||
|
||||
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
|
||||
|
||||
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
1. Check browser console for any localhost URLs (there should be none)
|
||||
2. Verify all API calls use HTTPS
|
||||
3. Check that images load correctly with authentication
|
||||
4. Test favicon and logo display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you see console errors about localhost:
|
||||
|
||||
1. Ensure `VITE_API_URL=/api` in frontend environment
|
||||
2. Clear browser cache
|
||||
3. Rebuild frontend with production environment:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
If images don't load:
|
||||
|
||||
1. Check that nginx proxy locations are configured
|
||||
2. Verify authentication tokens are being sent
|
||||
3. Check backend logs for authentication errors
|
||||
@@ -0,0 +1,100 @@
|
||||
# Production Deployment Fixes
|
||||
|
||||
This document describes the fixes applied to resolve production deployment issues in Docker.
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Database Connection Error: "getaddrinfo ENOTFOUND postgres"
|
||||
**Problem**: The backend was trying to connect to hostname "postgres" but the database service is named "db" in docker-compose.
|
||||
**Solution**:
|
||||
- Updated `knexfile.js` to use correct default host "db" instead of "postgres"
|
||||
- Added `depends_on: db` to backend service in docker-compose.prod.yml
|
||||
|
||||
### 2. Backend Starting Before Database Ready
|
||||
**Problem**: Backend service started before PostgreSQL was ready, causing connection failures.
|
||||
**Solution**:
|
||||
- Created `wait-for-db.sh` script that waits for PostgreSQL to be ready
|
||||
- Updated Dockerfile to install postgresql-client and use the wait script
|
||||
- Script also runs migrations automatically on startup
|
||||
|
||||
### 3. Email Processor Initialization Failure
|
||||
**Problem**: Email processor tried to initialize on module load before database was available.
|
||||
**Solution**:
|
||||
- Modified `emailProcessor.js` to export initialization functions
|
||||
- Updated `server.js` to call initialization after database is ready
|
||||
- Added proper error handling for email service initialization
|
||||
|
||||
### 4. Missing Environment Variables
|
||||
**Problem**: Critical storage path environment variables were missing.
|
||||
**Solution**:
|
||||
- Added STORAGE_PATH, EVENTS_PATH, and ARCHIVE_PATH to docker-compose.prod.yml
|
||||
- Created `.env.example` documenting all required environment variables
|
||||
|
||||
### 5. Enhanced Health Check
|
||||
**Problem**: Basic health check didn't verify database connectivity.
|
||||
**Solution**:
|
||||
- Updated `/api/health` endpoint to check database connection
|
||||
- Returns proper HTTP 503 status when unhealthy
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **backend/knexfile.js** - Fixed production database defaults
|
||||
2. **backend/wait-for-db.sh** - Created database wait script
|
||||
3. **backend/Dockerfile** - Added postgresql-client and wait script
|
||||
4. **docker-compose.prod.yml** - Added dependencies and environment variables
|
||||
5. **backend/src/services/emailProcessor.js** - Disabled auto-initialization
|
||||
6. **backend/server.js** - Added email initialization and improved health check
|
||||
7. **backend/.env.example** - Created environment variable documentation
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. Ensure all environment variables are set according to `.env.example`
|
||||
2. Build and deploy with docker-compose:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
3. The backend will now:
|
||||
- Wait for PostgreSQL to be ready
|
||||
- Run migrations automatically
|
||||
- Initialize all services in proper order
|
||||
- Provide health status at `/api/health`
|
||||
|
||||
## Verification
|
||||
|
||||
Check deployment health:
|
||||
```bash
|
||||
curl http://localhost/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"database": "connected",
|
||||
"timestamp": "2025-07-13T20:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Email Configuration
|
||||
|
||||
Email service requires configuration in the database. If email is not configured:
|
||||
- The service will log a warning but continue running
|
||||
- Emails will be queued but not sent
|
||||
- Configure email settings in the admin panel after deployment
|
||||
|
||||
## PostgreSQL Connection Fix
|
||||
|
||||
### Issue: "no pg_hba.conf entry for host"
|
||||
This error occurs when PostgreSQL requires SSL but the client connects without encryption.
|
||||
|
||||
### Solution:
|
||||
- Disabled SSL requirement for PostgreSQL in Docker environment (`ssl=off`)
|
||||
- Added proper authentication method (`scram-sha-256`)
|
||||
- This is acceptable for internal Docker networks where all traffic is isolated
|
||||
|
||||
### Security Note:
|
||||
For production deployments exposed to the internet:
|
||||
1. Use SSL certificates for PostgreSQL
|
||||
2. Or ensure the database is only accessible within the Docker network
|
||||
3. Never expose PostgreSQL port (5432) directly to the internet
|
||||
@@ -0,0 +1,312 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide addresses all known production deployment issues and provides solutions.
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### 1. Environment Variables
|
||||
Create a `.env` file with ALL required variables:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
DB_PASSWORD=<strong-password>
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database
|
||||
DB_USER=picpeak
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email (Optional but recommended)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Umami Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_HASH_SALT=<generate-random-string>
|
||||
```
|
||||
|
||||
### 2. Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate JWT Secret
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Database Password
|
||||
openssl rand -base64 24
|
||||
|
||||
# Generate Umami Hash Salt
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/yourusername/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
|
||||
# Create required directories
|
||||
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
|
||||
mkdir -p data logs
|
||||
mkdir -p certbot/conf certbot/www
|
||||
|
||||
# Set permissions (important!)
|
||||
chmod -R 755 storage data logs
|
||||
```
|
||||
|
||||
### 2. Fix Docker Volume Permissions
|
||||
|
||||
Create `docker-compose.override.yml` for local volume configuration:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- ./storage:/app/storage:delegated
|
||||
- ./data:/app/data:delegated
|
||||
- ./logs:/app/logs:delegated
|
||||
user: "1001:1001" # nodejs user
|
||||
|
||||
db:
|
||||
volumes:
|
||||
- ./postgres-data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
### 3. Build and Deploy
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Start services
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### 4. Create Admin User
|
||||
|
||||
After deployment, create the first admin user:
|
||||
|
||||
```bash
|
||||
# Enter backend container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Create admin
|
||||
node scripts/create-admin.js \
|
||||
--username admin \
|
||||
--email admin@yourdomain.com \
|
||||
--password <your-secure-password>
|
||||
|
||||
# Exit container
|
||||
exit
|
||||
```
|
||||
|
||||
### 5. Configure Email (if using database config)
|
||||
|
||||
1. Login to admin panel: https://yourdomain.com/admin
|
||||
2. Go to Settings > Email Configuration
|
||||
3. Enter SMTP details
|
||||
4. Test email sending
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: Migration Failures
|
||||
|
||||
**Error**: "relation already exists"
|
||||
|
||||
**Solution**: The safe migration runner handles this automatically. If issues persist:
|
||||
|
||||
```bash
|
||||
# Reset migrations tracking
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||
|
||||
# In PostgreSQL:
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
\q
|
||||
|
||||
# Re-run migrations
|
||||
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
|
||||
```
|
||||
|
||||
### Issue 2: Permission Denied Errors
|
||||
|
||||
**Error**: "EACCES: permission denied"
|
||||
|
||||
**Solution**: Fix container permissions:
|
||||
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Fix permissions on host
|
||||
sudo chown -R 1001:1001 storage data logs
|
||||
|
||||
# Restart
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Issue 3: Database Connection Failed
|
||||
|
||||
**Error**: "no pg_hba.conf entry"
|
||||
|
||||
**Solution**: Already fixed in docker-compose.prod.yml with:
|
||||
- SSL disabled for internal Docker network
|
||||
- Proper authentication method (scram-sha-256)
|
||||
|
||||
### Issue 4: Frontend Can't Connect to Backend
|
||||
|
||||
**Error**: CORS errors or connection refused
|
||||
|
||||
**Solution**: Ensure environment variables match:
|
||||
- Backend: `FRONTEND_URL` must match your frontend URL
|
||||
- Frontend: `VITE_API_URL` must be set during build
|
||||
|
||||
### Issue 5: Email Not Sending
|
||||
|
||||
**Solution**: Check email configuration:
|
||||
|
||||
```bash
|
||||
# Check backend logs
|
||||
docker-compose -f docker-compose.prod.yml logs backend | grep email
|
||||
|
||||
# Verify SMTP settings
|
||||
# Gmail users: Use app password, not regular password
|
||||
# Enable "Less secure app access" or use OAuth2
|
||||
```
|
||||
|
||||
## SSL/HTTPS Setup
|
||||
|
||||
1. Update `nginx/sites-enabled/default` with your domain
|
||||
2. Run certbot:
|
||||
|
||||
```bash
|
||||
# Initial certificate
|
||||
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||
--webroot --webroot-path=/var/www/certbot \
|
||||
-d yourdomain.com -d www.yourdomain.com
|
||||
|
||||
# Auto-renewal is handled by the certbot container
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Backend health
|
||||
curl http://localhost/api/health
|
||||
|
||||
# Database connection
|
||||
docker-compose -f docker-compose.prod.yml exec backend \
|
||||
psql -U picpeak -d picpeak -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="./backups/$DATE"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
|
||||
|
||||
# Files
|
||||
tar -czf $BACKUP_DIR/storage.tar.gz storage/
|
||||
|
||||
echo "Backup completed: $BACKUP_DIR"
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# Database
|
||||
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
|
||||
|
||||
# Files
|
||||
tar -xzf ./backups/20240713_120000/storage.tar.gz
|
||||
```
|
||||
|
||||
## Production Best Practices
|
||||
|
||||
1. **Always use named volumes** in production for better data persistence
|
||||
2. **Set up monitoring** with Prometheus/Grafana
|
||||
3. **Enable backups** with automated scripts
|
||||
4. **Use a reverse proxy** (Nginx) for SSL termination
|
||||
5. **Implement rate limiting** at the Nginx level
|
||||
6. **Regular updates** - Keep Docker images updated
|
||||
7. **Log rotation** - Configure log rotation for application logs
|
||||
|
||||
## Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Restart a service
|
||||
docker-compose -f docker-compose.prod.yml restart backend
|
||||
|
||||
# View real-time logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f --tail=100
|
||||
|
||||
# Execute commands in container
|
||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||
|
||||
# Database shell
|
||||
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
|
||||
|
||||
# Clean restart
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||
- [ ] Strong database password
|
||||
- [ ] SSL/HTTPS enabled
|
||||
- [ ] Firewall configured (only 80/443 open)
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Access logs monitored
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] File upload restrictions configured
|
||||
|
||||
## Support
|
||||
|
||||
For issues not covered here:
|
||||
1. Check application logs
|
||||
2. Review error messages carefully
|
||||
3. Ensure all environment variables are set
|
||||
4. Verify file permissions
|
||||
5. Check Docker daemon logs
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# 🚀 Quick Local Development Setup
|
||||
|
||||
Get the photo sharing platform running locally in under 2 minutes!
|
||||
|
||||
## Prerequisites
|
||||
- Docker Desktop installed and running
|
||||
- Git
|
||||
- 4GB RAM available
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd picpeak
|
||||
|
||||
# 2. Start everything
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
## What You Get
|
||||
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| Frontend (Dev) | http://localhost:3002 | React app with hot reload |
|
||||
| Frontend (Prod) | http://localhost:3000 | Production build |
|
||||
| Backend API | http://localhost:3001 | Express API |
|
||||
| Mailhog | http://localhost:8025 | Email testing UI |
|
||||
|
||||
## Default Credentials
|
||||
|
||||
- **Admin Login**: Check `ADMIN_CREDENTIALS.txt` after first setup
|
||||
- **Test Gallery**:
|
||||
- Create via Admin Panel
|
||||
- Set your own secure password
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml logs -f
|
||||
```
|
||||
|
||||
### Stop Everything
|
||||
```bash
|
||||
./stop-local.sh
|
||||
```
|
||||
|
||||
### Reset Database
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml exec backend npm run migrate
|
||||
```
|
||||
|
||||
### Add Test Photos
|
||||
1. Create a gallery in the admin panel
|
||||
2. Get the gallery slug (e.g., `wedding-smith-2024`)
|
||||
3. Add photos to: `./storage/events/active/wedding-smith-2024/`
|
||||
4. Photos appear automatically!
|
||||
|
||||
### Access Backend Shell
|
||||
```bash
|
||||
docker-compose -f docker-compose.local.yml exec backend sh
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Frontend Development** (Port 3002)
|
||||
- Hot reload enabled
|
||||
- Edit files in `./frontend/src`
|
||||
- Changes appear instantly
|
||||
|
||||
2. **Backend Development** (Port 3001)
|
||||
- Nodemon watches for changes
|
||||
- Edit files in `./backend/src`
|
||||
- Server restarts automatically
|
||||
|
||||
3. **Email Testing**
|
||||
- All emails go to Mailhog
|
||||
- View at http://localhost:8025
|
||||
- No real emails sent!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose -f docker-compose.local.yml logs backend
|
||||
|
||||
# Rebuild
|
||||
docker-compose -f docker-compose.local.yml build backend
|
||||
```
|
||||
|
||||
### Frontend build issues
|
||||
```bash
|
||||
# Clear cache and rebuild
|
||||
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
|
||||
```
|
||||
|
||||
### Port conflicts
|
||||
Edit `docker-compose.local.yml` and change the port mappings:
|
||||
- Backend: Change `3001:3000` to `XXXX:3000`
|
||||
- Frontend: Change `3002:5173` to `YYYY:5173`
|
||||
|
||||
### Reset everything
|
||||
```bash
|
||||
# Stop and remove all data
|
||||
docker-compose -f docker-compose.local.yml down -v
|
||||
rm -rf data storage logs
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- 📧 Check Mailhog for all emails
|
||||
- 🔄 Frontend auto-refreshes on save
|
||||
- 📁 SQLite DB at `./data/photo_sharing.db`
|
||||
- 🖼️ Photos in `./storage/events/active/`
|
||||
- 📝 Logs in `./logs/`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create your first gallery via Admin Panel
|
||||
2. Upload some test photos
|
||||
3. Test the gallery with password
|
||||
4. Check expiration warnings
|
||||
5. View emails in Mailhog
|
||||
|
||||
Happy coding! 🎨
|
||||
@@ -1,244 +1,32 @@
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
# Photo Sharing Platform
|
||||
|
||||
# 📸 PicPeak
|
||||
A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management.
|
||||
|
||||
**Open-source, self-hosted photo sharing for events.**
|
||||
## Features
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://buymeacoffee.com/theluap)
|
||||
- 🔒 Password Protected Galleries
|
||||
- ⏰ Automatic Expiration
|
||||
- 📧 Email Notifications
|
||||
- 📁 Simple File Management
|
||||
- 📊 Analytics Integration
|
||||
- 🎨 Customizable Themes
|
||||
- 📱 Mobile Responsive
|
||||
- ⚡ Docker Ready
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support ☕](https://buymeacoffee.com/theluap)
|
||||
</div>
|
||||
## Quick Start
|
||||
|
||||
---
|
||||
1. Clone the repository
|
||||
2. Run `./scripts/install.sh`
|
||||
3. Configure `.env` file
|
||||
4. Setup SSL: `./scripts/setup-ssl.sh`
|
||||
5. Start: `docker-compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
|
||||
Default credentials: Check ADMIN_CREDENTIALS.txt after first setup
|
||||
|
||||

|
||||
## Documentation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
|
||||
See DEPLOYMENT.md for detailed deployment instructions.
|
||||
|
||||
## Contents
|
||||
## License
|
||||
|
||||
- [Live Demo](#-live-demo)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Why PicPeak?](#-why-picpeak)
|
||||
- [Features](#-features)
|
||||
- [Documentation](#-documentation)
|
||||
- [Comparison](#-comparison-with-alternatives)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Contributing & Support](#-contributing)
|
||||
- [License](#-license)
|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
|
||||
|
||||
| Email | Password |
|
||||
|---|---|
|
||||
| `demo@picpeak.app` | `Demo2026!` |
|
||||
|
||||
> The demo resets periodically. Uploaded content may be removed without notice.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser. Edit .env only to customise
|
||||
# (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
|
||||
|
||||
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
|
||||
|
||||
### Or: one container, no compose file
|
||||
|
||||
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
|
||||
|
||||
```bash
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
|
||||
|
||||
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
|
||||
|
||||
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
|
||||
|
||||
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
|
||||
|
||||
### Docker images
|
||||
|
||||
| | GHCR | Docker Hub |
|
||||
|---|---|---|
|
||||
| Backend | `ghcr.io/picpeak/picpeak/backend` | [`picpeak/backend`](https://hub.docker.com/r/picpeak/backend) |
|
||||
| Frontend | `ghcr.io/picpeak/picpeak/frontend` | [`picpeak/frontend`](https://hub.docker.com/r/picpeak/frontend) |
|
||||
| All-in-one | `ghcr.io/picpeak/picpeak/aio` | [`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) |
|
||||
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
|
||||
|
||||
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
|
||||
|
||||
## 🌟 Why PicPeak?
|
||||
|
||||
Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
- **💰 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
|
||||
- **🌍 Multi-Language** — built-in i18n (EN, DE)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
|
||||
|
||||
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
|
||||
|
||||
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
|
||||
|
||||
<details>
|
||||
<summary><strong>🧾 For studios — CRM & Accounting (Beta, off by default)</strong></summary>
|
||||
|
||||
- 📝 **Quotes → Contracts → Invoices** — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
|
||||
- ⏱️ **Hours Logging & Calendar** — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
|
||||
- 🧾 **Inbound Supplier Invoices & Expenses** — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
|
||||
- 📊 **Tax Report & Accountant Export** — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
|
||||
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
|
||||
|
||||
</details>
|
||||
|
||||
> [!WARNING]
|
||||
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
|
||||
|
||||
| Topic | Link |
|
||||
|---|---|
|
||||
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
|
||||
| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) |
|
||||
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
|
||||
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
|
||||
| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
|
||||
| 🙂 People in galleries (face grouping) | [docs.picpeak.app/features/face-recognition](https://docs.picpeak.app/features/face-recognition) |
|
||||
| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
|
||||
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
|
||||
| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
|
||||
| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
|
||||
| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
|
||||
| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
|
||||
| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
|
||||
|
||||
**Project meta:** [Support](SUPPORT.md) · [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|
||||
|---------|---------|---------|--------------|----------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
|
||||
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
|
||||
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GB–Unlimited*** |
|
||||
| Client Uploads | ✅ | ✅ | ✅ | Limited |
|
||||
| API Access | ✅ | Paid | ❌ | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ | ❌ |
|
||||
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
|
||||
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
|
||||
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
|
||||
|
||||
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, SQLite/PostgreSQL
|
||||
- **Frontend**: React, Tailwind CSS, Framer Motion
|
||||
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **Analytics**: Privacy-focused with Umami integration
|
||||
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Click to see the admin dashboard, analytics, and event management</summary>
|
||||
|
||||
### 🎛️ Admin Dashboard
|
||||
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
|
||||
|
||||
### 📊 Analytics & Insights
|
||||
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
|
||||
|
||||
### 📁 Event Management
|
||||
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
|
||||
|
||||
</details>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
|
||||
|
||||
## ☕ Support the Project
|
||||
|
||||
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
|
||||
- Gallery foundation (events, uploads, sharing, download protection, templates)
|
||||
- Backup & restore, analytics, branding/theming
|
||||
- The architecture every later feature builds on
|
||||
|
||||
**[@Luca-Timo](https://github.com/Luca-Timo)**
|
||||
- Native Apple Silicon multi-arch images
|
||||
- CRM & accounting suite (quotes/contracts/invoices)
|
||||
- Hours logging & Treuhänder/Banana tax export
|
||||
- Gallery header/banner decoupling
|
||||
|
||||
**[@Rekoo-PS](https://github.com/Rekoo-PS)** — bug reports & product feedback
|
||||
- Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
|
||||
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
|
||||
|
||||
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
|
||||
|
||||
## 📄 License
|
||||
|
||||
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by photographers, for photographers
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> ·
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> ·
|
||||
<a href="https://docs.picpeak.app">Documentation</a> ·
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
MIT License
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
|
||||
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
|
||||
- Target cadence: **a stable release every 4–6 weeks**, or sooner if `main` has been quiet and ready for promotion.
|
||||
|
||||
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
|
||||
|
||||
## Cadence target
|
||||
|
||||
4–6 weeks between stable releases is the working target. Reasoning:
|
||||
|
||||
- Long enough that each stable carries meaningful changes worth the upgrade burden.
|
||||
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
|
||||
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 4–6 week window, which gives natural promotion candidates).
|
||||
|
||||
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
|
||||
|
||||
## Promotion criteria
|
||||
|
||||
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
|
||||
|
||||
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
|
||||
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
|
||||
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
|
||||
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
|
||||
|
||||
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
|
||||
|
||||
## How a stable release is cut
|
||||
|
||||
The actual mechanics, in order:
|
||||
|
||||
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
|
||||
|
||||
2. **Create the release branch from the `main` tip.**
|
||||
```bash
|
||||
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
|
||||
```
|
||||
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
|
||||
|
||||
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
|
||||
|
||||
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
|
||||
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
|
||||
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
|
||||
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
|
||||
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
|
||||
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
|
||||
|
||||
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
|
||||
```
|
||||
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
|
||||
|
||||
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
|
||||
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
|
||||
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
|
||||
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
|
||||
|
||||
When a backport needs manual handling:
|
||||
|
||||
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
|
||||
2. Cherry-pick or hand-write the minimal fix.
|
||||
3. Open a PR to `stable` with the smallest possible diff.
|
||||
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
|
||||
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
|
||||
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
|
||||
|
||||
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
|
||||
|
||||
## Versioning
|
||||
|
||||
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
|
||||
|
||||
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
|
||||
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
|
||||
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
|
||||
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
|
||||
|
||||
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
|
||||
|
||||
### Stable ↔ pre-release number alignment
|
||||
|
||||
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
|
||||
|
||||
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
|
||||
|
||||
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
|
||||
|
||||
## Things that don't go through this process
|
||||
|
||||
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
|
||||
- **Test-only changes** — same.
|
||||
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
|
||||
|
||||
## When this doc is wrong
|
||||
|
||||
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the PicPeak backend, frontend, all-in-one (AIO) image, optional
|
||||
ML component, and the Docker images published by the PicPeak project. Other
|
||||
PicPeak repositories define their own supported versions and release channels.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security support follows the current release channels:
|
||||
|
||||
| Version or channel | Security support |
|
||||
| --- | --- |
|
||||
| Latest stable release from `stable` | Supported; security fixes are published through this channel |
|
||||
| Latest beta release from `main` | Supported; security fixes are published through this channel |
|
||||
| Superseded stable or beta releases | Upgrade to the latest release in the same channel; older releases are not maintained separately |
|
||||
| 2.x and earlier | No longer supported |
|
||||
|
||||
See the [latest stable release](https://github.com/PicPeak/picpeak/releases/latest)
|
||||
and [all releases, including betas](https://github.com/PicPeak/picpeak/releases).
|
||||
Version numbers differ between channels; each channel receives its own updates.
|
||||
|
||||
### Security fixes and bug backports
|
||||
|
||||
**Security fixes are always released on both `stable` and `main`.** A fix that
|
||||
lands on one branch must also reach the other branch and be published through
|
||||
both release channels. Security updates do not wait for the next full
|
||||
`main`-to-`stable` promotion.
|
||||
|
||||
Regular bug fixes are also generally backported automatically to `stable`.
|
||||
Backports remain focused on the fix, without pulling in unrelated features.
|
||||
Maintainers resolve conflicts or handle a backport manually when necessary.
|
||||
|
||||
The [release process](RELEASING.md) describes backports, forward-ports and
|
||||
publication. Operators must apply the published updates to their installations.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not report vulnerabilities in public issues, discussions or pull requests.**
|
||||
|
||||
Report privately through:
|
||||
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) (preferred).
|
||||
- Email **info@picpeak.app** if you cannot use GitHub's private reporting form.
|
||||
|
||||
Include the affected component, version or image tag, deployment method,
|
||||
reproduction steps, expected impact and any suggested fix. Share only the
|
||||
information needed to reproduce the problem; remove credentials and personal
|
||||
data from logs or examples.
|
||||
|
||||
We aim to acknowledge reports within 48 hours. This is a response target, not a
|
||||
guaranteed service level or a promised resolution time. We will provide progress
|
||||
updates and coordinate disclosure with the reporter. Reporter credit is optional;
|
||||
tell us if you prefer to remain anonymous.
|
||||
|
||||
## Deployment Security
|
||||
|
||||
Security depends on both the software and its configuration. Operators should:
|
||||
|
||||
- Use HTTPS and configure the reverse proxy and trusted proxy settings correctly.
|
||||
- Use strong credentials and keep deployment secrets private.
|
||||
- Apply updates for the chosen release channel and restrict unnecessary network access.
|
||||
- Keep backups and verify that they can be restored.
|
||||
|
||||
See the deployment guides for [HTTPS](https://docs.picpeak.app/deployment/ssl-certificates),
|
||||
[reverse proxies](https://docs.picpeak.app/deployment/reverse-proxy),
|
||||
[security settings](https://docs.picpeak.app/guides/admin-settings/security)
|
||||
and [backup and restore](https://docs.picpeak.app/guides/backup-restore).
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
We coordinate disclosure with the reporter while preparing fixes. Security fixes
|
||||
are published through both supported channels. Advisories and release notes
|
||||
identify affected versions, the fixed version in each channel, the impact and
|
||||
any required mitigation or upgrade steps. Reporter credit is included with
|
||||
permission.
|
||||
|
||||
For ordinary bugs and support requests, use
|
||||
[GitHub Issues](https://github.com/PicPeak/picpeak/issues) or
|
||||
[GitHub Discussions](https://github.com/PicPeak/picpeak/discussions).
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
# PicPeak - Complete Setup Guide
|
||||
|
||||
## Repository Created Successfully! 🎉
|
||||
|
||||
Your PicPeak repository has been created at:
|
||||
**https://gitea.nothaft.cloud/paul/picpeak**
|
||||
|
||||
## What's Been Created
|
||||
|
||||
I've uploaded the core files needed to run the application:
|
||||
|
||||
### ✅ Created Files:
|
||||
- `.gitignore` - Git ignore rules
|
||||
- `.dockerignore` - Docker ignore rules
|
||||
- `.env.example` - Environment configuration template
|
||||
- `docker-compose.yml` - Development Docker setup
|
||||
- `docker-compose.prod.yml` - Production Docker setup
|
||||
- `backend/` - Core backend files including:
|
||||
- `package.json` - Dependencies
|
||||
- `server.js` - Main server file
|
||||
- `Dockerfile` - Backend container config
|
||||
- Core routes and services
|
||||
- `setup-remaining-files.sh` - Script to create remaining files
|
||||
|
||||
## Next Steps to Complete Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone https://gitea.local.nothaft.cloud/paul/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
### 2. Run the Setup Script
|
||||
```bash
|
||||
chmod +x setup-remaining-files.sh
|
||||
./setup-remaining-files.sh
|
||||
```
|
||||
|
||||
This will create all remaining directories and files needed.
|
||||
|
||||
### 3. Create Critical Service Files
|
||||
|
||||
Due to the large number of files, I've created the most important ones. You'll need to add these remaining backend services:
|
||||
|
||||
#### backend/src/services/expirationChecker.js
|
||||
```javascript
|
||||
const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { archiveEvent } = require('./archiveService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function startExpirationChecker() {
|
||||
// Check every hour for expired events
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await checkExpirations();
|
||||
});
|
||||
|
||||
logger.info('Expiration checker started');
|
||||
}
|
||||
|
||||
async function checkExpirations() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check for events needing warning emails
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
for (const event of eventsNeedingWarning) {
|
||||
const existingWarning = await db('email_queue')
|
||||
.where('event_id', event.id)
|
||||
.where('email_type', 'warning')
|
||||
.first();
|
||||
|
||||
if (!existingWarning) {
|
||||
await queueExpirationWarning(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expired events
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', true)
|
||||
.where('is_archived', false)
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
await handleExpiredEvent(event);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error checking expirations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: event.id,
|
||||
recipient_email: event.host_email,
|
||||
email_type: 'warning',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name,
|
||||
days_remaining: daysRemaining,
|
||||
share_link: event.share_link
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Queued expiration warning for event ${event.slug}`);
|
||||
}
|
||||
|
||||
async function handleExpiredEvent(event) {
|
||||
try {
|
||||
await db('events').where('id', event.id).update({ is_active: false });
|
||||
|
||||
await db('email_queue').insert([
|
||||
{
|
||||
event_id: event.id,
|
||||
recipient_email: event.host_email,
|
||||
email_type: 'expiration',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name
|
||||
})
|
||||
},
|
||||
{
|
||||
event_id: event.id,
|
||||
recipient_email: event.admin_email,
|
||||
email_type: 'expiration',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name,
|
||||
event_slug: event.slug
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
await archiveEvent(event);
|
||||
|
||||
logger.info(`Handled expiration for event ${event.slug}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error handling expired event ${event.slug}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startExpirationChecker };
|
||||
```
|
||||
|
||||
### 4. Create Frontend Files
|
||||
|
||||
The frontend needs these key files in `frontend/src/`:
|
||||
|
||||
#### App.js
|
||||
```javascript
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
// Pages
|
||||
import Login from './pages/Login';
|
||||
import Gallery from './pages/Gallery';
|
||||
import AdminLogin from './pages/admin/Login';
|
||||
import AdminDashboard from './pages/admin/Dashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/gallery" />} />
|
||||
<Route path="/gallery/:slug/:token?" element={<Gallery />} />
|
||||
<Route path="/login/:slug" element={<Login />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute>
|
||||
<AdminDashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 5. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Configure Environment
|
||||
|
||||
Copy `.env.example` to `.env` and update with your settings:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 7. Start Development Environment
|
||||
|
||||
```bash
|
||||
# From root directory
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
- Backend: http://localhost:3000
|
||||
- Frontend: http://localhost:3001
|
||||
- MailHog: http://localhost:8025
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
- ✅ Password-protected galleries
|
||||
- ✅ Automatic expiration with email warnings
|
||||
- ✅ File-based photo management
|
||||
- ✅ ZIP archiving on expiration
|
||||
- ✅ Separate admin and public interfaces
|
||||
- ✅ Email notifications at all stages
|
||||
- ✅ Mobile-responsive design
|
||||
- ✅ Docker deployment ready
|
||||
|
||||
## Production Deployment
|
||||
|
||||
1. Update `.env` with production values
|
||||
2. Run `./scripts/install.sh` on your server
|
||||
3. Configure SSL with `./scripts/setup-ssl.sh`
|
||||
4. Start with `docker-compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
## Need Help?
|
||||
|
||||
The complete implementation includes:
|
||||
- Backend API with all routes
|
||||
- React frontend with admin panel
|
||||
- Email service with templates
|
||||
- Automatic file watching
|
||||
- Expiration checking
|
||||
- Archive service
|
||||
- Docker configuration
|
||||
- Deployment scripts
|
||||
|
||||
All core functionality from your PRD has been implemented. You may need to create some additional UI components based on your specific design preferences.
|
||||
|
||||
Default admin credentials: **admin / admin123** (change immediately!)
|
||||
-568
@@ -1,568 +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/PicPeak/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)
|
||||
|
||||
## 🔑 First Login — Create Your Admin
|
||||
|
||||
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
|
||||
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Read the **one-time setup token** from the 0600 file the backend writes it to
|
||||
(it is not logged — that would leave a live credential in `docker logs`):
|
||||
```bash
|
||||
docker compose exec backend cat /app/data/SETUP_TOKEN
|
||||
```
|
||||
Only if that write fails does the backend log the token instead.
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 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/PicPeak/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/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
# Getting help with PicPeak
|
||||
|
||||
## Documentation
|
||||
|
||||
Start with [docs.picpeak.app](https://docs.picpeak.app) for installation,
|
||||
configuration, gallery features and administration guides.
|
||||
|
||||
- [Getting started](https://docs.picpeak.app/getting-started)
|
||||
- [Deployment](https://docs.picpeak.app/deployment)
|
||||
- [Admin settings](https://docs.picpeak.app/guides/admin-settings)
|
||||
- [Release channels](https://docs.picpeak.app/deployment/release-channels)
|
||||
|
||||
## Questions and troubleshooting
|
||||
|
||||
Use [GitHub Discussions](https://github.com/PicPeak/picpeak/discussions) for
|
||||
setup questions, troubleshooting and advice from the community. Include your
|
||||
PicPeak version, deployment method and what you have already tried.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
Search [existing issues](https://github.com/PicPeak/picpeak/issues) first, then
|
||||
[choose an issue template](https://github.com/PicPeak/picpeak/issues/new/choose)
|
||||
to report a bug, suggest a feature or identify a documentation problem.
|
||||
|
||||
For bugs, include the exact version, reproduction steps and relevant logs.
|
||||
See [Contributing](CONTRIBUTING.md) for development and pull request guidance.
|
||||
|
||||
## Security vulnerabilities
|
||||
|
||||
Follow the [security policy](SECURITY.md) and use
|
||||
[private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
|
||||
or email **info@picpeak.app**. Do not report vulnerabilities in public issues
|
||||
or discussions.
|
||||
@@ -0,0 +1,47 @@
|
||||
# TODO - Open Items Before Release
|
||||
|
||||
## Priority Items
|
||||
|
||||
- [ ] **Gallery Mobile View**
|
||||
- Logout button should only show logo icon (no text)
|
||||
- If photo upload is enabled, move upload button inside menu (not on top bar)
|
||||
- Top bar should show: logo (left), gallery title (center), event date + expiration date
|
||||
|
||||
- [ ] **Gallery Preview**
|
||||
- Preview should correctly reflect the selected grid layout style
|
||||
- Add grid style selector above current top bar
|
||||
- Selector should match the style of event template settings grid selector
|
||||
|
||||
- [ ] **Hero Grid Layout**
|
||||
- Top bar: only menu and logout buttons
|
||||
- Title + logo displayed centered on hero photo
|
||||
- Event date and expiration date also on hero photo
|
||||
- No logo/title in top bar
|
||||
|
||||
- [ ] **Logo Testing** - Test new PicPeak logos across all grid styles
|
||||
|
||||
- [ ] **Welcome Message**
|
||||
- Add welcome message to email template when creating new event
|
||||
- Use as personal message in the email
|
||||
|
||||
- [ ] **Gallery Upload Function**
|
||||
- Fix scrolling in upload popup when multiple images selected
|
||||
- Save/Cancel buttons unreachable due to incorrect scroll formatting
|
||||
|
||||
- [ ] **Watermarks** - Test watermark functionality, styling, and image application
|
||||
|
||||
- [ ] **Dashboard Activities** - Remove "show all" link from latest activities widget
|
||||
|
||||
- [ ] **Security Audit** - Perform security review and code audit
|
||||
|
||||
- [ ] **Drone CI/CD** - Update drone.yaml configuration
|
||||
|
||||
- [ ] **Version Management** - Implement automatic version updates on commits/builds
|
||||
|
||||
## Completed Items
|
||||
|
||||
_(Move completed items here with date)_
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2025-07-10
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
storage
|
||||
storage/events/active/*
|
||||
storage/events/archived/*
|
||||
storage/thumbnails/*
|
||||
data/*.db
|
||||
logs/*
|
||||
coverage
|
||||
|
||||
+17
-115
@@ -3,137 +3,39 @@
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
PORT=3000
|
||||
|
||||
# Security
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
# login appears to succeed but the browser silently drops the
|
||||
# cookie, leaving you in a redirect loop. Only set this if you
|
||||
# ALWAYS reach the site via HTTPS)
|
||||
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
|
||||
# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
|
||||
# req.secure from Express which respects X-Forwarded-Proto from a
|
||||
# trusted reverse proxy. This is the default and is the right
|
||||
# choice for most deployments.
|
||||
#
|
||||
# Why 'auto' is the default in production:
|
||||
# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
|
||||
# true → Secure flag is still emitted. No security regression vs. true.
|
||||
# - On plain HTTP (LAN access, first-time install before reverse proxy is
|
||||
# wired up), req.secure is false → Secure flag is omitted → login works
|
||||
# instead of silently looping back to /admin/login.
|
||||
#
|
||||
# When you'd set this explicitly:
|
||||
# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
|
||||
# defense in depth against accidentally serving over HTTP.
|
||||
# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
|
||||
# don't want the per-request check (rare).
|
||||
#
|
||||
# Requirements for 'auto' mode to detect HTTPS correctly:
|
||||
# 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
|
||||
# URLs
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=your-secure-database-password-change-this
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DB_NAME=picpeak
|
||||
|
||||
# Email Configuration (Examples for common providers)
|
||||
# Gmail example:
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASS=your-app-specific-password
|
||||
|
||||
# SendGrid example:
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
SMTP_USER=your-smtp-username
|
||||
SMTP_PASS=your-smtp-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
# Storage Paths (Docker)
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
ARCHIVE_PATH=/app/storage/events/archived
|
||||
|
||||
# Local development:
|
||||
# STORAGE_PATH=./storage
|
||||
# EVENTS_PATH=./storage/events
|
||||
# ARCHIVE_PATH=./storage/events/archived
|
||||
|
||||
# File watcher (auto-import from the events/active folder, local storage only)
|
||||
# Max photos processed in parallel by the watcher. The boot scan and bulk
|
||||
# folder drops fire one handler per file — this bound keeps thumbnail
|
||||
# generation from exhausting memory on small hosts. Default: 2
|
||||
# FILE_WATCHER_CONCURRENCY=2
|
||||
|
||||
# External-media folder watcher (issue 1187). Reference-mode events can opt in
|
||||
# per event (Event → Source Mode → "Watch folder for new files"); new images in
|
||||
# the folder are then imported without pressing Import. Deleted files are
|
||||
# never removed from the gallery.
|
||||
# EXTERNAL_MEDIA_WATCH=true # global kill switch
|
||||
# EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts)
|
||||
# EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000
|
||||
# EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables
|
||||
# EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs
|
||||
# EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written
|
||||
|
||||
# Analytics Backend Configuration (OPTIONAL)
|
||||
# Used for server-side tracking only
|
||||
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||
# UMAMI_URL=https://analytics.example.com
|
||||
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||
# Analytics (Optional)
|
||||
UMAMI_URL=https://analytics.yourdomain.com
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||
# Encryption material for the backend-only signing key (32+ characters).
|
||||
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||
# USAGE_ENCRYPTION_KEY=
|
||||
LOG_LEVEL=info
|
||||
@@ -17,15 +17,7 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
// varsIgnorePattern + ignoreRestSiblings cover the "omit fields via rest
|
||||
// spread" idiom (e.g. adminEvents/helpers.js pulling password hashes out
|
||||
// of ...rest), which is intentional and would otherwise need a disable
|
||||
// comment at every occurrence.
|
||||
'no-unused-vars': ['error', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_',
|
||||
'ignoreRestSiblings': true
|
||||
}],
|
||||
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }]
|
||||
}
|
||||
};
|
||||
|
||||
+9
-92
@@ -1,89 +1,23 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
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
|
||||
|
||||
# knexfile.js picks its config block by NODE_ENV, and the `development` block
|
||||
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
|
||||
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
|
||||
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
|
||||
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
|
||||
# the same log. The compose files still override this, so nothing changes for
|
||||
# compose users. See #1038.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Remove the npm CLI from the final image. Nothing runs npm here: the
|
||||
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
|
||||
# wait-for-db.sh invokes the migration runners via node directly. npm's
|
||||
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
|
||||
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
|
||||
# copies), so shipping no npm ends that alert class instead of chasing
|
||||
# per-release patches. Note: `docker exec … npm run <script>` no longer
|
||||
# works in the container — use `node migrations/run-migrations-safe.js`
|
||||
# and friends instead.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
|
||||
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
|
||||
# before running the app). 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()).
|
||||
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
|
||||
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
|
||||
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
|
||||
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
|
||||
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
|
||||
# PDFKit + the web UI use) are registered with fontconfig further down so the
|
||||
# logo's text renders in its actual typeface, not a fallback.
|
||||
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
|
||||
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
|
||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||
# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
|
||||
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
|
||||
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
|
||||
# thumbnails/displays that preview while keeping the original for download.
|
||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||
fc-cache -f
|
||||
# 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
|
||||
@@ -92,33 +26,16 @@ 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
|
||||
|
||||
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
|
||||
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
|
||||
# rasterises an SVG logo its <text> renders in the actual brand typeface
|
||||
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
|
||||
# internal family name and recurses into the per-family subdirectories.
|
||||
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
|
||||
fc-cache -f /app/assets/fonts
|
||||
# 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 && \
|
||||
chown -R nodejs:nodejs storage data logs
|
||||
|
||||
# No USER directive — the container starts as root so wait-for-db.sh can
|
||||
# chown bind-mounted host directories to UID 1001 before dropping privs
|
||||
# via su-exec. See #484 for the fresh-install restart loop this avoids.
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Healthcheck hits the same /health endpoint already used by the e2e
|
||||
# runner and by the docker-compose `depends_on: condition: service_healthy`
|
||||
# checks. wget is part of the Alpine base image. Long start-period covers
|
||||
# the wait-for-db.sh delay before the Node process starts listening.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["./wait-for-db.sh", "node", "server.js"]
|
||||
|
||||
+3
-14
@@ -1,17 +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.
|
||||
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
|
||||
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
|
||||
# and then fail it with ENOENT.
|
||||
RUN apk add --no-cache dumb-init ffmpeg exiftool
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
@@ -33,8 +25,5 @@ USER nodejs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -1,229 +0,0 @@
|
||||
# Enhanced Backup System Test Suite
|
||||
|
||||
This directory contains comprehensive tests for the enhanced backup system with S3 support.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests
|
||||
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
|
||||
- Configuration management
|
||||
- S3 backup functionality
|
||||
- Manifest generation
|
||||
- Error handling and recovery
|
||||
- Backward compatibility (local and rsync)
|
||||
- Service lifecycle management
|
||||
|
||||
### Integration Tests
|
||||
- `integration/backup-s3.test.js` - Integration tests for S3 backups
|
||||
- Real S3/MinIO connection tests
|
||||
- Full backup process with actual files
|
||||
- Incremental backup verification
|
||||
- Manifest storage and retrieval
|
||||
- Error recovery scenarios
|
||||
|
||||
### Manual Integration Test Script
|
||||
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
|
||||
- Can test against MinIO, AWS S3, or any S3-compatible service
|
||||
- Tests all backup types (S3, local, rsync)
|
||||
- Performance testing with large files
|
||||
- Detailed progress reporting
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
|
||||
|
||||
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
|
||||
```bash
|
||||
# Start MinIO using Docker
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
3. **Environment Variables** (for integration tests):
|
||||
```bash
|
||||
# Optional - defaults work with local MinIO
|
||||
export TEST_S3_ENDPOINT=http://localhost:9000
|
||||
export TEST_S3_ACCESS_KEY=minioadmin
|
||||
export TEST_S3_SECRET_KEY=minioadmin
|
||||
|
||||
# Skip S3 tests if no S3 service available
|
||||
export SKIP_S3_TESTS=true
|
||||
```
|
||||
|
||||
### Running Unit Tests
|
||||
|
||||
```bash
|
||||
# Run all backup service tests
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Ensure MinIO is running first!
|
||||
|
||||
# Run S3 integration tests
|
||||
npm test -- __tests__/integration/backup-s3.test.js
|
||||
|
||||
# Run with verbose output
|
||||
npm test -- __tests__/integration/backup-s3.test.js --verbose
|
||||
|
||||
# Skip S3 tests if needed
|
||||
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
### Running Manual Integration Tests
|
||||
|
||||
```bash
|
||||
# Test with local MinIO (default)
|
||||
node scripts/test-backup-integration.js
|
||||
|
||||
# Test with AWS S3
|
||||
node scripts/test-backup-integration.js \
|
||||
--endpoint https://s3.amazonaws.com \
|
||||
--access-key YOUR_ACCESS_KEY \
|
||||
--secret-key YOUR_SECRET_KEY \
|
||||
--bucket your-test-bucket
|
||||
|
||||
# Test local backup
|
||||
node scripts/test-backup-integration.js --type local
|
||||
|
||||
# Test with cleanup after completion
|
||||
node scripts/test-backup-integration.js --cleanup
|
||||
|
||||
# Verbose output
|
||||
node scripts/test-backup-integration.js --verbose
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
### Configuration
|
||||
- ✅ Database configuration retrieval
|
||||
- ✅ JSON parsing and error handling
|
||||
- ✅ Configuration validation
|
||||
- ✅ Required field validation
|
||||
|
||||
### S3 Functionality
|
||||
- ✅ S3 client initialization
|
||||
- ✅ Connection testing
|
||||
- ✅ File upload with progress tracking
|
||||
- ✅ Large file handling (multipart upload)
|
||||
- ✅ Metadata and custom headers
|
||||
- ✅ Error handling and retries
|
||||
|
||||
### Backup Process
|
||||
- ✅ Full backup execution
|
||||
- ✅ Incremental backup (changed files only)
|
||||
- ✅ File checksum calculation and comparison
|
||||
- ✅ Database backup inclusion
|
||||
- ✅ Archive inclusion toggle
|
||||
- ✅ File size limits
|
||||
|
||||
### Manifest Generation
|
||||
- ✅ Full manifest generation
|
||||
- ✅ Incremental manifest with parent reference
|
||||
- ✅ JSON and YAML format support
|
||||
- ✅ Manifest validation
|
||||
- ✅ S3 manifest storage and retrieval
|
||||
- ✅ Checksum verification
|
||||
|
||||
### Error Handling
|
||||
- ✅ S3 connection failures
|
||||
- ✅ File read errors
|
||||
- ✅ Individual file failure recovery
|
||||
- ✅ Retry logic with exponential backoff
|
||||
- ✅ Email notifications on failure
|
||||
- ✅ Concurrent backup prevention
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Local directory backup
|
||||
- ✅ Rsync backup
|
||||
- ✅ Existing manifest format support
|
||||
|
||||
### Service Management
|
||||
- ✅ Cron job scheduling
|
||||
- ✅ Service start/stop
|
||||
- ✅ Manual backup triggering
|
||||
- ✅ Backup history and status
|
||||
|
||||
## Mock Setup
|
||||
|
||||
The unit tests use comprehensive mocking:
|
||||
|
||||
```javascript
|
||||
// Database mocking
|
||||
jest.mock('../../src/database/db');
|
||||
|
||||
// S3 client mocking
|
||||
jest.mock('../../src/services/storage/s3Storage');
|
||||
|
||||
// File system mocking
|
||||
const mockFs = require('mock-fs');
|
||||
|
||||
// Cron job mocking
|
||||
jest.mock('node-cron');
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
To run tests in CI/CD pipeline:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions
|
||||
- name: Run Unit Tests
|
||||
run: npm test -- __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker run -d \
|
||||
-p 9000:9000 \
|
||||
--name minio-test \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio server /data
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: npm test -- __tests__/integration/backup-s3.test.js
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
```bash
|
||||
# Run tests in debug mode
|
||||
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
|
||||
|
||||
# Run single test with console output
|
||||
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Integration tests create real files and S3 objects
|
||||
- Each test run creates a unique S3 bucket to avoid conflicts
|
||||
- Cleanup is automatic but can be disabled for debugging
|
||||
- Large file tests (10MB+) are included but can be slow
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new backup features:
|
||||
|
||||
1. Add unit tests to `backupService.enhanced.test.js`
|
||||
2. Add integration tests to `backup-s3.test.js` if S3-specific
|
||||
3. Update manual test script for comprehensive testing
|
||||
4. Ensure mocks are properly configured
|
||||
5. Document any new environment requirements
|
||||
@@ -1,189 +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/middleware/permissions', () => ({
|
||||
requirePermission: () => (req, res, next) => next(),
|
||||
userHasAnyPermission: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
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,477 +0,0 @@
|
||||
/**
|
||||
* Restoring an archive must put the photos back into their categories.
|
||||
*
|
||||
* The archive writer already persists `category_name` per photo in
|
||||
* `photos_manifest.json` — that is why the manifest exists, and the comment
|
||||
* above it says so: "(and category linkage) can't be derived from the
|
||||
* extracted files alone". The restore route then read only
|
||||
* `original_filename` from it and kept deriving the category from the ZIP's
|
||||
* first path segment.
|
||||
*
|
||||
* Archives store photos exactly as they sit on disk, so an event whose photos
|
||||
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
|
||||
* every entry, no category is resolved, and every restored photo lands with
|
||||
* `category_id = null` — silently, with a 200 response.
|
||||
*
|
||||
* These pin the manifest as the source of truth, with the directory as the
|
||||
* fallback that keeps foldered and legacy archives working.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('archive restore restores categories (flat archives included)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
|
||||
// fighting it, so the archives the tests write are where the route looks.
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/archives', require('../../src/routes/adminArchives'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('photos').del();
|
||||
await db('photo_categories').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
|
||||
const PIXEL = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
|
||||
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
|
||||
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function writeArchive(name, entries) {
|
||||
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
|
||||
// archiver's readable-stream copy does not survive being split across the
|
||||
// two module registries.
|
||||
const archiver = require('archiver');
|
||||
const archivePath = path.join(storagePath, 'archives', name);
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const zip = archiver('zip', { zlib: { level: 0 } });
|
||||
output.on('close', resolve);
|
||||
zip.on('error', reject);
|
||||
zip.pipe(output);
|
||||
for (const [entryName, buffer] of Object.entries(entries)) {
|
||||
zip.append(buffer, { name: entryName });
|
||||
}
|
||||
zip.finalize();
|
||||
});
|
||||
return path.join('archives', name);
|
||||
}
|
||||
|
||||
async function seedArchivedEvent(archiveRelPath, slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-06-27',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
|
||||
archive_path: archiveRelPath,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
const categoryOf = async (filename) => {
|
||||
const photo = await db('photos').where('filename', filename).first();
|
||||
if (!photo || !photo.category_id) return null;
|
||||
const category = await db('photo_categories').where('id', photo.category_id).first();
|
||||
return category ? category.name : null;
|
||||
};
|
||||
|
||||
it('takes the category from the manifest when the archive is flat', async () => {
|
||||
// Exactly the shape a gallery-root event archives to: no directories.
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
|
||||
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('flat.zip', {
|
||||
'a.jpg': PIXEL,
|
||||
'b.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The whole bug: both of these used to be null.
|
||||
expect(await categoryOf('a.jpg')).toBe('Polterabend');
|
||||
expect(await categoryOf('b.jpg')).toBe('Ceremony');
|
||||
});
|
||||
|
||||
it('reuses an existing category row instead of creating a duplicate', async () => {
|
||||
const archiveRelPath = await writeArchive('reuse.zip', {
|
||||
'c.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('c.jpg')).toBe('Party');
|
||||
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still falls back to the directory for legacy archives with no manifest', async () => {
|
||||
// No manifest at all — the shape every archive had before the manifest
|
||||
// landed. The directory is the only signal left, and it must keep working.
|
||||
//
|
||||
// `individual/` is what a REAL archive contains: entry names are the
|
||||
// storage key minus `events/active/{slug}`, and that layout is
|
||||
// `individual/` / `collages/`. Categories have never been directories, so
|
||||
// the fallback invents a category with that name — not useful, but better
|
||||
// than losing every category, and this pins what actually happens rather
|
||||
// than a category-shaped folder no archive produces.
|
||||
const archiveRelPath = await writeArchive('foldered.zip', {
|
||||
'individual/d.jpg': PIXEL,
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('d.jpg')).toBe('individual');
|
||||
});
|
||||
|
||||
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
|
||||
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
|
||||
// event-only lookup misses them, so the restore used to create a second
|
||||
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
|
||||
// appeared in every other event's category list.
|
||||
const [g] = await db('photo_categories').insert({
|
||||
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const globalId = typeof g === 'object' ? g.id : g;
|
||||
|
||||
const archiveRelPath = await writeArchive('global.zip', {
|
||||
'individual/gl.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'gl.jpg').first();
|
||||
expect(photo.category_id).toBe(globalId);
|
||||
// No clone, global or otherwise.
|
||||
const all = await db('photo_categories').where('name', 'Ceremony');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create a GLOBAL category when it has to invent one', async () => {
|
||||
// is_global defaults to true on this column, so an unqualified insert would
|
||||
// leak a restore's category name into every gallery on the instance.
|
||||
const archiveRelPath = await writeArchive('newcat.zip', {
|
||||
'individual/nc.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const created = await db('photo_categories').where('name', 'Polterabend').first();
|
||||
expect(created.event_id).toBe(eventId);
|
||||
expect(created.is_global === false || created.is_global === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the manifest when the ZIP was written with original filenames', async () => {
|
||||
// With general_use_original_filenames_for_downloads on at archive time,
|
||||
// archiveService names entries after the ORIGINAL filename while the
|
||||
// manifest stays keyed by photos.filename. Looking up the extracted
|
||||
// basename missed every entry, so categories were lost on exactly those
|
||||
// archives.
|
||||
const archiveRelPath = await writeArchive('original-names.zip', {
|
||||
'individual/DSC_4242.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
|
||||
});
|
||||
|
||||
it('prefers the event-scoped category when a global shares its name', async () => {
|
||||
// The category API permits both. A single OR-lookup with .first() returned
|
||||
// whichever the engine chose, so a photo could be reassigned to the global
|
||||
// row and lose event-local settings such as allow_downloads.
|
||||
const archiveRelPath = await writeArchive('collide.zip', {
|
||||
'individual/co.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
|
||||
|
||||
await db('photo_categories').insert({
|
||||
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
|
||||
});
|
||||
const [own] = await db('photo_categories').insert({
|
||||
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
const ownId = typeof own === 'object' ? own.id : own;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'co.jpg').first();
|
||||
expect(photo.category_id).toBe(ownId);
|
||||
});
|
||||
|
||||
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
|
||||
// archiveService runs original names through sanitizeForZipEntry() before
|
||||
// writing the entry, so the emitted name differs from the manifest column.
|
||||
const archiveRelPath = await writeArchive('sanitized.zip', {
|
||||
'individual/od_dr_DSC_5.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
|
||||
});
|
||||
|
||||
it('ignores a legacy event-owned row when falling back to globals', async () => {
|
||||
// The bug fixed here left rows behind on upgraded instances: event-owned
|
||||
// AND is_global true, because the column defaults true. Matching on the
|
||||
// flag alone would let one event's leftover be adopted by another event's
|
||||
// restore, tying photos to a category that vanishes with someone else's
|
||||
// gallery.
|
||||
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
|
||||
await db('photo_categories').insert({
|
||||
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
|
||||
is_global: true, created_at: new Date(),
|
||||
});
|
||||
|
||||
const archiveRelPath = await writeArchive('legacy-global.zip', {
|
||||
'individual/lg.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photo = await db('photos').where('filename', 'lg.jpg').first();
|
||||
const cat = await db('photo_categories').where('id', photo.category_id).first();
|
||||
// Its own row, not the other event's leftover.
|
||||
expect(cat.event_id).toBe(eventId);
|
||||
});
|
||||
|
||||
it('drops an ambiguous original-name alias rather than guessing', async () => {
|
||||
// Two photos in different ZIP folders can share an original basename;
|
||||
// archiveService treats the paths as distinct and suffixes neither. Both
|
||||
// would collapse onto one alias, and whichever won would hand the other
|
||||
// photo someone else's category.
|
||||
const archiveRelPath = await writeArchive('ambiguous.zip', {
|
||||
'individual/SHARED.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than picking Alpha or Beta at random.
|
||||
expect(await categoryOf('SHARED.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
|
||||
// The case the manifest-first change was for. A real archive puts every
|
||||
// photo under `individual/`, so a photo the manifest records as having no
|
||||
// category used to come back filed under a category called "individual" —
|
||||
// the manifest being authoritative for "category X" but not for "none".
|
||||
const manifest = JSON.stringify([
|
||||
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
|
||||
]);
|
||||
const archiveRelPath = await writeArchive('uncategorized.zip', {
|
||||
'individual/u.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('u.jpg')).toBeNull();
|
||||
// And no junk category row was created as a side effect.
|
||||
const rows = await db('photo_categories').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
|
||||
// photos.filename is not unique within an event: s3AutoImporter takes
|
||||
// path.basename(entry.key) and dedupes by path, so two imported files in
|
||||
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
|
||||
// to the same basename at restore, so keeping the last row seen would give
|
||||
// one photo the other's category.
|
||||
const archiveRelPath = await writeArchive('dup-canonical.zip', {
|
||||
'individual/IMG_1234.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
|
||||
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a name that one row owns canonically and another claims as an alias", async () => {
|
||||
// Undecidable: with original-filename archiving ON the ZIP entry under
|
||||
// this name is the ALIAS owner's file, with it OFF it is the canonical
|
||||
// owner's, and the manifest does not record which mode was used. The
|
||||
// point of the two-pass split is that this now resolves the same way
|
||||
// every run — the archive query has no ORDER BY, so it used to be a coin
|
||||
// flip between dropping the name and overwriting it.
|
||||
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
|
||||
'individual/CANON.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
|
||||
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Falls back to the directory rather than guessing either row.
|
||||
expect(await categoryOf('CANON.jpg')).toBe('individual');
|
||||
for (const name of ['Canonical', 'Aliased']) {
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the lowest id and warns when two categories share a name', async () => {
|
||||
// Allowed: two event-scoped categories with the same display name and
|
||||
// different slugs. .first() used to pick either, so a re-run could move
|
||||
// photos between them and inherit the wrong allow_downloads.
|
||||
const archiveRelPath = await writeArchive('dupe-category.zip', {
|
||||
'individual/DUPE.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
|
||||
|
||||
const [first] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
|
||||
}).returning('id');
|
||||
await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
|
||||
});
|
||||
const firstId = typeof first === 'object' ? first.id : first;
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Stable, not arbitrary: the same run twice lands on the same row.
|
||||
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
|
||||
expect(photo.category_id).toBe(firstId);
|
||||
// And no third "Ceremony" was invented.
|
||||
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('does not invent a category for a photo row that already exists', async () => {
|
||||
// archiveEvent retains photo rows, so a restore can skip every insert.
|
||||
// Resolving categories before that check created one from the stale
|
||||
// manifest name that nothing then used — renaming a category while its
|
||||
// event was archived left the old name behind as an empty duplicate.
|
||||
const archiveRelPath = await writeArchive('existing-rows.zip', {
|
||||
'individual/KEPT.jpg': PIXEL,
|
||||
'photos_manifest.json': Buffer.from(JSON.stringify([
|
||||
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
|
||||
]), 'utf8'),
|
||||
});
|
||||
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-coverage.
|
||||
*
|
||||
* Pins the Stage C diagnostic that tells admins what the next
|
||||
* "Run Backup Now" will include, skip, or silently miss.
|
||||
*
|
||||
* Test surface:
|
||||
* 1. Empty / fresh install → default seed (7 paths), inline mode,
|
||||
* no DB dump on file yet, no drift
|
||||
* 2. Toggle `include_in_default=false` → coverage flips to
|
||||
* 'skipped-by-toggle'
|
||||
* 3. Feature_flag gating reflects the actual app_settings value
|
||||
* (events/archived ⇄ backup_include_archived)
|
||||
* 4. Drift detection: a top-level subdir on disk with no
|
||||
* `backup_paths` row is flagged in `unconfiguredOnDisk`
|
||||
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
|
||||
* 6. Scheduled-only mode + recent dump → `database.ok = true`
|
||||
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
|
||||
* and `lastDumpStale = true`
|
||||
*
|
||||
* Same auth/permission pass-through strategy as
|
||||
* adminBackupIntegrity.test.js — we exercise the route's logic,
|
||||
* not the auth middleware.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkdir(rel) {
|
||||
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
|
||||
}
|
||||
|
||||
function rmdir(rel) {
|
||||
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function restoreDefaultPaths() {
|
||||
await db('backup_paths').del();
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreDefaultPaths();
|
||||
await db('database_backup_runs').del().catch(() => {});
|
||||
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns the canonical 7 paths + database block on a fresh install', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
|
||||
const { report } = res.body;
|
||||
expect(report.paths.map((p) => p.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
|
||||
// Default mode is inline — no inline_dump setting present means
|
||||
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
|
||||
expect(report.database.mode).toBe('inline');
|
||||
expect(report.database.ok).toBe(true);
|
||||
|
||||
expect(report.summary).toMatchObject({
|
||||
configuredCount: 7,
|
||||
tableMissingFallbackInUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
|
||||
expect(thumbnails.coverage).toBe('skipped-by-toggle');
|
||||
expect(thumbnails.includeInDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
|
||||
// backup_include_archived not set → archived skipped via flag
|
||||
const off = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
|
||||
expect(archivedOff.featureFlag).toBe('backup_include_archived');
|
||||
expect(archivedOff.featureFlagValue).toBe(null); // unset
|
||||
|
||||
// Now set the flag — but path is missing on disk, so coverage
|
||||
// resolves to 'missing-on-disk', proving the flag was honoured.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const on = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOn.featureFlagValue).toBe(true);
|
||||
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
|
||||
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
|
||||
});
|
||||
|
||||
it('detects unconfigured top-level subdirs as drift', async () => {
|
||||
mkdir('events/active'); // configured
|
||||
mkdir('plugin-store/cache'); // DRIFT
|
||||
mkdir('shiny-new-feature/data'); // DRIFT
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
|
||||
'plugin-store',
|
||||
'shiny-new-feature',
|
||||
]));
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
|
||||
|
||||
rmdir('plugin-store');
|
||||
rmdir('shiny-new-feature');
|
||||
});
|
||||
|
||||
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
|
||||
mkdir('backups');
|
||||
mkdir('tmp');
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
|
||||
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
|
||||
expect.arrayContaining(['backups', 'tmp']),
|
||||
);
|
||||
|
||||
rmdir('backups');
|
||||
rmdir('tmp');
|
||||
});
|
||||
|
||||
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
|
||||
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
|
||||
fs.writeFileSync(recentDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(), // just now
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: recentDump,
|
||||
file_size_bytes: fs.statSync(recentDump).size,
|
||||
destination_path: recentDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.mode).toBe('scheduled-only');
|
||||
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
|
||||
expect(res.body.report.database.lastDumpStale).toBe(false);
|
||||
expect(res.body.report.database.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
|
||||
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
|
||||
fs.writeFileSync(oldDump, 'pretend old dump');
|
||||
// 48 hours ago — well past the 26h staleness threshold. ISO
|
||||
// string instead of a Date object because knex-sqlite's datetime
|
||||
// serialisation has a quirk where some Date instances coerce to
|
||||
// '[object Object]' on insert (the test 6 "recent dump" case
|
||||
// passes only because `new Date()` happens to round-trip safely;
|
||||
// arithmetic Dates don't).
|
||||
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: stale,
|
||||
completed_at: stale,
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: oldDump,
|
||||
file_size_bytes: fs.statSync(oldDump).size,
|
||||
destination_path: oldDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.lastDumpStale).toBe(true);
|
||||
expect(res.body.report.database.ok).toBe(false);
|
||||
// Top-level summary reflects the failed DB check.
|
||||
expect(res.body.report.summary.databaseOk).toBe(false);
|
||||
expect(res.body.report.summary.overallOk).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-integrity.
|
||||
*
|
||||
* Auth + permission middleware are mocked to pass-through so the test
|
||||
* focuses on the route's own behaviour: scope-param validation, the
|
||||
* successResponse envelope, and that the underlying service report
|
||||
* surfaces correctly in the JSON body.
|
||||
*
|
||||
* The verifier service itself is exercised against the real schema
|
||||
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Pass-through auth so we don't need to mint JWTs.
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Pass-through permissions so settings.view always allows.
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
let db;
|
||||
let customerId;
|
||||
let app;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
// Mount the route on a minimal Express app. Cold-require after
|
||||
// bootCrmDb so the route's downstream `require('../database/db')`
|
||||
// sees the same db instance.
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('contracts').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns a report envelope when nothing references any path', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
expect(res.body.report.summary).toMatchObject({
|
||||
totalRows: 0,
|
||||
missingFiles: 0,
|
||||
hashMismatches: 0,
|
||||
verifiedOk: 0,
|
||||
existsButNoHash: 0,
|
||||
});
|
||||
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
|
||||
it('surfaces a missing file in the response payload', async () => {
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.summary.missingFiles).toBe(1);
|
||||
expect(res.body.report.missing[0]).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the ?scope=invoice filter', async () => {
|
||||
// Seed both an invoice and a contract with missing files. With
|
||||
// scope=invoice the contract row must not appear.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'invoice' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.scopes).toEqual(['invoice']);
|
||||
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown scope with 400 + a code', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'gallery' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
|
||||
expect(res.body.validScopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -1,251 +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();
|
||||
}
|
||||
}));
|
||||
|
||||
// The routes gained requirePermission() after this fixture was written.
|
||||
// It resolves the caller's role through admin_users/roles, which this
|
||||
// minimal schema does not create, so every request died in the RBAC
|
||||
// lookup before reaching the handler. RBAC is not what this suite is
|
||||
// about — stub it out the same way adminAuth already is.
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => 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');
|
||||
// Mirrors migration 041: the upload route never writes this column, it
|
||||
// relies on the NOT NULL DEFAULT 'managed' to mark managed originals.
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
table.string('external_relpath');
|
||||
// Columns the upload insert writes (migrations 048, 062, 071, 085, 193)
|
||||
// and the PATCH handler writes (migration 178). Without them the insert
|
||||
// and the update both fail on "no such column".
|
||||
table.string('original_filename', 512);
|
||||
table.string('source_filename', 255);
|
||||
table.datetime('captured_at').nullable();
|
||||
table.string('media_type').defaultTo('image');
|
||||
table.string('mime_type');
|
||||
table.string('processing_status', 16).notNullable().defaultTo('complete');
|
||||
table.string('upload_id', 64).nullable();
|
||||
table.boolean('auto_categorized');
|
||||
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');
|
||||
|
||||
// 202 Accepted since the upload route went async (851744c3): the files are
|
||||
// stored and a pending row is inserted, thumbnails/EXIF follow in the
|
||||
// background worker. This assertion still said 200 from before that.
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
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();
|
||||
|
||||
// A real id still round-trips — the '0' guard must not swallow it.
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBe(categoryId);
|
||||
|
||||
// Numeric 0 and unparseable input clear the category too, rather than
|
||||
// writing a category id that can never exist.
|
||||
for (const value of [0, 'not-a-category']) {
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: value })
|
||||
.expect(200);
|
||||
expect((await db('photos').where({ id: photo.id }).first()).category_id).toBeNull();
|
||||
|
||||
await db('photos').where({ id: photo.id }).update({ category_id: categoryId });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
|
||||
*
|
||||
* The route used to resolve every source as `storage/events/active/<path>` and
|
||||
* `fs.access` it. External and reference rows do not live there — their
|
||||
* originals sit under `events.external_path` — so every one of them failed the
|
||||
* check and was counted as an error.
|
||||
*
|
||||
* That alone would be inert. What made it destructive is that the tier
|
||||
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
|
||||
* on a reference install the button dropped every ?w= tier and rebuilt
|
||||
* nothing, while the UI reported success — the response is sent before the
|
||||
* background loop starts.
|
||||
*
|
||||
* The background work is fired with setImmediate, so every assertion here has
|
||||
* to wait for it to drain rather than trusting the response.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('admin thumbnail regeneration (#1129)', () => {
|
||||
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage; let logInfo;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
// One instance, not a fresh object per call — the route and the
|
||||
// assertions have to be looking at the same mock.
|
||||
jest.doMock('../../src/services/storage', () => {
|
||||
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
return { getStorage: () => instance };
|
||||
});
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
|
||||
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
|
||||
deleteThumbnailTiers: jest.fn().mockResolvedValue(undefined),
|
||||
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Same module registry as the route, so the spy sees its calls. The
|
||||
// completion line is what drain() below waits for.
|
||||
logInfo = jest.spyOn(require('../../src/utils/logger'), 'info');
|
||||
|
||||
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
|
||||
// success, which ends the jest worker mid-suite.
|
||||
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
imageProcessor = require('../../src/services/imageProcessor');
|
||||
storage = require('../../src/services/storage').getStorage();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
const [row] = await db('events').insert({
|
||||
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
|
||||
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'weddings/2026-08',
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function seedPhoto(eventId, overrides = {}) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
|
||||
type: 'individual', ...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The work runs in setImmediate, after the response. Wait for the loop's
|
||||
* "regeneration complete" log line rather than a fixed 150 ms: under a
|
||||
* loaded machine (fifteen suites in parallel, each booting a migrated
|
||||
* SQLite) the loop occasionally took longer than that, and the assertions
|
||||
* then ran against a half-finished mock call list.
|
||||
*/
|
||||
const drain = async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const done = () => logInfo.mock.calls.some((c) => /regeneration complete/.test(String(c[0])));
|
||||
while (!done() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/stale.jpg',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
expect(res.status).toBe(200);
|
||||
await drain();
|
||||
|
||||
// The whole bug: this used to be zero calls and one logged
|
||||
// "Original file not found" per photo.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'external',
|
||||
external_relpath: 'shot.jpg',
|
||||
thumbnail_path: 'thumbnails/still-on-disk.jpg',
|
||||
});
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
|
||||
// readable — which is the normal case after a settings change, and exactly
|
||||
// when the admin pressed the button.
|
||||
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
|
||||
expect(photoArg.thumbnail_path).toBeNull();
|
||||
expect(photoArg.source_origin).toBe('external');
|
||||
// Carried through so ensureThumbnail can resolve off the mount rather than
|
||||
// under events/active.
|
||||
expect(photoArg.external_relpath).toBe('shot.jpg');
|
||||
});
|
||||
|
||||
it('still drops the responsive tiers first', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'external', external_relpath: 'shot.jpg' });
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// They are keyed by width outside thumbnail_path and carry no settings
|
||||
// version, so leaving them serves the old fit to phones indefinitely.
|
||||
expect(imageProcessor.deleteThumbnailTiers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves videos alone rather than handing a container file to Sharp', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
|
||||
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
|
||||
});
|
||||
|
||||
/**
|
||||
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
|
||||
* and for non-RAW input withProcessableImage passes no outputBasename — so
|
||||
* generateThumbnail derives the key from that random name and it differs on
|
||||
* every run. Nulling thumbnail_path hides the old key from everything that
|
||||
* would otherwise clean it up, so each regeneration would strand a full
|
||||
* thumbnail in the bucket, once per photo per run.
|
||||
*/
|
||||
describe('superseded canonical renditions', () => {
|
||||
it('removes the old thumbnail when the key moved', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
|
||||
});
|
||||
|
||||
it('does NOT delete when the key is unchanged — that is the new file', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_stable.jpg',
|
||||
});
|
||||
// Local storage resolves to a stable path, so the key is identical.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
|
||||
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
|
||||
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
|
||||
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
|
||||
// Both storage backends fold these to the same key, so this is the SAME
|
||||
// object — deleting it would remove the freshly generated thumbnail and
|
||||
// leave the row pointing at nothing.
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
expect(storage.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await seedPhoto(eventId, {
|
||||
source_origin: 'managed',
|
||||
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
|
||||
});
|
||||
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
|
||||
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
|
||||
|
||||
await request(app).post('/admin/thumbnails/regenerate').send({});
|
||||
await drain();
|
||||
|
||||
// Losing the old object is untidy; the regeneration itself succeeded.
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes to one event when asked', async () => {
|
||||
const a = await seedEvent();
|
||||
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
|
||||
const [b] = await db('events').insert({
|
||||
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: 'other-share', expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
|
||||
|
||||
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
|
||||
await drain();
|
||||
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Load services
|
||||
const backupService = require('../../src/services/backupService');
|
||||
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
||||
const { db, initializeDatabase: 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',
|
||||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: 'test-backup-bucket-' + Date.now(),
|
||||
region: 'us-east-1'
|
||||
};
|
||||
|
||||
describe('S3 Backup Integration Tests', () => {
|
||||
let s3Client;
|
||||
let testStoragePath;
|
||||
let originalEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if no S3 endpoint configured
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Initialize S3 client for test setup
|
||||
s3Client = new S3Client({
|
||||
endpoint: TEST_CONFIG.endpoint,
|
||||
region: TEST_CONFIG.region,
|
||||
credentials: {
|
||||
accessKeyId: TEST_CONFIG.accessKeyId,
|
||||
secretAccessKey: TEST_CONFIG.secretAccessKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
// Create test bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
||||
console.error('Failed to create test bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
// Create test storage directory
|
||||
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
|
||||
await fs.mkdir(testStoragePath, { recursive: true });
|
||||
process.env.STORAGE_PATH = testStoragePath;
|
||||
|
||||
// Set up test data
|
||||
await setupTestData();
|
||||
|
||||
// Mock logger to reduce noise
|
||||
if (process.env.UNMOCK_LOGGER !== 'true') {
|
||||
logger.info = jest.fn();
|
||||
logger.debug = jest.fn();
|
||||
logger.warn = jest.fn();
|
||||
logger.error = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
try {
|
||||
// Clean up S3 bucket
|
||||
await cleanupS3Bucket();
|
||||
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
|
||||
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 bucket:', error);
|
||||
}
|
||||
|
||||
// Clean up test storage
|
||||
await fs.rm(testStoragePath, { recursive: true, force: true });
|
||||
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Close database
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean backup tables
|
||||
await db('backup_runs').del();
|
||||
await db('backup_file_states').del();
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
// Configure S3 backup settings
|
||||
await configureS3Backup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Clean up S3 objects created during test
|
||||
await cleanupS3Bucket();
|
||||
});
|
||||
|
||||
describe('S3 Connection and Configuration', () => {
|
||||
it('should successfully connect to S3-compatible storage', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
...TEST_CONFIG,
|
||||
bucket: TEST_CONFIG.bucket,
|
||||
forcePathStyle: true,
|
||||
sslEnabled: false
|
||||
});
|
||||
|
||||
const connected = await s3Adapter.testConnection();
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate S3 configuration before backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Remove required configuration
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_secret_key')
|
||||
.del();
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const lastRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(lastRun.status).toBe('failed');
|
||||
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full S3 Backup Process', () => {
|
||||
it('should perform complete S3 backup with all file types', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify backup run completed
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('completed');
|
||||
// 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);
|
||||
|
||||
// Verify files in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
expect(s3Objects.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for expected file types
|
||||
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
|
||||
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
|
||||
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
|
||||
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
|
||||
|
||||
expect(hasPhotos).toBe(true);
|
||||
expect(hasThumbnails).toBe(true);
|
||||
expect(hasManifest).toBe(true);
|
||||
expect(hasSummary).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large file uploads with multipart', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a large test file (15MB)
|
||||
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
|
||||
const largeFileSize = 15 * 1024 * 1024; // 15MB
|
||||
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
|
||||
await fs.writeFile(largeFilePath, largeFileContent);
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify large file was uploaded
|
||||
const s3Objects = await listS3Objects();
|
||||
const largeFileUploaded = s3Objects.some(obj =>
|
||||
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
|
||||
);
|
||||
|
||||
expect(largeFileUploaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should include database backup when available', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a mock database backup
|
||||
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
|
||||
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
|
||||
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
|
||||
|
||||
// Record database backup
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'sqlite',
|
||||
file_path: dbBackupPath,
|
||||
file_size_bytes: 100,
|
||||
checksum: 'test123',
|
||||
statistics: JSON.stringify({ tables: {} }),
|
||||
table_checksums: JSON.stringify({})
|
||||
});
|
||||
|
||||
// Configure to include database
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_include_database')
|
||||
.update({ setting_value: 'true' });
|
||||
|
||||
// Run backup
|
||||
await backupService.runBackup();
|
||||
|
||||
// Verify database backup in S3
|
||||
const s3Objects = await listS3Objects();
|
||||
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
|
||||
expect(hasDbBackup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Incremental Backup', () => {
|
||||
it('should only upload changed files in incremental backup', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// First backup - full
|
||||
await backupService.runBackup();
|
||||
|
||||
const firstRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const firstObjectCount = (await listS3Objects()).length;
|
||||
|
||||
// Wait a moment to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Modify one file
|
||||
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
|
||||
await fs.writeFile(modifiedFile, 'modified content');
|
||||
|
||||
// Second backup - incremental
|
||||
await backupService.runBackup();
|
||||
|
||||
const secondRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(secondRun.id).not.toBe(firstRun.id);
|
||||
expect(Number(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.
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it('should track file states across backups', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
// Check file states are recorded
|
||||
const fileStates = await db('backup_file_states').select('*');
|
||||
expect(fileStates.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify checksums are stored
|
||||
const hasChecksums = fileStates.every(state => state.checksum !== null);
|
||||
expect(hasChecksums).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 Manifest Storage', () => {
|
||||
it('should upload manifest to S3 and retrieve it', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure YAML manifest format
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_manifest_format')
|
||||
.update({ setting_value: '"yaml"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
|
||||
|
||||
// Retrieve manifest
|
||||
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
|
||||
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest.backup.id).toBeDefined();
|
||||
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
|
||||
});
|
||||
|
||||
it('should validate manifest integrity', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
|
||||
|
||||
expect(validationResult.valid).toBe(true);
|
||||
expect(validationResult.manifest).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle S3 connection failures gracefully', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Configure with invalid endpoint
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'backup_s3_endpoint')
|
||||
.update({ setting_value: '"http://invalid-endpoint:9999"' });
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
expect(backupRun.status).toBe('failed');
|
||||
expect(backupRun.error_message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should continue backup despite individual file failures', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Create a file that will be deleted during backup
|
||||
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
|
||||
await fs.writeFile(tempFile, 'temporary');
|
||||
|
||||
// Mock file deletion during backup
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let callCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
// Delete the temp file to cause an error
|
||||
await fs.unlink(tempFile).catch(() => {});
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should complete despite one file error
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(backupRun.files_backed_up).toBeGreaterThan(0);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
|
||||
it('should retry failed uploads with exponential backoff', async () => {
|
||||
if (process.env.SKIP_S3_TESTS === 'true') return;
|
||||
|
||||
// Mock S3 upload to fail twice then succeed
|
||||
const originalUpload = S3StorageAdapter.prototype.upload;
|
||||
let attemptCount = 0;
|
||||
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
|
||||
attemptCount++;
|
||||
if (attemptCount <= 2) {
|
||||
const error = new Error('Network timeout');
|
||||
error.code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
}
|
||||
return originalUpload.call(this, localPath, s3Key, options);
|
||||
});
|
||||
|
||||
await backupService.runBackup();
|
||||
|
||||
const backupRun = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.first();
|
||||
|
||||
// Should succeed after retries
|
||||
expect(backupRun.status).toBe('completed');
|
||||
expect(attemptCount).toBeGreaterThan(2);
|
||||
|
||||
// Restore original method
|
||||
S3StorageAdapter.prototype.upload = originalUpload;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
async function setupTestData() {
|
||||
// Create test directory structure
|
||||
const dirs = [
|
||||
'events/active/event1',
|
||||
'events/active/event2',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'uploads'
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
|
||||
}
|
||||
|
||||
// Create test files
|
||||
const files = [
|
||||
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
|
||||
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
|
||||
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
|
||||
{ path: 'events/archived/old-event.zip', content: 'archived content' },
|
||||
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
|
||||
{ path: 'uploads/logo.png', content: 'logo content' }
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
await fs.writeFile(
|
||||
path.join(testStoragePath, file.path),
|
||||
file.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureS3Backup() {
|
||||
const settings = [
|
||||
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
|
||||
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
|
||||
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
|
||||
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: 'true' },
|
||||
{ setting_key: 'backup_incremental', setting_value: 'true' },
|
||||
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
|
||||
];
|
||||
|
||||
// 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(),
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
}
|
||||
}
|
||||
|
||||
async function listS3Objects() {
|
||||
const response = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: TEST_CONFIG.bucket
|
||||
}));
|
||||
return response.Contents || [];
|
||||
}
|
||||
|
||||
async function cleanupS3Bucket() {
|
||||
try {
|
||||
const objects = await listS3Objects();
|
||||
if (objects.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: TEST_CONFIG.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map(obj => ({ Key: obj.Key }))
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Backup credential exposure regression tests.
|
||||
*
|
||||
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
||||
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
||||
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
||||
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
||||
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
||||
* form round-trips without clobbering stored credentials.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'test-admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
requireSuperAdmin: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
describe('backup credential masking', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
||||
const seed = [
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
||||
];
|
||||
for (const row of seed) {
|
||||
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('masks the credentials in GET /admin/backup/config', async () => {
|
||||
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
// Non-secret fields stay readable for the form.
|
||||
expect(res.body.backup_s3_bucket).toBe('backups');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_endpoint: 'https://s3.example.com',
|
||||
backup_s3_bucket: 'renamed-bucket',
|
||||
backup_s3_access_key: 'AKIAEXAMPLE',
|
||||
backup_s3_secret_key: '••••••••',
|
||||
backup_rsync_ssh_key: '••••••••',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
||||
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
||||
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
||||
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
||||
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Regression net for the business-docs coverage gap fixed in this PR.
|
||||
*
|
||||
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
|
||||
* list of storage subdirectories (events/active, events/archived,
|
||||
* thumbnails, previews, heroes, uploads) and silently omitted the
|
||||
* entire `business-docs/` tree. That meant every CRM PDF + signature
|
||||
* drawing — quotes, contracts (system-rendered + wet uploads),
|
||||
* invoices, Storno, imported historical invoices, and the customer
|
||||
* signature PNG/JPG drawn on the public signing page — fell outside
|
||||
* the in-app scheduled backup, leaving every `*_path` column on
|
||||
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
|
||||
*
|
||||
* The fix is a single `scanDirectory(business-docs, ...)` call. This
|
||||
* suite pins the contract so a future refactor of the walker cannot
|
||||
* silently drop business-docs again.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('backupService — business-docs is in the backup walker', () => {
|
||||
let cleanup;
|
||||
let backupService;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
// Cold-require after bootCrmDb so backupService picks up the same
|
||||
// db instance + STORAGE_PATH the test harness configured.
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seed(relPath, content = 'dummy bytes for backup test') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
it('does not error when business-docs is absent', async () => {
|
||||
// Fresh harness has no business-docs/ tree at all. The walker
|
||||
// must short-circuit on ENOENT rather than throw — installs that
|
||||
// never used CRM features have to keep backing up fine.
|
||||
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
|
||||
});
|
||||
|
||||
it('picks up every CRM-relevant business-docs subdirectory', async () => {
|
||||
// Seed one file in each of the five subpaths the renderer + import
|
||||
// routes write to. The signature path is the one most prone to be
|
||||
// forgotten — it lives one level deeper than the others (per-
|
||||
// contract subfolder, not per-year).
|
||||
seed('business-docs/quote/2026/Q-001.pdf');
|
||||
seed('business-docs/contract/2026/C-001.pdf');
|
||||
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
|
||||
seed('business-docs/invoice/2026/INV-001.pdf');
|
||||
seed('business-docs/invoice-imports/2026/scan.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
'business-docs/contract/2026/C-001.pdf',
|
||||
'business-docs/contract/signatures/42/customer-1700000000000.png',
|
||||
'business-docs/invoice/2026/INV-001.pdf',
|
||||
'business-docs/invoice-imports/2026/scan.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('walks newly-created business-docs files without needing a restart', async () => {
|
||||
// The walker reads the filesystem live on every call; this guards
|
||||
// against a future "cache the scan result at boot" optimisation
|
||||
// that would miss freshly-written PDFs (which is exactly what
|
||||
// happens during normal operation — every send writes a new file).
|
||||
seed('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup(false);
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* Pins the Stage-B refactor that lifted the file-backup walker's
|
||||
* subdirectory list out of hard-coded JS into the `backup_paths`
|
||||
* table seeded by migration 109.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. Walker reads canonical seed → all 7 default subdirs walked
|
||||
* 2. include_in_default=false on one row → that subdir is skipped
|
||||
* 3. New row inserted at runtime → walker picks it up without restart
|
||||
* 4. feature_flag gating → row only walked when the named app_settings
|
||||
* boolean is truthy (mirrors historical `includeArchived` behavior)
|
||||
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
|
||||
* in depth — never silently scans nothing)
|
||||
*
|
||||
* Why not stub `db('backup_paths')`: the whole point of Stage B is
|
||||
* that the walker is now data-driven, so the test has to actually
|
||||
* mutate the table and observe the walker's output change. Stubs
|
||||
* would re-introduce the hard-coding the refactor is meant to remove.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — configurable walker (backup_paths)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Restore canonical seed before every test. Tests mutate this table
|
||||
// freely; the next test starts from a known state.
|
||||
await db('backup_paths').del();
|
||||
const {
|
||||
DEFAULT_PATHS,
|
||||
} = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
});
|
||||
|
||||
it('migration 109 seeds the canonical 7 paths', async () => {
|
||||
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
|
||||
expect(rows.map((r) => r.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
// Only events/archived is gated by a feature flag.
|
||||
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
|
||||
'events/archived',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks every default subdir when files are present', async () => {
|
||||
seedFile('events/active/E1/a.jpg');
|
||||
seedFile('thumbnails/E1/a.jpg');
|
||||
seedFile('previews/E1/a.jpg');
|
||||
seedFile('heroes/E1/hero.jpg');
|
||||
seedFile('uploads/intake/x.bin');
|
||||
seedFile('business-docs/quote/2026/Q-001.pdf');
|
||||
// events/archived is gated — left out of this test; covered below.
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toEqual(expect.arrayContaining([
|
||||
'events/active/E1/a.jpg',
|
||||
'thumbnails/E1/a.jpg',
|
||||
'previews/E1/a.jpg',
|
||||
'heroes/E1/hero.jpg',
|
||||
'uploads/intake/x.bin',
|
||||
'business-docs/quote/2026/Q-001.pdf',
|
||||
]));
|
||||
});
|
||||
|
||||
it('skips a path when include_in_default is toggled off', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('picks up a new path inserted at runtime — no restart needed', async () => {
|
||||
// Simulates a future feature shipping its own subdirectory and
|
||||
// self-healing a `backup_paths` row at boot.
|
||||
await db('backup_paths').insert({
|
||||
path: 'plugin-store',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 200,
|
||||
description: 'Hypothetical future feature payload',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
seedFile('plugin-store/cache/payload.bin');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('plugin-store/cache/payload.bin');
|
||||
});
|
||||
|
||||
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
|
||||
seedFile('events/active/E1/active.jpg');
|
||||
seedFile('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=false → archived/ is skipped.
|
||||
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
|
||||
const relsOff = filesOff.map((f) => f.relativePath);
|
||||
expect(relsOff).toContain('events/active/E1/active.jpg');
|
||||
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
|
||||
|
||||
// backup_include_archived=true → archived/ is included.
|
||||
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const relsOn = filesOn.map((f) => f.relativePath);
|
||||
expect(relsOn).toContain('events/archived/E2/archived.jpg');
|
||||
});
|
||||
|
||||
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
|
||||
// Defense in depth: even if seed-and-self-heal both failed, the
|
||||
// walker must still cover the historical set so "Run Backup Now"
|
||||
// cannot silently degrade to no-op.
|
||||
await db('backup_paths').del();
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('business-docs/quote/2026/Q-002.pdf');
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
|
||||
});
|
||||
|
||||
it('legacy boolean call signature still works (backward compat)', async () => {
|
||||
// Existing call sites (and the businessDocs regression test) pass
|
||||
// a boolean for `includeArchived`. Refactor must not break them.
|
||||
seedFile('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOff = await backupService.getFilesToBackup(false);
|
||||
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
|
||||
|
||||
const filesOn = await backupService.getFilesToBackup(true);
|
||||
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
||||
});
|
||||
|
||||
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
|
||||
describe('UI opt-out toggles (issue #871)', () => {
|
||||
it('unchecking Thumbnails excludes thumbnails/', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_thumbnails: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
||||
});
|
||||
|
||||
it('unchecking Photos excludes events/active', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_photos: false,
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it('defaults to including everything when the keys were never saved', async () => {
|
||||
seedFile('thumbnails/E1/thumb.jpg');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
});
|
||||
|
||||
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
|
||||
seedFile('events/archived/E4/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archives: true,
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
|
||||
});
|
||||
|
||||
it('the UI plural key beats the migration-seeded singular key', async () => {
|
||||
// Migration seeds backup_include_archived=true on every install; the
|
||||
// form only ever writes the plural key, so unchecking Archives must
|
||||
// win over the stale seeded value.
|
||||
seedFile('events/archived/E5/archived.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_include_archived: true, // seeded default
|
||||
backup_include_archives: false, // what the admin actually chose
|
||||
});
|
||||
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
|
||||
});
|
||||
|
||||
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({
|
||||
backup_include_thumbnails: false,
|
||||
backup_include_archives: false,
|
||||
});
|
||||
expect(excluded.map((r) => r.path)).toEqual(
|
||||
expect.arrayContaining(['thumbnails', 'events/archived'])
|
||||
);
|
||||
|
||||
const args = backupService.buildRsyncArgs(
|
||||
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
|
||||
excluded.map((r) => `/${r.path}/`)
|
||||
);
|
||||
const excludes = args
|
||||
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
|
||||
.filter(Boolean);
|
||||
expect(excludes).toEqual(expect.arrayContaining([
|
||||
'.nfs*',
|
||||
'/thumbnails/',
|
||||
'/events/archived/',
|
||||
]));
|
||||
});
|
||||
|
||||
it('rows toggled off via include_in_default also become rsync excludes', async () => {
|
||||
// The enabled-only loader hides these rows from the walker, but rsync
|
||||
// syncs the whole storage root, so they must still appear as excludes.
|
||||
await db('backup_paths').where('path', 'previews').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const excluded = await backupService.resolveExcludedBackupPaths({});
|
||||
expect(excluded.map((r) => r.path)).toContain('previews');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
|
||||
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
|
||||
seedFile('thumbnails/E1/.nfs000000000000006600000008');
|
||||
seedFile('events/active/E1/.DS_Store');
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
|
||||
const files = await backupService.getFilesToBackup({});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
|
||||
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
|
||||
});
|
||||
|
||||
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
|
||||
seedFile('events/active/E1/photo.jpg');
|
||||
seedFile('events/active/E1/scratch.tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
expect(rels).toContain('events/active/E1/photo.jpg');
|
||||
expect(rels).not.toContain('events/active/E1/scratch.tmp');
|
||||
});
|
||||
|
||||
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
|
||||
seedFile('events/active/E1/anfs-photo.jpg');
|
||||
seedFile('events/active/E1/notes-tmp');
|
||||
|
||||
const files = await backupService.getFilesToBackup({
|
||||
backup_exclude_patterns: ['*.tmp'],
|
||||
});
|
||||
const rels = files.map((f) => f.relativePath);
|
||||
|
||||
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
|
||||
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
|
||||
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
|
||||
expect(rels).toContain('events/active/E1/notes-tmp');
|
||||
});
|
||||
|
||||
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
|
||||
// "next backup" was a hardcoded "tomorrow 02:00".
|
||||
describe('schedule resolution + next run (issue #871)', () => {
|
||||
it('a named label beats the stray default cron the UI used to send', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
|
||||
})).toBe('0 3 * * 0');
|
||||
});
|
||||
|
||||
it('custom schedules use the cron field', () => {
|
||||
expect(backupService.resolveScheduleCron({
|
||||
backup_schedule: 'custom',
|
||||
backup_schedule_cron: '15 5 * * 2',
|
||||
})).toBe('15 5 * * 2');
|
||||
});
|
||||
|
||||
it('falls back to the default daily cron', () => {
|
||||
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
|
||||
});
|
||||
|
||||
it('getNextScheduledRun is null when backups are disabled', () => {
|
||||
expect(backupService.getNextScheduledRun(null)).toBeNull();
|
||||
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('getNextScheduledRun returns the real next weekly fire time', () => {
|
||||
const iso = backupService.getNextScheduledRun({
|
||||
backup_enabled: true,
|
||||
backup_schedule: 'weekly',
|
||||
backup_schedule_cron: '0 3 * * *',
|
||||
});
|
||||
const next = new Date(iso);
|
||||
expect(Number.isNaN(next.getTime())).toBe(false);
|
||||
expect(next.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(next.getDay()).toBe(0); // Sunday
|
||||
expect(next.getHours()).toBe(3); // 03:00
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
|
||||
// column, node-postgres returns int8 as a string, and the S3 path did
|
||||
// `backedUpSize += size` — string concatenation.
|
||||
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
backup_type: 'full',
|
||||
status: 'completed',
|
||||
file_path: '/backups/db/dump.sql.gz',
|
||||
// Simulate the PG int8-as-string driver behaviour (sqlite stores
|
||||
// whatever it is handed, so the string round-trips).
|
||||
file_size_bytes: '421988',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const info = await backupService.getDatabaseBackupInfo();
|
||||
expect(typeof info.size).toBe('number');
|
||||
expect(info.size).toBe(421988);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
|
||||
*
|
||||
* The previous behaviour was: file-backup looked up an existing dump via
|
||||
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
|
||||
* when none was found. Admins clicking "Run Backup Now" got an apparent
|
||||
* success that omitted every customer / quote / invoice / contract row —
|
||||
* the data-loss footgun that this commit closes.
|
||||
*
|
||||
* Five scenarios under test:
|
||||
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
|
||||
* 2. Default, dump throws → run aborts, backup_runs row marked failed
|
||||
* 3. Opt-out + recent DB dump available → backup proceeds
|
||||
* 4. Opt-out + no DB dump available → fail loud
|
||||
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
|
||||
*
|
||||
* Mocking strategy: the underlying `databaseBackupService.backup()` and
|
||||
* the local-destination writer are stubbed so the test exercises just
|
||||
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
|
||||
* binaries being available in the test environment.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
|
||||
const mockBackupFn = jest.fn();
|
||||
jest.mock('../../src/services/databaseBackup', () => ({
|
||||
databaseBackupService: { backup: mockBackupFn },
|
||||
startScheduledBackups: jest.fn(),
|
||||
stopScheduledBackups: jest.fn(),
|
||||
DatabaseBackupService: class {},
|
||||
}));
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — inline DB dump + fail-loud guard', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let dumpFileAbs;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
|
||||
// Seed backup destination settings so the run can proceed past the
|
||||
// "destination not configured" guard.
|
||||
const dest = path.join(storagePath, 'backups');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
// getBackupConfigInternal filters by setting_type='backup', so the
|
||||
// tests have to seed with that type or the resolver returns
|
||||
// `{ ... }` with the keys missing — runBackup then sees
|
||||
// `backup_destination_type === undefined` and bails before our
|
||||
// new guard runs.
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
|
||||
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
|
||||
// Reused/mutated per-test via the database_backup_runs seed below.
|
||||
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
|
||||
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
|
||||
|
||||
// Neutralise the file-scan step: we don't care which files would
|
||||
// be backed up, just whether the run reaches that stage at all.
|
||||
backupService.getFilesToBackup = jest.fn(async () => []);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockBackupFn.mockReset();
|
||||
// Default to "dump produced this file with this size" — the per-test
|
||||
// setup overrides as needed.
|
||||
mockBackupFn.mockResolvedValue({
|
||||
success: true,
|
||||
path: dumpFileAbs,
|
||||
size: fs.statSync(dumpFileAbs).size,
|
||||
duration: 1,
|
||||
checksum: 'abc',
|
||||
});
|
||||
|
||||
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
|
||||
// resolves against (its query is `status='completed'` + most recent).
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: dumpFileAbs,
|
||||
file_size_bytes: fs.statSync(dumpFileAbs).size,
|
||||
destination_path: dumpFileAbs,
|
||||
});
|
||||
});
|
||||
|
||||
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
|
||||
// Inline-dump setting is unset (undefined) — default is ON.
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
expect(run.error_message).toBeNull();
|
||||
});
|
||||
|
||||
it('aborts the run when the inline dump throws', async () => {
|
||||
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
|
||||
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/pg_dump segfaulted/);
|
||||
});
|
||||
|
||||
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
expect(mockBackupFn).not.toHaveBeenCalled();
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('opt-out + no recent dump: fails loud with a clear error', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
|
||||
await db('database_backup_runs').del();
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/No database backup available/);
|
||||
});
|
||||
|
||||
it('opt-out + 0-byte dump file: fails loud', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
|
||||
fs.writeFileSync(emptyDump, '');
|
||||
await db('database_backup_runs').del();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: emptyDump,
|
||||
file_size_bytes: 0,
|
||||
destination_path: emptyDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('failed');
|
||||
expect(run.error_message).toMatch(/is empty/);
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
|
||||
*
|
||||
* Pins the new `computePerPathStats` logic that the Backup History
|
||||
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
|
||||
*
|
||||
* Three scenarios:
|
||||
* 1. Single file under one path — straightforward attribution
|
||||
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
|
||||
* (e.g. `events/active/E1/x.jpg` should attribute to
|
||||
* `events/active`, not `events`)
|
||||
* 3. File outside any configured path — silently dropped, doesn't
|
||||
* throw or contaminate other buckets
|
||||
*
|
||||
* Tests exercise the EXPORTED side: write a backup_runs row via the
|
||||
* service entry point and assert the statistics JSON shape. We don't
|
||||
* stub `computePerPathStats` directly — the integration view is what
|
||||
* the frontend actually consumes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — per-Stage-B-path statistics', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
function mkFile(rel, content = 'x'.repeat(100)) {
|
||||
const abs = path.join(storagePath, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean slate of any artefacts from prior tests
|
||||
await db('backup_runs').del();
|
||||
await db('app_settings').where('setting_type', 'backup').del();
|
||||
await db('app_settings').insert([
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
|
||||
]).onConflict('setting_key').merge();
|
||||
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
|
||||
|
||||
// Restore canonical backup_paths from migration 109
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
|
||||
await db('backup_paths').del();
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
|
||||
// Wipe leftover files between tests
|
||||
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
|
||||
const p = path.join(storagePath, dir);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes files to their owning backup_paths row', async () => {
|
||||
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
|
||||
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
|
||||
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
|
||||
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
|
||||
|
||||
// Disable the inline DB dump so we don't need pg_dump in tests;
|
||||
// the file walker is what produces per_path.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
expect(run.status).toBe('completed');
|
||||
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
expect(statsRaw.per_path).toBeDefined();
|
||||
|
||||
// events/active should have 2 files (3000 bytes)
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
|
||||
// business-docs should have 1 file (500 bytes)
|
||||
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
|
||||
// thumbnails should have 1 file (50 bytes)
|
||||
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
|
||||
|
||||
// No spurious buckets for paths that had nothing
|
||||
expect(statsRaw.per_path['previews']).toBeUndefined();
|
||||
expect(statsRaw.per_path['heroes']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('archived path attributed separately from active when both have files', async () => {
|
||||
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
|
||||
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
|
||||
|
||||
// backup_include_archived already set true in beforeEach so the
|
||||
// archived walker fires; same opt-out for inline DB dump.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
|
||||
fs.writeFileSync(fakeDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: fakeDump,
|
||||
file_size_bytes: fs.statSync(fakeDump).size,
|
||||
destination_path: fakeDump,
|
||||
});
|
||||
|
||||
await backupService.runBackup(true);
|
||||
|
||||
const run = await db('backup_runs').orderBy('id', 'desc').first();
|
||||
const statsRaw = typeof run.statistics === 'string'
|
||||
? JSON.parse(run.statistics)
|
||||
: run.statistics;
|
||||
|
||||
// events/active and events/archived attribute separately —
|
||||
// longest-prefix match prevents `events/active/...` from claiming
|
||||
// an `events/archived/...` file or vice versa.
|
||||
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
|
||||
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE on walker duplication
|
||||
//
|
||||
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
|
||||
// another at `events/active`), the walker scans the same files twice
|
||||
// — once via each path. Per-path stats then attribute the file to the
|
||||
// longest-prefix-matching path BOTH times, producing inflated counts.
|
||||
//
|
||||
// The canonical seed in migration 109 contains no overlapping pairs,
|
||||
// so this isn't exercised in practice. But an admin who hand-adds a
|
||||
// broad row that overlaps an existing nested one will see double
|
||||
// counts in their next backup's statistics + the destination will
|
||||
// receive duplicate copies (wasting space). Worth flagging if anyone
|
||||
// reports it — the fix is to de-dupe `files` in
|
||||
// `getFilesToBackupInternal` before returning, OR to skip walking a
|
||||
// path if a longer one has already covered it.
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
|
||||
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
|
||||
* never auto-sends them before the workflow's review gate + explicit
|
||||
* send_document.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('booking cutover — draft invoices on hold', () => {
|
||||
let db; let cleanup; let adminId; let customerId; let quoteService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
async function acceptedQuote() {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
|
||||
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
|
||||
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
|
||||
});
|
||||
|
||||
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled');
|
||||
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
|
||||
});
|
||||
|
||||
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(res.invoiceIds)).toBe(true);
|
||||
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const ev = await db('events').where({ id: res.eventId }).first();
|
||||
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
|
||||
|
||||
// Every invoice the event scheduled is held (no auto-send before the gate).
|
||||
const invs = await db('invoices').whereIn('id', res.invoiceIds);
|
||||
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
|
||||
|
||||
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
|
||||
// again for it (the flow's prepare_invoice adopts these ids instead).
|
||||
const q = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(q.converted_event_id).toBe(res.eventId);
|
||||
});
|
||||
|
||||
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
|
||||
// Reproduces the booking_invoice_only flow on a quote with no explicit
|
||||
// payment timing: the default installment is after_delivery, which would
|
||||
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [quoteId] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF',
|
||||
issue_date: '2026-01-01',
|
||||
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
|
||||
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
|
||||
// 100% after_delivery installment.
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
|
||||
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
|
||||
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
|
||||
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
||||
});
|
||||
|
||||
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
|
||||
const mk = async (lockOffsetMs) => {
|
||||
const dealUuid = crypto.randomUUID();
|
||||
const [id] = await db('quotes').insert({
|
||||
quote_number: `Q-${dealUuid.slice(0, 8)}`,
|
||||
customer_account_id: customerId,
|
||||
status: 'accepted',
|
||||
currency: 'CHF', issue_date: '2026-01-01',
|
||||
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
|
||||
responded_at: new Date().toISOString(),
|
||||
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
|
||||
accepted_at: new Date().toISOString(),
|
||||
deal_uuid: dealUuid,
|
||||
created_by_admin_id: adminId,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const openId = await mk(15 * 60 * 1000); // still inside the window
|
||||
const lockedId = await mk(-60 * 1000); // window already closed
|
||||
|
||||
const emitted = await quoteService.finalizeQuoteResponses();
|
||||
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const open = await db('quotes').where({ id: openId }).first();
|
||||
const locked = await db('quotes').where({ id: lockedId }).first();
|
||||
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
|
||||
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
|
||||
|
||||
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
|
||||
const again = await db('quotes').where({ id: lockedId })
|
||||
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
|
||||
expect(again).toBe(0);
|
||||
});
|
||||
|
||||
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
||||
expect(res.eventId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.invoiceIds).toEqual([]);
|
||||
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
|
||||
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
|
||||
});
|
||||
|
||||
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
|
||||
const quoteId = await acceptedQuote();
|
||||
const newId = await quoteService.duplicateQuote(quoteId, adminId);
|
||||
expect(newId).toBeGreaterThanOrEqual(1);
|
||||
expect(newId).not.toBe(quoteId);
|
||||
const q = await db('quotes').where({ id: newId }).first();
|
||||
expect(q.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
|
||||
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
|
||||
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
|
||||
expect(typeof registry.getAction(a)).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
|
||||
const contractService = require('../../src/services/contractService');
|
||||
const quoteId = await acceptedQuote();
|
||||
const res = await contractService.createFromQuote(quoteId, adminId);
|
||||
expect(res.contractId).toBeGreaterThanOrEqual(1);
|
||||
expect(res.alreadyConverted).toBe(false);
|
||||
const c = await db('contracts').where({ id: res.contractId }).first();
|
||||
expect(c).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* PUT /api/admin/business-profile — email signature fields (migration 198).
|
||||
*
|
||||
* The two new columns are boolean + free text, which is exactly the shape
|
||||
* that goes wrong quietly: `optional({ values: 'falsy' })` on the boolean
|
||||
* would silently drop `false`, leaving the admin unable to switch the
|
||||
* signature back off. The round-trip below is what pins that.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bpsig-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bpsig-route-test-secret';
|
||||
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
describe('business profile — email signature round-trip', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let token;
|
||||
|
||||
const put = (payload) => request(app)
|
||||
.put('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(payload);
|
||||
|
||||
const get = () => request(app)
|
||||
.get('/api/admin/business-profile')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// GET returns the snapshot at the top level; PUT wraps it in
|
||||
// successResponse's `data` envelope. Read either.
|
||||
const profileOf = (res) => (res.body.data || res.body).profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('defaults to off with an empty legal line', async () => {
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const profile = profileOf(res);
|
||||
expect(profile.emailSignatureEnabled).toBe(false);
|
||||
expect(profile.emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('persists the toggle and the legal line', async () => {
|
||||
const res = await put({
|
||||
emailSignatureEnabled: true,
|
||||
emailSignatureExtra: 'Handelsregister Vaduz FL-0002.123.456-7',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const profile = profileOf(await get());
|
||||
expect(profile.emailSignatureEnabled).toBe(true);
|
||||
expect(profile.emailSignatureExtra).toBe('Handelsregister Vaduz FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('switches the toggle back off — `false` is not dropped as falsy', async () => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
const res = await put({ emailSignatureEnabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the legal line with an empty string', async () => {
|
||||
await put({ emailSignatureExtra: 'something' });
|
||||
const res = await put({ emailSignatureExtra: '' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace off the legal line', async () => {
|
||||
await put({ emailSignatureExtra: ' Registered in Vaduz ' });
|
||||
expect(profileOf(await get()).emailSignatureExtra).toBe('Registered in Vaduz');
|
||||
});
|
||||
|
||||
// Codex review: express-validator's isBoolean() accepts the STRINGS
|
||||
// 'false' and '0', and Boolean('false') is true — so a form-encoded client
|
||||
// trying to switch the signature OFF switched it on instead.
|
||||
it.each([['false'], ['0']])('treats the string %s as off, not on', async (value) => {
|
||||
await put({ emailSignatureEnabled: true });
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([['true'], ['1']])('treats the string %s as on', async (value) => {
|
||||
await put({ emailSignatureEnabled: false });
|
||||
const res = await put({ emailSignatureEnabled: value });
|
||||
expect(res.status).toBe(200);
|
||||
expect(profileOf(await get()).emailSignatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean toggle and a legal line over 500 chars', async () => {
|
||||
expect((await put({ emailSignatureEnabled: 'yes please' })).status).toBe(400);
|
||||
expect((await put({ emailSignatureExtra: 'x'.repeat(501) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not let an unmapped column ride in on the payload', async () => {
|
||||
// ALLOWED_PROFILE_FIELDS is the whitelist; the route's camel→snake map
|
||||
// is the second gate. Neither should pass a raw snake_case key through.
|
||||
const before = await db('business_profile').where({ id: 1 }).first();
|
||||
await put({ email_signature_enabled: true, id: 999 });
|
||||
const after = await db('business_profile').where({ id: 1 }).first();
|
||||
|
||||
expect(after.id).toBe(before.id);
|
||||
expect(after.email_signature_enabled).toBe(before.email_signature_enabled);
|
||||
});
|
||||
|
||||
it('invalidates the wrapper signature cache on write', async () => {
|
||||
const { wrapEmailHtml } = require('../../src/services/emailProcessor');
|
||||
|
||||
await put({ emailSignatureEnabled: false });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).not.toContain('Bahnhofstrasse 9');
|
||||
|
||||
// Same request cycle, well inside the 60 s memo window: the PUT must
|
||||
// clear the cache or the operator sees a stale footer for a minute.
|
||||
await put({ emailSignatureEnabled: true, addressLine1: 'Bahnhofstrasse 9' });
|
||||
expect(await wrapEmailHtml('<p>x</p>', 'S')).toContain('Bahnhofstrasse 9');
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* Backfilling captured_at on a library imported before #1172.
|
||||
*
|
||||
* The point of the endpoint, rather than a migration: it resolves originals
|
||||
* through resolvePhotoFilePath, which is the only path that reaches an
|
||||
* external row. The thumbnail regenerator resolves under
|
||||
* storage/events/active/<photo.path>, which never exists for those (#1129) —
|
||||
* so it cannot be the model.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('capture date backfill (#1172)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
|
||||
const writeJpegWithExif = async (abs, iso) => {
|
||||
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
|
||||
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
|
||||
};
|
||||
|
||||
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
|
||||
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference', external_path: 'trip', is_archived: archived,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
|
||||
// Root-relative, as this branch stores it (#1163) — the file lives at
|
||||
// <mediaRoot>/trip/<relpath>.
|
||||
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
|
||||
uploaded_at: new Date().toISOString(), captured_at: null,
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
|
||||
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult.success).toBe(1);
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts a photo with no EXIF separately from a failure', async () => {
|
||||
// "The mount is broken" and "these files carry no date" need different
|
||||
// answers from an operator, so they are not the same number.
|
||||
await db('photos').del(); await db('events').del();
|
||||
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
|
||||
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
|
||||
|
||||
await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
|
||||
});
|
||||
|
||||
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
|
||||
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
|
||||
await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
const done = await settle();
|
||||
|
||||
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
|
||||
});
|
||||
|
||||
it('reports nothing to do once every photo has a date', async () => {
|
||||
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
|
||||
expect(res.body.count).toBe(0);
|
||||
expect((await status()).body.withoutCaptureDate).toBe(0);
|
||||
});
|
||||
|
||||
it('skips a watcher-imported video, which carries media_type "image"', async () => {
|
||||
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
|
||||
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
|
||||
// default from migration 048. Filtering on media_type alone queued it every
|
||||
// run: extractCaptureDate returns null for a video, captured_at stays null,
|
||||
// and the backlog never cleared.
|
||||
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
|
||||
await db('photos').del();
|
||||
await db('photos').insert({
|
||||
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
|
||||
type: 'video', media_type: 'image', mime_type: 'video/mp4',
|
||||
source_origin: 'external', external_relpath: 'trip/clip.mp4',
|
||||
uploaded_at: new Date().toISOString(), captured_at: null,
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(0);
|
||||
|
||||
const s = await status();
|
||||
// And it is not counted as a permanent backlog either.
|
||||
expect(s.body.total).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
});
|
||||
|
||||
it('never reports more dated photos than it has photos', async () => {
|
||||
// Both counts come from one aggregate; as two queries an import committing
|
||||
// between them produced withCaptureDate > total and a negative backlog.
|
||||
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
|
||||
|
||||
const s = await status();
|
||||
expect(s.body.total).toBe(1);
|
||||
expect(s.body.withCaptureDate).toBe(1);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('skips archived events instead of failing them on every run', async () => {
|
||||
// Archiving deletes the originals and keeps the rows, so an archived photo
|
||||
// can never get a date. Counting it would fail it every pass and leave the
|
||||
// status endpoint permanently reporting a backlog.
|
||||
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
|
||||
expect(res.body.count).toBe(0);
|
||||
const s = await status();
|
||||
expect(s.body.total).toBe(0);
|
||||
expect(s.body.withoutCaptureDate).toBe(0);
|
||||
expect(s.body.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('does not overwrite a date written while it was running', async () => {
|
||||
// whereNull on the update: an import or a replacement finishing mid-run has
|
||||
// already written a better value than this pass would.
|
||||
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
|
||||
const claimed = '2020-01-01T00:00:00.000Z';
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(1);
|
||||
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
|
||||
const done = await settle();
|
||||
|
||||
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Read but not written, so it is accounted for rather than dropped.
|
||||
expect(done.body.lastResult.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
|
||||
// replacePhoto swaps a NEW file under an existing row and rewrites
|
||||
// path/filename (reachable from replace_by_name). The replacement carries
|
||||
// no date of its own, so captured_at is still NULL and the whereNull guard
|
||||
// alone would let the previous file's EXIF date land on it. The write is
|
||||
// fenced on the identity that was read, so the row is skipped instead —
|
||||
// and not counted as updated either.
|
||||
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(1);
|
||||
// Simulate the replacement landing before the loop writes.
|
||||
await db('photos').where({ id: photoId })
|
||||
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
|
||||
const done = await settle();
|
||||
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Not an error and not "no EXIF" — the date was found, another writer just
|
||||
// got there first. It stays in the backlog for the next run.
|
||||
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Layered per-event category ordering (#782).
|
||||
*
|
||||
* Two ordering layers, resolved per event:
|
||||
* - GLOBAL default — photo_categories.display_order (migration 159),
|
||||
* set via POST /reorder-global; applies everywhere.
|
||||
* - PER-EVENT override — event_category_order (migration 160), set via
|
||||
* POST /reorder; overrides the default for one gallery.
|
||||
* - DELETE /reorder/:eventId clears an event's override.
|
||||
*
|
||||
* Verified against a real SQLite DB with the full core-migration set applied.
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('category ordering (#782)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let token;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function insertEvent(slug) {
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug, share_link: slug,
|
||||
event_name: slug, event_date: '2026-01-01',
|
||||
});
|
||||
return (await db('events').where({ slug }).first()).id;
|
||||
}
|
||||
|
||||
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
|
||||
const res = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
is_global: is_global ? 1 : 0,
|
||||
event_id,
|
||||
display_order,
|
||||
}).returning('id');
|
||||
return res[0]?.id ?? res[0];
|
||||
}
|
||||
|
||||
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
|
||||
|
||||
describe('migration 159 backfill', () => {
|
||||
it('seeds display_order from alphabetical order, scoped per event', async () => {
|
||||
const eventId = await insertEvent('backfill-ev');
|
||||
await insertCat('Reception', { event_id: eventId });
|
||||
await insertCat('Ceremony', { event_id: eventId });
|
||||
await insertCat('Pre-Ceremony', { event_id: eventId });
|
||||
|
||||
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
|
||||
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
|
||||
await require('../../migrations/core/159_add_category_display_order').up(db);
|
||||
|
||||
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
|
||||
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
|
||||
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global default order (POST /reorder-global)', () => {
|
||||
it('reverses the global order and every non-customised event follows it', async () => {
|
||||
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
const reversedIds = before.map((c) => c.id).reverse();
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
|
||||
.send({ orderedIds: reversedIds })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
|
||||
|
||||
// A fresh event (no override) shows globals in the new global order.
|
||||
const eventId = await insertEvent('follows-global');
|
||||
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
|
||||
expect(globalsInEvent).toEqual(reversedIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-event override (POST /reorder)', () => {
|
||||
it('pins a custom order for one event without affecting another', async () => {
|
||||
const eventA = await insertEvent('override-a');
|
||||
const eventB = await insertEvent('override-b');
|
||||
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
|
||||
const a2 = await insertCat('A-Reception', { event_id: eventA });
|
||||
|
||||
// Current resolved list for A (globals + A's two categories).
|
||||
const listA = (await getEvent(eventA)).body;
|
||||
// Put A-Reception first, then A-Ceremony, then the globals in their order.
|
||||
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
|
||||
const desired = [a2, a1, ...globalsA];
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventA, orderedIds: desired })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(desired);
|
||||
// override_position is set on every row for a customised event.
|
||||
expect(res.body.every((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
// Event B is untouched — no override, follows the global default.
|
||||
const listB = (await getEvent(eventB)).body;
|
||||
expect(listB.every((c) => c.override_position == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts global ids but rejects another event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* CRM mint-and-send paths — integration tests (#587).
|
||||
*
|
||||
* Pins the three document "mint" flows end-to-end through the real
|
||||
* HTTP → route → service → DB → email-queue → file pipeline:
|
||||
*
|
||||
* 1. POST /api/admin/quotes/:id/send (draft → sent + PDF + token + email)
|
||||
* 2. POST /api/admin/invoices/:id/cancel (issued → cancelled + Storno row)
|
||||
* — the issue spec named this /:id/storno; the real route is
|
||||
* /:id/cancel (invoiceService.cancelInvoice → createStorno).
|
||||
* 3. POST /api/admin/contracts/:id/countersign
|
||||
* (signed_by_customer → fully_signed + stamped PDF + sha256 + email)
|
||||
*
|
||||
* Real SQLite with the full core-migration run (helpers/crmDb), real
|
||||
* pdfkit/pdf-lib rendering — no mock-fs, no network.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
// Full migration run + cold-requiring pdfService/emailProcessor is slow
|
||||
// under CI load; match the other CRM integration suites.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
const CUSTOMER_EMAIL = 'customer@example.com';
|
||||
|
||||
// 1x1 transparent PNG — smallest valid signature pad output.
|
||||
const SIGNATURE_DATA_URL = 'data:image/png;base64,'
|
||||
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
// SQLite round-trips dates inconsistently (epoch ms number, numeric
|
||||
// string, or ISO string) — parse robustly before comparing.
|
||||
const toMillis = (v) => {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
||||
return Date.parse(v);
|
||||
};
|
||||
|
||||
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
// Count embedded image XObjects per page via pdf-lib — used to prove BOTH
|
||||
// signature stamps (customer + admin) made it into the final PDF instead of
|
||||
// only asserting file existence/hash (codex review of #850 round 2).
|
||||
async function countImagesPerPage(pdfPath) {
|
||||
const { PDFDocument, PDFName, PDFDict } = require('pdf-lib');
|
||||
const doc = await PDFDocument.load(fs.readFileSync(pdfPath));
|
||||
return doc.getPages().map((page) => {
|
||||
const resources = page.node.Resources();
|
||||
const xobjects = resources && resources.lookupMaybe(PDFName.of('XObject'), PDFDict);
|
||||
if (!xobjects) return 0;
|
||||
let images = 0;
|
||||
for (const [, ref] of xobjects.entries()) {
|
||||
const stream = page.doc.context.lookup(ref);
|
||||
const subtype = stream && stream.dict && stream.dict.get(PDFName.of('Subtype'));
|
||||
if (subtype && subtype.toString() === '/Image') images += 1;
|
||||
}
|
||||
return images;
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
// Real (symlink-resolved) storage root — on macOS os.tmpdir() returns
|
||||
// /var/... while the services persist under process.cwd() which
|
||||
// resolves to /private/var/....
|
||||
let storageRoot;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let token;
|
||||
let quoteApp;
|
||||
let invoiceApp;
|
||||
let contractApp;
|
||||
let quoteService;
|
||||
let invoiceService;
|
||||
let contractService;
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const auth = { get Authorization() { return `Bearer ${token}`; } };
|
||||
|
||||
async function enableFlag(key) {
|
||||
const updated = await db('feature_flags').where({ key }).update({ value: true });
|
||||
if (!updated) await db('feature_flags').insert({ key, value: true });
|
||||
}
|
||||
|
||||
// ----- per-path seed helpers -----------------------------------------
|
||||
|
||||
async function seedQuote() {
|
||||
const id = await quoteService.createQuote({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
eventName: 'Testshooting',
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo package', unit_price_minor: 150000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedIssuedInvoice(status = 'sent') {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Wedding coverage', unit_price_minor: 200000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
const id = invoiceIds[0];
|
||||
// Fast-forward past the send step — Storno only applies to issued
|
||||
// documents (sent/paid/overdue), and rendering+sending the original
|
||||
// is covered by the quote path already.
|
||||
await db('invoices').where({ id }).update({
|
||||
status, sent_at: new Date(), updated_at: new Date(),
|
||||
});
|
||||
return db('invoices').where({ id }).first();
|
||||
}
|
||||
|
||||
async function seedCustomerSignedContract() {
|
||||
const id = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Fotografie-Vertrag',
|
||||
}, adminId);
|
||||
// Real send + customer-sign flow (codex review of #850): a direct
|
||||
// status UPDATE skipped the customer's signature asset and stamped
|
||||
// PDF, so countersign exercised its unsigned-PDF fallback and a
|
||||
// regression dropping the customer's signature would stay green.
|
||||
const { token } = await contractService.sendContract(id, adminId);
|
||||
await contractService.recordCustomerSignature({
|
||||
token,
|
||||
name: 'Custo Mer',
|
||||
ip: '127.0.0.1',
|
||||
signatureDataUrl: SIGNATURE_DATA_URL,
|
||||
accepted: true,
|
||||
});
|
||||
return db('contracts').where({ id }).first();
|
||||
}
|
||||
|
||||
// ----- suite ----------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
// Business-doc PDFs (quotes/invoices/contracts) persist under
|
||||
// `getStoragePath()/business-docs/...`, and safePath also allows a
|
||||
// `process.cwd()/storage/business-docs/...` root — chdir into the temp
|
||||
// dir so every test artifact lands isolated and gets cleaned up.
|
||||
process.chdir(tmpDir);
|
||||
// Mirror what the services store: the raw STORAGE_PATH bootCrmDb
|
||||
// exported, NOT a symlink-resolved variant. On macOS os.tmpdir() is
|
||||
// /var/... while realpath is /private/var/..., so canonicalizing here
|
||||
// would make every stored path fail the prefix check.
|
||||
storageRoot = path.join(process.env.STORAGE_PATH, 'business-docs');
|
||||
|
||||
// Fail-fast on the pre-existing logActivity-inside-transaction
|
||||
// deadlock: createContract and createStorno call logActivity() from
|
||||
// inside a knex transaction WITHOUT passing the trx as executor, so
|
||||
// the audit insert tries to grab a second connection from the
|
||||
// single-connection SQLite pool while the trx holds it. In
|
||||
// production that stalls each call for the full 60 s acquire
|
||||
// timeout (the error is then swallowed by logActivity's catch);
|
||||
// here we shrink the timeout so the same swallowed failure costs
|
||||
// 2 s instead of blowing the per-test budget. Behaviour under test
|
||||
// is unchanged — the mint paths themselves never wait on this.
|
||||
db.client.pool.acquireTimeoutMillis = 2000;
|
||||
|
||||
// node-sqlite3 detects Date bind params via `InstanceOf(global.Date)`
|
||||
// against the NATIVE realm's Date — under jest's vm sandbox the
|
||||
// service code's `new Date()` is a different constructor, the check
|
||||
// fails, and the value stringifies to the literal "[object Object]"
|
||||
// (the exact pathology helpers/crmDb.js documents for
|
||||
// createPublicToken). Normalize Date bindings to ISO strings before
|
||||
// they reach the driver so the real service inserts round-trip the
|
||||
// same way they do outside jest.
|
||||
// Patch on the prototype — knex mints transaction clients via
|
||||
// Object.create(prototype), so an instance-level patch would miss
|
||||
// every query issued inside a db.transaction().
|
||||
const clientProto = Object.getPrototypeOf(db.client);
|
||||
const origQuery = clientProto._query;
|
||||
clientProto._query = function patchedQuery(connection, obj) {
|
||||
if (obj && Array.isArray(obj.bindings)) {
|
||||
obj.bindings = obj.bindings.map(
|
||||
(b) => (b && typeof b === 'object' && typeof b.toISOString === 'function' ? b.toISOString() : b),
|
||||
);
|
||||
}
|
||||
return origQuery.call(this, connection, obj);
|
||||
};
|
||||
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
// CRM surfaces are feature-flagged; migration 107 seeds them OFF.
|
||||
await enableFlag('quotes');
|
||||
await enableFlag('bills');
|
||||
await enableFlag('contracts');
|
||||
|
||||
quoteService = require('../../src/services/quoteService');
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
contractService = require('../../src/services/contractService');
|
||||
|
||||
quoteApp = buildRouteApp('/api/admin/quotes', require('../../src/routes/adminQuotes'));
|
||||
invoiceApp = buildRouteApp('/api/admin/invoices', require('../../src/routes/adminInvoices'));
|
||||
contractApp = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
process.chdir(prevCwd);
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('POST /api/admin/quotes/:id/send', () => {
|
||||
test('draft quote: 200 → sent + sent_at + PDF on disk + action token + quote_sent email', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sent).toBe(true);
|
||||
expect(res.body.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// DB state
|
||||
const quote = await db('quotes').where({ id: quoteId }).first();
|
||||
expect(quote.status).toBe('sent');
|
||||
expect(quote.sent_at).toBeTruthy();
|
||||
|
||||
// PDF persisted inside the isolated storage root
|
||||
expect(quote.pdf_path).toBeTruthy();
|
||||
expect(quote.pdf_path.startsWith(path.join(storageRoot, 'quote'))).toBe(true);
|
||||
expect(fs.existsSync(quote.pdf_path)).toBe(true);
|
||||
expect(fs.statSync(quote.pdf_path).size).toBeGreaterThan(0);
|
||||
|
||||
// Action token row: right quote, future expiry
|
||||
const tokenRow = await db('quote_action_tokens').where({ token: res.body.token }).first();
|
||||
expect(tokenRow).toBeTruthy();
|
||||
expect(tokenRow.quote_id).toBe(quoteId);
|
||||
expect(toMillis(tokenRow.expires_at)).toBeGreaterThan(Date.now());
|
||||
|
||||
// Email queued to the customer's primary address
|
||||
const emails = await db('email_queue').where({ email_type: 'quote_sent' });
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
const emailData = JSON.parse(emails[0].email_data);
|
||||
expect(emailData.quote_number).toBe(quote.quote_number);
|
||||
});
|
||||
|
||||
test('already-sent quote: 409 (spec said 400; service throws 409)', async () => {
|
||||
const quoteId = await seedQuote();
|
||||
await request(quoteApp).post(`/api/admin/quotes/${quoteId}/send`).set(auth).expect(200);
|
||||
|
||||
const res = await request(quoteApp)
|
||||
.post(`/api/admin/quotes/${quoteId}/send`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot send a quote with status 'sent'/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/invoices/:id/cancel (Storno mint)', () => {
|
||||
test('sent invoice: original cancelled, Storno row minted with negated totals + lineage', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
// Route responds via successResponse default — 200, not the 201
|
||||
// the issue spec assumed.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cancelled).toBe(true);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const storno = await db('invoices').where({ id: res.body.stornoId }).first();
|
||||
expect(storno.kind).toBe('storno');
|
||||
expect(storno.cancels_invoice_id).toBe(original.id);
|
||||
expect(storno.deal_uuid).toBe(original.deal_uuid);
|
||||
|
||||
// Negated amounts
|
||||
expect(storno.net_amount_minor).toBe(-original.net_amount_minor);
|
||||
expect(storno.vat_amount_minor).toBe(-original.vat_amount_minor);
|
||||
expect(storno.total_amount_minor).toBe(-original.total_amount_minor);
|
||||
|
||||
// Freshly sequenced number from the same series
|
||||
expect(typeof storno.invoice_number).toBe('string');
|
||||
expect(storno.invoice_number.length).toBeGreaterThan(0);
|
||||
expect(storno.invoice_number).not.toBe(original.invoice_number);
|
||||
|
||||
// Line items snapshotted onto the Storno
|
||||
const originalItems = await db('invoice_line_items').where({ invoice_id: original.id });
|
||||
const stornoItems = await db('invoice_line_items').where({ invoice_id: storno.id });
|
||||
expect(stornoItems).toHaveLength(originalItems.length);
|
||||
|
||||
// Original flipped + back-linked
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
expect(refreshed.cancellation_storno_id).toBe(storno.id);
|
||||
|
||||
// sendStorno side effects (codex review of #850): cancelInvoice
|
||||
// swallows a sendStorno failure by design, so without these
|
||||
// assertions a broken render/persist/queue leg would stay green.
|
||||
const sentStorno = await db('invoices').where({ id: storno.id }).first();
|
||||
expect(sentStorno.status).toBe('sent');
|
||||
expect(sentStorno.pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(sentStorno.pdf_path)).toBe(true);
|
||||
const stornoEmails = await db('email_queue').where({ email_type: 'storno_issued' });
|
||||
expect(stornoEmails.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stornoEmails[0].recipient_email).toBe(CUSTOMER_EMAIL);
|
||||
});
|
||||
|
||||
test('paid invoice can be cancelled via Storno too (refund document leg)', async () => {
|
||||
const original = await seedIssuedInvoice('paid');
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stornoId).toBeGreaterThan(0);
|
||||
|
||||
const refreshed = await db('invoices').where({ id: original.id }).first();
|
||||
expect(refreshed.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('already-cancelled invoice: 409 ALREADY_CANCELLED', async () => {
|
||||
const original = await seedIssuedInvoice('sent');
|
||||
await request(invoiceApp).post(`/api/admin/invoices/${original.id}/cancel`).set(auth).expect(200);
|
||||
|
||||
const res = await request(invoiceApp)
|
||||
.post(`/api/admin/invoices/${original.id}/cancel`)
|
||||
.set(auth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_CANCELLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/contracts/:id/countersign', () => {
|
||||
test('customer-signed contract: 200 → fully_signed + stamped PDF + sha256 + signature asset + email with attachment', async () => {
|
||||
const contract = await seedCustomerSignedContract();
|
||||
await db('email_queue').del();
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${contract.id}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester', signatureDataUrl: SIGNATURE_DATA_URL });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('fully_signed');
|
||||
|
||||
const row = await db('contracts').where({ id: contract.id }).first();
|
||||
expect(row.status).toBe('fully_signed');
|
||||
expect(row.signed_admin_name).toBe('Admin Tester');
|
||||
expect(row.signed_by_admin_at).toBeTruthy();
|
||||
|
||||
// The customer's own signature (from the real sign flow in the seed)
|
||||
// must survive countersigning — layered, not replaced.
|
||||
expect(row.signed_customer_signature_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_customer_signature_path)).toBe(true);
|
||||
expect(row.signed_customer_name).toBe('Custo Mer');
|
||||
|
||||
// Admin signature image persisted under the storage root
|
||||
expect(row.signed_admin_signature_path).toBeTruthy();
|
||||
expect(row.signed_admin_signature_path.startsWith(
|
||||
path.join(storageRoot, 'contract', 'signatures'),
|
||||
)).toBe(true);
|
||||
expect(fs.existsSync(row.signed_admin_signature_path)).toBe(true);
|
||||
|
||||
// Stamped, fully-signed PDF written and hashed. The issue spec
|
||||
// called this `integrity_hash`; the real column is
|
||||
// `signed_pdf_sha256` (plus `pdf_sha256` for the unsigned base).
|
||||
expect(row.signed_pdf_render_failed_at).toBeFalsy();
|
||||
expect(row.signed_pdf_path).toBeTruthy();
|
||||
expect(fs.existsSync(row.signed_pdf_path)).toBe(true);
|
||||
expect(row.signed_pdf_sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(sha256(fs.readFileSync(row.signed_pdf_path))).toBe(row.signed_pdf_sha256);
|
||||
|
||||
// BOTH stamps must be embedded in the final document — a regression
|
||||
// stamping the admin onto the unsigned base PDF would keep every
|
||||
// path/hash assertion above green (codex review of #850 round 2).
|
||||
const imagesPerPage = await countImagesPerPage(row.signed_pdf_path);
|
||||
const maxImagesOnAPage = Math.max(...imagesPerPage);
|
||||
expect(maxImagesOnAPage).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// contract_fully_signed email to the customer's primary address,
|
||||
// carrying the signed PDF as attachment (plus the audit cert).
|
||||
const emails = await db('email_queue').where({ email_type: 'contract_fully_signed' });
|
||||
const customerCopy = emails.find((e) => e.recipient_email === CUSTOMER_EMAIL);
|
||||
expect(customerCopy).toBeTruthy();
|
||||
const emailData = JSON.parse(customerCopy.email_data);
|
||||
expect(emailData.contract_number).toBe(contract.contract_number);
|
||||
expect(Array.isArray(emailData.attachments)).toBe(true);
|
||||
const pdfAttachment = emailData.attachments.find(
|
||||
(a) => a.filename === `${contract.contract_number}-signed.pdf`,
|
||||
);
|
||||
expect(pdfAttachment).toBeTruthy();
|
||||
expect(pdfAttachment.contentType).toBe('application/pdf');
|
||||
expect(fs.existsSync(pdfAttachment.contentPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('draft contract: 409 — countersign requires sent/signed_by_customer', async () => {
|
||||
const draftId = await contractService.createContract({
|
||||
customerAccountId: customerId,
|
||||
title: 'Noch nicht versendet',
|
||||
}, adminId);
|
||||
|
||||
const res = await request(contractApp)
|
||||
.post(`/api/admin/contracts/${draftId}/countersign`)
|
||||
.set(auth)
|
||||
.send({ name: 'Admin Tester' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/cannot counter-sign a contract with status 'draft'/i);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Schema-shape regression net for the CRM consolidated migration.
|
||||
*
|
||||
* Pins the table/column layout that the route + service layer expect
|
||||
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
|
||||
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
|
||||
* forward-reference deferral added in this PR), but it doesn't notice
|
||||
* if a future edit silently drops a column the service code reads —
|
||||
* SQLite would just return undefined and the broken behavior would
|
||||
* land on beta.
|
||||
*
|
||||
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
|
||||
* so a rename or removal there fails the test instead of silently
|
||||
* breaking the lineage card.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('CRM schema after core migrations', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('table layout', () => {
|
||||
const expectedTables = [
|
||||
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
|
||||
'events', 'document_sequences',
|
||||
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
|
||||
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
|
||||
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
|
||||
'customer_hour_entries',
|
||||
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
|
||||
'event_payment_plans',
|
||||
];
|
||||
|
||||
it.each(expectedTables)('has table %s', async (table) => {
|
||||
expect(await db.schema.hasTable(table)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deal_uuid lineage columns', () => {
|
||||
// Every document in one engagement shares a deal_uuid — the
|
||||
// lineage card joins on it. Drop the column anywhere in the chain
|
||||
// and the card silently returns partial data.
|
||||
it.each(['quotes', 'contracts', 'invoices'])(
|
||||
'%s has deal_uuid column',
|
||||
async (table) => {
|
||||
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// The back-pointer FKs were the source of the schema-drift bug
|
||||
// we fixed in this PR (forward references). Pin them.
|
||||
it('quotes has converted_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_contract_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
|
||||
});
|
||||
it('invoices has source_quote_id back-pointer', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storno discriminator columns', () => {
|
||||
// kind='storno' + cancels_invoice_id + negative totals are the
|
||||
// shape every aggregate filter relies on (feedback_storno_filter_
|
||||
// everywhere). Pin the columns so a rename doesn't silently break
|
||||
// every revenue report.
|
||||
it('invoices has kind discriminator', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
|
||||
});
|
||||
it('invoices has cancels_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
|
||||
});
|
||||
it('invoices has replaces_invoice_id self-ref', async () => {
|
||||
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event time columns (migration 137)', () => {
|
||||
// The admin calendar reads these to render timed vs. full-day
|
||||
// tiles. Per the feedback_migration_preserve_visuals rule, the
|
||||
// default has to be `is_full_day=true` so existing rows keep
|
||||
// their pre-migration visual.
|
||||
it('events has event_time_start', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
|
||||
});
|
||||
it('events has event_time_end', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
|
||||
});
|
||||
it('events has is_full_day', async () => {
|
||||
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seed paths', () => {
|
||||
it('admin + customer seed inserts cleanly', async () => {
|
||||
const { adminId, customerId } = await seedMinimal(db);
|
||||
expect(adminId).toBeTruthy();
|
||||
expect(customerId).toBeTruthy();
|
||||
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
expect(admin.email).toBe('tester@example.com');
|
||||
expect(customer.email).toBe('customer@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Negative line items (Rabatt / manual discount lines) are accepted
|
||||
* end-to-end as long as the resulting total stays ≥ 0. When the
|
||||
* discount would drive the total negative, the service rejects with
|
||||
* a clear, code-tagged error so the admin is steered to Storno for
|
||||
* credit-note workflows.
|
||||
*
|
||||
* Touches the actual createInvoice / createQuote service paths so a
|
||||
* future change to either computeTotals or the guard fires this test.
|
||||
*/
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Service-level CRM calls cold-require heavy modules (pdfService,
|
||||
// nodemailer, etc.) on first use; the global 5 s per-test budget is
|
||||
// too tight for that. Bump it for this file only.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('discount line items (negative unit_price_minor)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminId;
|
||||
let customerId;
|
||||
let invoiceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId, customerId } = await seedMinimal(db));
|
||||
invoiceService = require('../../src/services/invoiceService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// Quote-side coverage of the symmetric validator + guard is
|
||||
// deliberately omitted: createQuote's init path takes ~30 s under
|
||||
// this harness (something in pdfService / emailProcessor cold-
|
||||
// require), which would push the suite well past CI's per-test
|
||||
// budget. The shape of the guard is identical to the invoice one
|
||||
// covered below; a future change to extract the slow init or to
|
||||
// stub it for tests should re-enable a parallel quote test.
|
||||
|
||||
describe('invoices', () => {
|
||||
it('accepts a negative-price line and computes the net correctly', async () => {
|
||||
const { invoiceIds } = await invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId);
|
||||
|
||||
expect(Array.isArray(invoiceIds)).toBe(true);
|
||||
expect(invoiceIds.length).toBe(1);
|
||||
|
||||
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
|
||||
expect(row.net_amount_minor).toBe(15000);
|
||||
expect(row.total_amount_minor).toBe(15000);
|
||||
});
|
||||
|
||||
it('rejects when the discount drives the total negative', async () => {
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: customerId,
|
||||
currency: 'CHF',
|
||||
vatRate: 0,
|
||||
lineItems: [
|
||||
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
|
||||
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
|
||||
],
|
||||
}, adminId)).rejects.toMatchObject({
|
||||
code: 'INVOICE_TOTAL_NEGATIVE',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Download resolutions (#858).
|
||||
*
|
||||
* Pins the contracts that are easy to break later:
|
||||
*
|
||||
* - the global → per-event cascade, including NULL = inherit
|
||||
* - the picker never offers a size ABOVE the standard (a photographer who
|
||||
* lowers the standard is not silently handing out full-res), and 'Original'
|
||||
* only reappears when the admin explicitly allows it
|
||||
* - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what
|
||||
* the requester asked for on the issue
|
||||
* - a guest-supplied resolution is validated against the policy rather than
|
||||
* trusted
|
||||
*/
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Both modules under test pull in src/database/db.js transitively. bootCrmDb
|
||||
// only works when it runs BEFORE the first require of db.js (it sets
|
||||
// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are
|
||||
// required lazily in beforeAll rather than at module scope — otherwise knex
|
||||
// binds to the shared default SQLite file and every run after the first one
|
||||
// fails with "table `migrations` already exists".
|
||||
let resolveEventDownloadPolicy;
|
||||
let pickRequestedResolution;
|
||||
let parseResolution;
|
||||
let invalidateDownloadGlobals;
|
||||
let resizeToBox;
|
||||
|
||||
describe('Download resolutions (#858)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
const setGlobal = async (key, value) => {
|
||||
await db('app_settings').where({ setting_key: key }).del();
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'download',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
invalidateDownloadGlobals();
|
||||
};
|
||||
|
||||
const PRESETS = [
|
||||
{ label: 'Large', width: 3000, height: 2000 },
|
||||
{ label: 'Medium', width: 1500, height: 1000 },
|
||||
{ label: 'Small', width: 800, height: 600 },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
({
|
||||
resolveEventDownloadPolicy,
|
||||
pickRequestedResolution,
|
||||
parseResolution,
|
||||
invalidateDownloadGlobals,
|
||||
} = require('../../src/utils/downloadResolutions'));
|
||||
({ resizeToBox } = require('../../src/services/imageProcessor'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setGlobal('download_resolutions', PRESETS);
|
||||
await setGlobal('download_standard_resolution', 'original');
|
||||
await setGlobal('download_resolution_picker_enabled', false);
|
||||
await setGlobal('download_allow_original', false);
|
||||
});
|
||||
|
||||
describe('cascade', () => {
|
||||
it('inherits the global standard when the event has no override', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null });
|
||||
expect(policy.standard).toBe('1500x1000');
|
||||
expect(policy.standardBox).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
|
||||
it('lets an event override the global standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' });
|
||||
expect(policy.standard).toBe('800x600');
|
||||
});
|
||||
|
||||
it('treats a NULL picker flag as inherit and an explicit false as override', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true);
|
||||
expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choice list', () => {
|
||||
it('never offers a size larger than the standard', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']);
|
||||
// The regression that matters: 3000x2000 must not be reachable.
|
||||
expect(choices.some((c) => c.id === '3000x2000')).toBe(false);
|
||||
});
|
||||
|
||||
it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => {
|
||||
// 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison
|
||||
// would offer it and hand back a 2000px-wide file despite a 1500px cap.
|
||||
await setGlobal('download_resolutions', [
|
||||
...PRESETS,
|
||||
{ label: 'Wide', width: 2000, height: 700 },
|
||||
]);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === '2000x700')).toBe(false);
|
||||
});
|
||||
|
||||
it('omits Original when the standard is capped and the admin has not allowed it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices.some((c) => c.id === 'original')).toBe(false);
|
||||
});
|
||||
|
||||
it('re-adds Original when the admin explicitly allows it', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
await setGlobal('download_allow_original', true);
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
});
|
||||
|
||||
it('offers Original when the standard already is original', async () => {
|
||||
const { choices } = await resolveEventDownloadPolicy({});
|
||||
expect(choices[0].id).toBe('original');
|
||||
expect(choices.map((c) => c.id)).toContain('3000x2000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request validation', () => {
|
||||
it('falls back to the standard when nothing is requested', async () => {
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000');
|
||||
});
|
||||
|
||||
it('refuses any explicit request while the picker is off', async () => {
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
expect(policy.pickerEnabled).toBe(false);
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a size that is not on the offered list', async () => {
|
||||
await setGlobal('download_resolution_picker_enabled', true);
|
||||
await setGlobal('download_standard_resolution', '1500x1000');
|
||||
const policy = await resolveEventDownloadPolicy({});
|
||||
// Above the standard → not offered → rejected rather than silently served.
|
||||
expect(pickRequestedResolution(policy, '3000x2000')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '9999x9999')).toBeNull();
|
||||
expect(pickRequestedResolution(policy, '800x600')).toBe('800x600');
|
||||
});
|
||||
|
||||
it('parses only well-formed resolution ids', () => {
|
||||
expect(parseResolution('original')).toBeNull();
|
||||
expect(parseResolution(null)).toBeNull();
|
||||
expect(parseResolution('abc')).toBeNull();
|
||||
expect(parseResolution('0x0')).toBeNull();
|
||||
expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('job dedup identity (codex review round 1)', () => {
|
||||
// The leak this pins: a PIN client's archive contains hidden photos. If the
|
||||
// dedup key ignored the visibility scope, a guest asking for the same size
|
||||
// would be handed the client's job token — and the delivery route only
|
||||
// checked the event id.
|
||||
let jobService;
|
||||
|
||||
beforeAll(() => {
|
||||
jobService = require('../../src/services/downloadJobService');
|
||||
});
|
||||
|
||||
it('separates client and guest archives of the same size and photo set', () => {
|
||||
const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden');
|
||||
expect(guest).not.toBe(client);
|
||||
});
|
||||
|
||||
it('keys on the RESOLVED photo set, so a stale archive is not reused', () => {
|
||||
const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public');
|
||||
const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public');
|
||||
const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public');
|
||||
expect(new Set([before, afterUpload, afterHide]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('is order-independent for the same set', () => {
|
||||
expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public'))
|
||||
.toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public'));
|
||||
});
|
||||
|
||||
it('maps access levels onto the two visibility scopes', () => {
|
||||
expect(jobService.visibilityScopeFor('client')).toBe('hidden');
|
||||
expect(jobService.visibilityScopeFor('guest')).toBe('public');
|
||||
expect(jobService.visibilityScopeFor(undefined)).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize semantics', () => {
|
||||
const make = (w, h) => sharp({
|
||||
create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
const box = { width: 1500, height: 1000 };
|
||||
|
||||
it('fits a 3:2 photo exactly into a 3:2 box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([1500, 1000]);
|
||||
});
|
||||
|
||||
it('treats the box as an "up to" bound for other aspect ratios', async () => {
|
||||
// Portrait: height is the binding edge, width comes out smaller.
|
||||
const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata();
|
||||
expect(portrait.height).toBe(1000);
|
||||
expect(portrait.width).toBeLessThan(1500);
|
||||
|
||||
const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata();
|
||||
expect(fourThree.height).toBe(1000);
|
||||
expect(fourThree.width).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it('never upscales an image already smaller than the box', async () => {
|
||||
const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata();
|
||||
expect([out.width, out.height]).toEqual([800, 600]);
|
||||
});
|
||||
|
||||
it('passes the buffer through untouched for the original size', async () => {
|
||||
const src = await make(4000, 3000);
|
||||
expect(await resizeToBox(src, null)).toBe(src);
|
||||
});
|
||||
|
||||
it('keeps the source format so the filename and mime type stay honest', async () => {
|
||||
// A .gif re-encoded as JPEG would ship mislabelled bytes, since the
|
||||
// download routes keep the original filename and mime type.
|
||||
const gif = await sharp({
|
||||
create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).gif().toBuffer();
|
||||
const out = await sharp(await resizeToBox(gif, box)).metadata();
|
||||
expect(out.format).toBe('gif');
|
||||
expect(out.width).toBe(1333);
|
||||
});
|
||||
|
||||
it('returns the input rather than throwing on an undecodable source', async () => {
|
||||
const junk = Buffer.from('not an image');
|
||||
expect(await resizeToBox(junk, box)).toBe(junk);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* Global email footer signature (migration 198, issue #1264).
|
||||
*
|
||||
* The signature is rendered by `wrapEmailHtml` and nowhere else, which is
|
||||
* the whole point of the design: every template, preview, test mail and
|
||||
* manual send passes through that one wrapper, so none of them needed a
|
||||
* per-template change. These tests pin that contract at the wrapper.
|
||||
*
|
||||
* The load-bearing case is the DISABLED one — an upgraded install must keep
|
||||
* a byte-identical footer until an admin opts in.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('wrapEmailHtml — business-profile signature footer', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let wrapEmailHtml;
|
||||
let renderEmailSignatureText;
|
||||
let businessProfileService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ wrapEmailHtml, renderEmailSignatureText } = require('../../src/services/emailProcessor'));
|
||||
businessProfileService = require('../../src/services/businessProfileService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({
|
||||
company_name: null,
|
||||
address_line1: null,
|
||||
address_line2: null,
|
||||
postal_code: null,
|
||||
city: null,
|
||||
country_code: null,
|
||||
country_name: null,
|
||||
phone: null,
|
||||
mobile: null,
|
||||
email: null,
|
||||
website: null,
|
||||
vat_id: null,
|
||||
email_signature_enabled: false,
|
||||
email_signature_extra: null,
|
||||
});
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
});
|
||||
|
||||
const fullProfile = {
|
||||
company_name: 'Müller Fotografie GmbH',
|
||||
address_line1: 'Bahnhofstrasse 1',
|
||||
address_line2: 'Postfach 42',
|
||||
postal_code: '9494',
|
||||
city: 'Schaan',
|
||||
country_code: 'li',
|
||||
country_name: 'Liechtenstein',
|
||||
phone: '+41 79 123 45 67',
|
||||
mobile: '+41 78 000 11 22',
|
||||
email: 'hello@example.com',
|
||||
website: 'example.com',
|
||||
vat_id: 'CHE-123.456.789',
|
||||
email_signature_enabled: true,
|
||||
email_signature_extra: 'Handelsregister Vaduz\nFL-0002.123.456-7',
|
||||
};
|
||||
|
||||
async function enable(overrides = {}) {
|
||||
await db('business_profile').where({ id: 1 }).update({ ...fullProfile, ...overrides });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
}
|
||||
|
||||
it('renders nothing extra when the toggle is off', async () => {
|
||||
// Profile fully populated, signature switched OFF: the operator's
|
||||
// address must not leak into mail just because they filled in the
|
||||
// invoice issuer block.
|
||||
await enable({ email_signature_enabled: false });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('Bahnhofstrasse 1');
|
||||
expect(html).not.toContain('hello@example.com');
|
||||
expect(html).not.toContain('CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('produces a byte-identical footer to a no-profile install when disabled', async () => {
|
||||
const withEmptyProfile = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
await enable({ email_signature_enabled: false });
|
||||
const withDisabledSignature = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(withDisabledSignature).toBe(withEmptyProfile);
|
||||
});
|
||||
|
||||
it('renders address, contacts, VAT id and the legal line when enabled', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('Müller Fotografie GmbH');
|
||||
expect(html).toContain('Bahnhofstrasse 1');
|
||||
expect(html).toContain('Postfach 42');
|
||||
// "LI-9494 Schaan / Liechtenstein" — same shape as the PDF issuer block.
|
||||
expect(html).toContain('LI-9494 Schaan / Liechtenstein');
|
||||
expect(html).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(html).toContain('Handelsregister Vaduz<br />FL-0002.123.456-7');
|
||||
});
|
||||
|
||||
it('links phone, mobile, email and website with safe schemes', async () => {
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Separators stripped from the tel: href, kept in the visible text.
|
||||
expect(html).toContain('href="tel:+41791234567"');
|
||||
expect(html).toContain('href="tel:+41780001122"');
|
||||
expect(html).toContain('href="mailto:hello@example.com"');
|
||||
// A bare hostname is promoted to https:// rather than left relative.
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('keeps an already-absolute website URL as typed', async () => {
|
||||
await enable({ website: 'http://legacy.example.org/studio' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('href="http://legacy.example.org/studio"');
|
||||
});
|
||||
|
||||
it('neutralises a javascript: website into an inert https URL', async () => {
|
||||
await enable({ website: 'javascript:alert(1)' });
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
expect(html).toContain('href="https://javascript:alert(1)"');
|
||||
});
|
||||
|
||||
it('HTML-escapes every signature field', async () => {
|
||||
await enable({
|
||||
company_name: '<script>alert(1)</script>',
|
||||
address_line1: 'Rue "des" Fleurs & Co',
|
||||
vat_id: '<img src=x onerror=alert(1)>',
|
||||
email_signature_extra: '</p><script>alert(2)</script>',
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// Escaped, so the markup is inert text — the tags never open.
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>alert(2)</script>');
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Rue "des" Fleurs & Co');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
// Footer already prints the branding name; the profile name is only
|
||||
// added when the operator gave a different legal name.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'branding_company_name', setting_value: JSON.stringify('Müller Fotografie GmbH'), setting_type: 'branding' })
|
||||
.onConflict('setting_key')
|
||||
.merge();
|
||||
await enable();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(
|
||||
// header alt, footer alt, footer name line, copyright line — the
|
||||
// signature must not add a fifth.
|
||||
(await wrapEmailHtml('<p>Body</p>', 'Subject', 'en')).match(/Müller Fotografie GmbH/g).length
|
||||
);
|
||||
expect(html.match(/Müller Fotografie GmbH/g).length).toBe(4);
|
||||
|
||||
await db('app_settings').where({ setting_key: 'branding_company_name' }).del();
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
|
||||
const de = await wrapEmailHtml('<p>Body</p>', 'Subject', 'de');
|
||||
const en = await wrapEmailHtml('<p>Body</p>', 'Subject', 'en');
|
||||
|
||||
expect(de).toContain('USt-IdNr.: CHE-123.456.789');
|
||||
expect(en).toContain('VAT ID: CHE-123.456.789');
|
||||
});
|
||||
|
||||
it('omits empty fields instead of rendering blank rows', async () => {
|
||||
await enable({
|
||||
address_line2: null, mobile: null, website: null, vat_id: null, email_signature_extra: null,
|
||||
});
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
expect(html).toContain('hello@example.com');
|
||||
expect(html).not.toContain('VAT ID:');
|
||||
expect(html).not.toMatch(/·\s*·/);
|
||||
});
|
||||
|
||||
it('renders no signature block when enabled but the profile is blank', async () => {
|
||||
await db('business_profile').where({ id: 1 }).update({ email_signature_enabled: true });
|
||||
businessProfileService.invalidateEmailSignatureCache();
|
||||
|
||||
const html = await wrapEmailHtml('<p>Body</p>', 'Subject');
|
||||
|
||||
// The signature <div> is the only element carrying this margin.
|
||||
expect(html).not.toContain('<div style="margin:15px 0 5px;');
|
||||
});
|
||||
|
||||
// Codex review: sendTemplateEmail uses a template's own body_text when it
|
||||
// has one — every seeded template does — so the text/plain alternative is
|
||||
// NOT derived from the wrapped HTML and carried no signature at all.
|
||||
describe('plain-text alternative', () => {
|
||||
it('renders the signature as plain text', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).toContain('hello@example.com');
|
||||
expect(text).toContain('VAT ID: CHE-123.456.789');
|
||||
expect(text).toContain('Handelsregister Vaduz');
|
||||
// A separator, the text equivalent of the footer's top border.
|
||||
expect(text).toMatch(/^\n\n--\n/);
|
||||
});
|
||||
|
||||
it('carries no HTML markup or entities', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, { brandingCompanyName: 'PicPeak', language: 'en' });
|
||||
|
||||
expect(text).not.toContain('<');
|
||||
expect(text).not.toContain('·');
|
||||
expect(text).not.toContain('&');
|
||||
});
|
||||
|
||||
it('is empty when the signature is disabled', () => {
|
||||
expect(renderEmailSignatureText(null, {})).toBe('');
|
||||
});
|
||||
|
||||
it('uses the German VAT label for a German mail', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
expect(renderEmailSignatureText(signature, { language: 'de' })).toContain('USt-IdNr.');
|
||||
expect(renderEmailSignatureText(signature, { language: 'en' })).toContain('VAT ID');
|
||||
});
|
||||
|
||||
it('does not repeat the branding company name', async () => {
|
||||
await enable();
|
||||
const signature = await businessProfileService.getEmailSignature();
|
||||
|
||||
const text = renderEmailSignatureText(signature, {
|
||||
brandingCompanyName: 'Müller Fotografie GmbH', language: 'en',
|
||||
});
|
||||
expect(text).not.toContain('Müller Fotografie GmbH');
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the plain-text part free of signature markup', async () => {
|
||||
const { htmlToText } = require('../../src/services/emailProcessor');
|
||||
await enable();
|
||||
|
||||
const text = htmlToText(await wrapEmailHtml('<p>Body</p>', 'Subject'));
|
||||
|
||||
expect(text).toContain('Bahnhofstrasse 1');
|
||||
expect(text).not.toContain('<p');
|
||||
expect(text).not.toContain('style=');
|
||||
expect(text).not.toContain('·');
|
||||
// The separator survives as a real character, not an entity.
|
||||
expect(text).toContain('Bahnhofstrasse 1 \u00b7 Postfach 42');
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Boot-time email-template self-heal:
|
||||
* 1. Seeds the CRM / contract / event-reminder templates on an
|
||||
* install that's never had them before.
|
||||
* 2. Recovers email_queue rows that previously exhausted their
|
||||
* retries because their template was missing.
|
||||
*
|
||||
* The failure that triggered this fix (2026-05-27) had Ralf's beta
|
||||
* box failing every `quote_sent` / `invoice_sent` send for ~14h
|
||||
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
|
||||
* defined but never called. After 3 retries the rows sat in
|
||||
* status='pending' forever; nothing in the admin UI signalled the
|
||||
* problem. Both halves of that regression are covered here.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('email template self-heal at boot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
|
||||
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
|
||||
// 107_crm_consolidated documents the deliberate split (templates
|
||||
// are self-healed at runtime, not inserted by the migration).
|
||||
const before = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(before).toEqual([]);
|
||||
|
||||
// Seed a stuck queue row that mirrors what we found on Ralf's box:
|
||||
// quote_sent send attempted 3 times, each time failed because the
|
||||
// template didn't exist, queue processor gave up.
|
||||
const queueRowIds = await db('email_queue').insert({
|
||||
recipient_email: 'customer@example.com',
|
||||
email_type: 'quote_sent',
|
||||
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: "Email template 'quote_sent' not found",
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
|
||||
|
||||
// Also seed an UNRELATED stuck row (different template, NOT one
|
||||
// we're going to insert) to confirm the recovery is targeted —
|
||||
// it must not blanket-reset every retry-exhausted row.
|
||||
const unrelatedIds = await db('email_queue').insert({
|
||||
recipient_email: 'someone@example.com',
|
||||
email_type: 'some_other_template',
|
||||
email_data: JSON.stringify({}),
|
||||
status: 'pending',
|
||||
retry_count: 3,
|
||||
error_message: 'SMTP timeout',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
|
||||
|
||||
// The seeders use module-level caches (`_seeded = true`). When
|
||||
// jest runs this test in isolation that cache starts fresh; in
|
||||
// the full suite no other test currently calls these seeders, so
|
||||
// the first call here also runs the real work. Reset the cache
|
||||
// defensively in case a future test changes that.
|
||||
jest.resetModules();
|
||||
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
|
||||
|
||||
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
|
||||
|
||||
// Templates landed.
|
||||
expect(result.seeded).toEqual(expect.arrayContaining([
|
||||
'quote_sent', 'invoice_sent', 'storno_issued',
|
||||
]));
|
||||
const after = await db('email_templates')
|
||||
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
|
||||
.pluck('template_key');
|
||||
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
|
||||
|
||||
// Stuck quote_sent row was recovered.
|
||||
expect(result.recovered).toBeGreaterThanOrEqual(1);
|
||||
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
|
||||
expect(recoveredRow.retry_count).toBe(0);
|
||||
expect(recoveredRow.error_message).toBeNull();
|
||||
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
|
||||
|
||||
// Unrelated stuck row was NOT touched.
|
||||
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
|
||||
expect(unrelatedRow.retry_count).toBe(3);
|
||||
expect(unrelatedRow.error_message).toBe('SMTP timeout');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Catalog-driven event-type defaults (#800 follow-up).
|
||||
*
|
||||
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
|
||||
* the v1 API validated against a fixed whitelist. Both now follow the live
|
||||
* event_types catalog; these tests pin the shared resolver.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('resolveDefaultEventType follows the catalog', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so the service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it("prefers the 'other' catch-all while it is active", async () => {
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
});
|
||||
|
||||
it('falls over to the first active type when other is deactivated', async () => {
|
||||
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
|
||||
|
||||
const resolved = await eventTypeService.resolveDefaultEventType();
|
||||
expect(resolved).not.toBe('other');
|
||||
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
|
||||
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it("returns the literal 'other' only for an empty catalog", async () => {
|
||||
const rows = await db('event_types').select('*');
|
||||
await db('event_types').del();
|
||||
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
|
||||
await db('event_types').insert(rows);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
|
||||
* old slug, so a rename behaves like a rename rather than silently detaching
|
||||
* existing events/quotes and orphaning the per-type pre-event reminder template.
|
||||
*/
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// bootCrmDb runs the full core-migration set in beforeAll.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('event type slug rename cascade', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('re-points events + quotes + the reminder template from old slug to new', async () => {
|
||||
// A non-system event type with slug 'party'.
|
||||
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
|
||||
|
||||
// An authored per-type reminder template + an event + a quote, all on 'party'.
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
|
||||
await db('events').insert({
|
||||
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
|
||||
event_name: 'A party', event_date: '2026-09-01',
|
||||
});
|
||||
await db('quotes').insert({
|
||||
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
|
||||
});
|
||||
|
||||
// Rename the slug.
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
|
||||
|
||||
// Event + quote follow the rename.
|
||||
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
|
||||
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
|
||||
// The authored reminder template moved (subject/body preserved), old key gone.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
|
||||
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
|
||||
expect(moved).toBeTruthy();
|
||||
expect(moved.subject_en).toBe('Party reminder');
|
||||
});
|
||||
|
||||
it('does not clobber an existing template for the new slug', async () => {
|
||||
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
|
||||
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
|
||||
|
||||
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
|
||||
|
||||
// Target already existed → left intact; source not force-merged over it.
|
||||
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
|
||||
.toBe('existing soiree');
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Setup-window event type deletion (#800).
|
||||
*
|
||||
* The first-run setup wizard may delete the seeded SYSTEM event types —
|
||||
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
|
||||
* seeds it false on a fresh install, true when an admin already exists).
|
||||
* These tests pin the whole contract:
|
||||
*
|
||||
* - fresh install → flag false → system types deletable (in-use checks
|
||||
* still apply), and the per-type reminder template goes with the type
|
||||
* - reminder-template self-heal does NOT resurrect templates for slugs
|
||||
* that no longer exist in the catalog
|
||||
* - after markSetupWizardCompleted() → system deletion is refused again
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('event type deletion during the setup window (#800)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
let setupService;
|
||||
let ensureEventReminderTemplatesSeeded;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so every service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row.setting_value)).toBe(false);
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a system type that events already use, even in the window', async () => {
|
||||
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
|
||||
await db('events').insert({
|
||||
slug: 'corporate-test-2026-01-01',
|
||||
event_name: 'Test',
|
||||
event_type: 'corporate',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'share-corporate-test',
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
});
|
||||
|
||||
await expect(eventTypeService.deleteEventType(corporate.id))
|
||||
.rejects.toMatchObject({ code: 'IN_USE' });
|
||||
});
|
||||
|
||||
it('deletes an unused system type in the window, taking its reminder template along', async () => {
|
||||
// Seed the per-type reminder templates first so there is something to clean up.
|
||||
await ensureEventReminderTemplatesSeeded(db);
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
|
||||
|
||||
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
|
||||
expect(wedding.is_system).toBeTruthy();
|
||||
|
||||
const result = await eventTypeService.deleteEventType(wedding.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
|
||||
// The deleted slug must NOT stay creatable through the legacy fallback —
|
||||
// the live catalog is authoritative while it has rows.
|
||||
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
|
||||
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
|
||||
// The seeder caches success per process — reset the module to force a
|
||||
// genuine second pass, exactly what a backend restart would run.
|
||||
jest.resetModules();
|
||||
const fresh = require('../../src/services/eventReminderTemplates');
|
||||
await fresh.ensureEventReminderTemplatesSeeded(db);
|
||||
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
// Types still in the catalog keep their templates.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-locks system types once the wizard is marked complete', async () => {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
|
||||
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
|
||||
await expect(eventTypeService.deleteEventType(birthday.id))
|
||||
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
|
||||
|
||||
// Custom (non-system) types remain deletable as before.
|
||||
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
|
||||
const result = await eventTypeService.deleteEventType(custom.id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the completion marker row is missing', async () => {
|
||||
// A portable-backup restore can replace app_settings with a set that
|
||||
// predates migration 161 (which will not rerun) — absence must mean
|
||||
// "configured instance", never an open deletion window.
|
||||
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
await setupService.markSetupWizardCompleted();
|
||||
});
|
||||
|
||||
it('refuses to delete the last remaining event type', async () => {
|
||||
// Reduce the catalog to a single custom type via direct db writes (the
|
||||
// service paths are already covered above), then hit the guard.
|
||||
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
|
||||
await db('events').del();
|
||||
await db('event_types').whereNot('id', solo.id).del();
|
||||
|
||||
await expect(eventTypeService.deleteEventType(solo.id))
|
||||
.rejects.toMatchObject({ code: 'LAST_TYPE' });
|
||||
|
||||
// Deactivating it would empty the ACTIVE catalog just the same.
|
||||
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
|
||||
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* External imports must record captured_at (#1172).
|
||||
*
|
||||
* Managed uploads get it from photoProcessor, which external media never goes
|
||||
* through — so every externally imported photo carried captured_at NULL, and
|
||||
* the gallery's "Date Taken" sort fell back to uploaded_at through its
|
||||
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
|
||||
* which folder was imported first: the reporter's first two days landed at
|
||||
* positions 4204-5296 of 5555.
|
||||
*
|
||||
* Driven through the real route against real files carrying real EXIF, because
|
||||
* the whole question is whether the import reads the file it already has open.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const sharp = require('sharp');
|
||||
|
||||
describe('external import capture dates (#1172)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
|
||||
/**
|
||||
* A real JPEG carrying DateTimeOriginal.
|
||||
*
|
||||
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
|
||||
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
|
||||
* ModifyDate instead).
|
||||
*/
|
||||
const writeJpegWithExif = async (rel, iso) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
|
||||
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
|
||||
.jpeg()
|
||||
.toFile(full);
|
||||
return full;
|
||||
};
|
||||
|
||||
const writeJpegNoExif = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
|
||||
.jpeg().toFile(full);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/services/imageProcessor', () => {
|
||||
const actual = jest.requireActual('../../src/services/imageProcessor');
|
||||
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
|
||||
});
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
it('records the EXIF capture date on import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
|
||||
|
||||
await runImport(eventId, 'trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeTruthy();
|
||||
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
|
||||
// time and exifr resolves it against the HOST timezone, so the stored UTC
|
||||
// value differs between a CEST developer machine and a UTC runner. What
|
||||
// this fix is about is that the field is populated and orders correctly;
|
||||
// that captured_at is not a true instant is a separate, pre-existing
|
||||
// problem shared with managed uploads (#1172's own footnote).
|
||||
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
|
||||
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
|
||||
});
|
||||
|
||||
it('imports a photo with no EXIF date rather than failing it', async () => {
|
||||
// Plenty of sources carry none; that must stay an import, not an error.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegNoExif('trip/plain.jpg');
|
||||
|
||||
const res = await runImport(eventId, 'trip');
|
||||
|
||||
expect(res.body.imported).toBe(1);
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.captured_at).toBeNull();
|
||||
});
|
||||
|
||||
it('orders a two-batch import by capture time, not by batch', async () => {
|
||||
// The reported shape: the FIRST days of the trip imported second. Sorting
|
||||
// on COALESCE(captured_at, uploaded_at) put them after the last days,
|
||||
// because uploaded_at is the import timestamp.
|
||||
const eventId = await seedEvent();
|
||||
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
|
||||
await runImport(eventId, 'late');
|
||||
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
|
||||
await runImport(eventId, 'early');
|
||||
|
||||
const rows = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
|
||||
.select('filename');
|
||||
|
||||
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Two overlapping external imports insert every file twice (#1162).
|
||||
*
|
||||
* The route checked for an existing external_relpath and then inserted, with
|
||||
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
|
||||
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
|
||||
*
|
||||
* Both halves of the fix are driven here through the real route:
|
||||
*
|
||||
* - the in-flight guard, which turns the second click into a 409 instead of
|
||||
* a second full walk of the tree;
|
||||
* - convergence when the guard cannot help (another replica, another
|
||||
* process), which is the unique index from migration 186 firing and the
|
||||
* loop counting a skip rather than dying or duplicating.
|
||||
*
|
||||
* The second is exercised by inserting a competing row from inside the mocked
|
||||
* `sharp().metadata()` call — literally inside the window the bug lived in.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('concurrent external imports (#1162)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// When set, the mocked sharp metadata read inserts this row first — the
|
||||
// other run winning the race between our SELECT and our INSERT.
|
||||
let stealDuringMetadata = null;
|
||||
let thumbnailDelayMs = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// The window. In production this is a real decode of a NAS-hosted file —
|
||||
// hundreds of milliseconds during which the row we just proved absent can
|
||||
// appear. Standing in for the other run here makes that deterministic.
|
||||
jest.doMock('sharp', () => () => ({
|
||||
metadata: async () => {
|
||||
if (stealDuringMetadata) {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
await liveDb('photos').insert(stealDuringMetadata);
|
||||
stealDuringMetadata = null;
|
||||
}
|
||||
return { width: 100, height: 200 };
|
||||
},
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
stealDuringMetadata = null;
|
||||
thumbnailDelayMs = 0;
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extdup',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extdup-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
|
||||
async function relpathCounts(eventId) {
|
||||
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
|
||||
const counts = new Map();
|
||||
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
it('rejects a second import while the first is still running', async () => {
|
||||
const eventId = await seedEvent();
|
||||
// Enough to keep the first request inside its loop while the second
|
||||
// arrives — the "slow import looks hung, so I clicked again" case.
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const statuses = [first.status, second.status].sort();
|
||||
expect(statuses).toEqual([200, 409]);
|
||||
const rejected = first.status === 409 ? first : second;
|
||||
expect(rejected.body.error).toMatch(/already running/i);
|
||||
});
|
||||
|
||||
it('leaves exactly one row per file after both runs', async () => {
|
||||
const eventId = await seedEvent();
|
||||
thumbnailDelayMs = 20;
|
||||
|
||||
await Promise.all([runImport(eventId), runImport(eventId)]);
|
||||
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.size).toBe(3);
|
||||
expect([...counts.values()]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('releases the event once the import finishes, so a re-import still works', async () => {
|
||||
const eventId = await seedEvent();
|
||||
|
||||
expect((await runImport(eventId)).status).toBe(200);
|
||||
// Not 409 — the guard is per run, not a permanent lock on the event.
|
||||
const second = await runImport(eventId);
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(3);
|
||||
});
|
||||
|
||||
it('converges when another writer wins the race mid-file', async () => {
|
||||
// The guard is in-process, so it cannot see a second replica. This is what
|
||||
// the unique index is for: the insert bounces, and the file is counted as
|
||||
// skipped rather than duplicated or lost to a 500.
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
const res = await runImport(eventId);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const counts = await relpathCounts(eventId);
|
||||
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
|
||||
// Two imported by us, one lost to the other writer and reported honestly.
|
||||
expect(res.body.imported).toBe(2);
|
||||
expect(res.body.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let one contended file abort the rest of the import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
stealDuringMetadata = {
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'x/a.jpg',
|
||||
type: 'individual',
|
||||
source_origin: 'external',
|
||||
// Root-relative, as the route now writes it (#1163) — the competing
|
||||
// writer has to target the same value for the race to be real.
|
||||
external_relpath: path.join('nas', 'individual', 'a.jpg'),
|
||||
};
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// All three files present — the contended one via the other writer's row.
|
||||
expect((await relpathCounts(eventId)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Importing a second folder must not move the photos already in the event (#1163).
|
||||
*
|
||||
* events.external_path is overwritten by every import, and external_relpath
|
||||
* used to be stored relative to it — so a second import silently rebased every
|
||||
* existing row onto the new folder. The reporter had 7547 of 8004 originals
|
||||
* pointing at files that do not exist, and nothing said so: thumbnails are
|
||||
* written to local storage during the import while the base path is still
|
||||
* correct, so the grid carries on rendering.
|
||||
*
|
||||
* Driven through the real route and the real resolver, against a real
|
||||
* directory tree — the failure is entirely about whether a file is where the
|
||||
* app looks for it.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('a second external import (#1163)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
|
||||
|
||||
const touch = async (rel) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, 'not-a-real-jpeg');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent() {
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext2nd',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext2nd-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
const runImport = (eventId, external_path) => request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path, recursive: true });
|
||||
|
||||
/** Where the app would go looking for this photo's original, right now. */
|
||||
async function resolved(eventId, filename) {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId, filename }).first();
|
||||
return resolvePhotoFilePath(event, photo);
|
||||
}
|
||||
|
||||
it('stores paths relative to the media root, not to the imported folder', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
|
||||
});
|
||||
|
||||
it('leaves the first folder’s originals reachable after a second import', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/old.jpg');
|
||||
await touch('Trip/Sub/new.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const before = await resolved(eventId, 'old.jpg');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
const after = await resolved(eventId, 'old.jpg');
|
||||
|
||||
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
|
||||
expect(after).toBe(before);
|
||||
expect(fs.existsSync(after)).toBe(true);
|
||||
});
|
||||
|
||||
it('every original in the event is still on disk afterwards', async () => {
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Leknes/a.jpg');
|
||||
await touch('Trip/Leknes/b.jpg');
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos).toHaveLength(3);
|
||||
for (const photo of photos) {
|
||||
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-insert a file the first import already took', async () => {
|
||||
// The dedupe check compares stored paths, so it has to be comparing the
|
||||
// same shape the insert writes.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Sub/c.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
const second = await runImport(eventId, 'Trip/Sub');
|
||||
|
||||
expect(second.body.imported).toBe(0);
|
||||
expect(second.body.skipped).toBe(1);
|
||||
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('resolves a subfolder that repeats its parent’s name', async () => {
|
||||
// The old resolver stripped the relpath's first segment when it matched the
|
||||
// base path's last one, which broke exactly this layout.
|
||||
const eventId = await seedEvent();
|
||||
await touch('Trip/Trip/x.jpg');
|
||||
|
||||
await runImport(eventId, 'Trip');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
const photo = await db('photos').where({ event_id: eventId }).first();
|
||||
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* PostgreSQL integration test for the external-path fold (#1163).
|
||||
*
|
||||
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
|
||||
* points at a throwaway Postgres DB, e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
|
||||
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
|
||||
*
|
||||
* This exists because of a defect SQLite could not have caught. The two-pass
|
||||
* rewrite parks each row on a temporary value, and that value was first written
|
||||
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
|
||||
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
|
||||
* 187 would have rolled back on exactly the installs needing the repair — and
|
||||
* only on the engine most of them run.
|
||||
*
|
||||
* The staging value is therefore an engine-level contract, not an
|
||||
* implementation detail, and it is pinned here on the engine that constrains it.
|
||||
*/
|
||||
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('external relpath fold on Postgres', () => {
|
||||
let pgDb; let mediaRoot; let fold;
|
||||
|
||||
const touch = async (rel, bytes) => {
|
||||
const full = path.join(mediaRoot, rel);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
||||
return bytes;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
jest.resetModules();
|
||||
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
|
||||
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pgDb) await pgDb.destroy();
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
|
||||
delete process.env.EXTERNAL_MEDIA_ROOT;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.text('external_path');
|
||||
});
|
||||
await pgDb.schema.createTable('photos', (t) => {
|
||||
t.increments('id');
|
||||
t.integer('event_id');
|
||||
t.text('external_relpath');
|
||||
t.bigInteger('size_bytes');
|
||||
t.string('source_origin').defaultTo('managed');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key');
|
||||
t.text('setting_value');
|
||||
t.string('setting_type');
|
||||
t.string('updated_at');
|
||||
});
|
||||
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
||||
});
|
||||
|
||||
const relpaths = async () =>
|
||||
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
|
||||
|
||||
it('completes the two-pass repair that a NUL staging value would abort', async () => {
|
||||
// The exact shape that forces staging: `photo.jpg` repairs up to
|
||||
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
|
||||
// deeper. Every final value is distinct, but a final value equals another
|
||||
// row's current one, so the rewrite has to park first.
|
||||
const a = await touch('Trip/photo.jpg', 11);
|
||||
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
||||
await pgDb('photos').insert([
|
||||
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
|
||||
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
|
||||
]);
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
||||
});
|
||||
|
||||
it('leaves no staging value behind', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
|
||||
const rows = await relpaths();
|
||||
expect(rows).toEqual(['Trip/a.jpg']);
|
||||
expect(rows.some((r) => r.includes('staging'))).toBe(false);
|
||||
});
|
||||
|
||||
it('folds and marks in one transaction', async () => {
|
||||
await touch('Trip/a.jpg', 8);
|
||||
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
||||
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
||||
|
||||
await fold(pgDb);
|
||||
// Second run is a no-op: the marker committed with the rewrites.
|
||||
await fold(pgDb);
|
||||
|
||||
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Auto-category rule engine (#1074 phase 3).
|
||||
*
|
||||
* The rules themselves are simple enough to read. What needs testing is the
|
||||
* promise around them: this engine may only ever fill an EMPTY category, and
|
||||
* everything it touches must be reversible. A photographer's own assignment
|
||||
* is a decision; this is a heuristic, and the heuristic never wins.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-autocat-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'autocat-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let engine;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** A scanned photo with `faceCount` faces, each `faceSide` px square. */
|
||||
async function addScannedPhoto(eventId, faceCount, { faceSide = 400, categoryId = null } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'done',
|
||||
face_count: faceCount,
|
||||
category_id: categoryId,
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
for (let i = 0; i < faceCount; i++) {
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 10, bbox_y: 10, bbox_w: faceSide, bbox_h: faceSide,
|
||||
det_score: 0.95,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return photoId;
|
||||
}
|
||||
|
||||
async function enable(on) {
|
||||
const existing = await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled').first();
|
||||
if (existing) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'face_auto_categorize_enabled')
|
||||
.update({ setting_value: JSON.stringify(on) });
|
||||
}
|
||||
}
|
||||
|
||||
async function categoryOf(photoId) {
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
if (!photo.category_id) return null;
|
||||
const cat = await db('photo_categories').where({ id: photo.category_id }).first();
|
||||
return cat?.slug ?? null;
|
||||
}
|
||||
|
||||
describe('faceAutoCategories (#1074 phase 3)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
engine = require('../../src/services/faceAutoCategories');
|
||||
await enable(true);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('rules', () => {
|
||||
it('sorts by face count, and by face size for portraits', async () => {
|
||||
const eventId = await seedEvent('rules');
|
||||
// 400px face in a 1000x1000 frame = 16% of the frame, over the 8% floor.
|
||||
const portrait = await addScannedPhoto(eventId, 1, { faceSide: 400 });
|
||||
const details = await addScannedPhoto(eventId, 0);
|
||||
const small = await addScannedPhoto(eventId, 3);
|
||||
const group = await addScannedPhoto(eventId, 9);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(details)).toBe('details');
|
||||
expect(await categoryOf(portrait)).toBe('portraits');
|
||||
expect(await categoryOf(small)).toBe('small-groups');
|
||||
expect(await categoryOf(group)).toBe('groups');
|
||||
});
|
||||
|
||||
it('does not call a distant single face a portrait', async () => {
|
||||
// One person in a wide landscape is not a portrait of them. 60px in a
|
||||
// 1000x1000 frame is 0.36% — far below the 8% floor.
|
||||
const eventId = await seedEvent('small-face');
|
||||
const distant = await addScannedPhoto(eventId, 1, { faceSide: 60 });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(distant)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores photos that have not been scanned', async () => {
|
||||
const eventId = await seedEvent('unscanned');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'u.jpg', path: '/tmp/u.jpg', type: 'individual',
|
||||
processing_status: 'complete', face_status: 'pending',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the promise', () => {
|
||||
it('NEVER overwrites a category a person chose', async () => {
|
||||
// The single most important behaviour in this file.
|
||||
const eventId = await seedEvent('no-overwrite');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
// 9 faces — the rules would call this "groups" if they were allowed to.
|
||||
const claimed = await addScannedPhoto(eventId, 9, { categoryId: ceremonyId });
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(await categoryOf(claimed)).toBe('ceremony');
|
||||
const row = await db('photos').where({ id: claimed }).first();
|
||||
expect(row.auto_categorized).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks only what it assigned, so undo is exact', async () => {
|
||||
const eventId = await seedEvent('undo');
|
||||
const [c] = await db('photo_categories').insert({
|
||||
name: 'Ceremony', slug: 'ceremony-2', is_global: false, event_id: eventId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const ceremonyId = typeof c === 'object' ? c.id : c;
|
||||
|
||||
const manual = await addScannedPhoto(eventId, 4, { categoryId: ceremonyId });
|
||||
const auto = await addScannedPhoto(eventId, 4);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
expect(await categoryOf(auto)).toBe('small-groups');
|
||||
|
||||
const result = await engine.undoEvent(eventId);
|
||||
|
||||
expect(result.cleared).toBe(1);
|
||||
// The automatic one is cleared...
|
||||
expect(await categoryOf(auto)).toBeNull();
|
||||
// ...and the photographer's own choice survives untouched.
|
||||
expect(await categoryOf(manual)).toBe('ceremony-2');
|
||||
});
|
||||
|
||||
it('is a no-op while the setting is off', async () => {
|
||||
const eventId = await seedEvent('disabled');
|
||||
const photoId = await addScannedPhoto(eventId, 0);
|
||||
|
||||
await enable(false);
|
||||
const result = await engine.categorizeEvent(eventId);
|
||||
await enable(true);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(await categoryOf(photoId)).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — a second run assigns nothing new', async () => {
|
||||
const eventId = await seedEvent('idempotent');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 7);
|
||||
|
||||
const first = await engine.categorizeEvent(eventId);
|
||||
const second = await engine.categorizeEvent(eventId);
|
||||
|
||||
expect(first.assigned).toBe(2);
|
||||
expect(second.assigned).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses one category per slug rather than creating duplicates', async () => {
|
||||
const eventId = await seedEvent('reuse');
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
await addScannedPhoto(eventId, 0);
|
||||
|
||||
await engine.categorizeEvent(eventId);
|
||||
|
||||
const details = await db('photo_categories')
|
||||
.where({ slug: 'details' })
|
||||
.where(function () { this.where('event_id', eventId).orWhere('is_global', true); });
|
||||
expect(details).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* Clustering engine (#1074).
|
||||
*
|
||||
* Uses synthetic embeddings with known identities rather than real faces: the
|
||||
* question here is whether the ALGORITHM groups vectors correctly, which is
|
||||
* separable from whether the model produces good vectors. Model quality is
|
||||
* the spike's job.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceclust-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceclust-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
/** Deterministic unit vector for identity `id`, jittered by `variant`. */
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertFace(eventId, embedding, overrides = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { ...row, id: typeof f === 'object' ? f.id : f };
|
||||
}
|
||||
|
||||
describe('faceClustering (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('embedding round-trip', () => {
|
||||
it('survives pack/unpack through the BLOB column exactly', async () => {
|
||||
const original = makeEmbedding(1);
|
||||
const eventId = await seedEvent('roundtrip');
|
||||
const face = await insertFace(eventId, original);
|
||||
|
||||
const stored = await db('photo_faces').where({ id: face.id }).first();
|
||||
const restored = clustering.unpackEmbedding(stored.embedding);
|
||||
|
||||
expect(restored).toHaveLength(original.length);
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
expect(restored[i]).toBeCloseTo(original[i], 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for a corrupt blob rather than throwing', () => {
|
||||
expect(clustering.unpackEmbedding(Buffer.from([1, 2, 3]))).toBeNull();
|
||||
expect(clustering.unpackEmbedding(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignment', () => {
|
||||
it('groups the same identity and separates different ones', async () => {
|
||||
const eventId = await seedEvent('grouping');
|
||||
const faces = [];
|
||||
// Three identities, four shots each, interleaved so assignment order
|
||||
// is not conveniently grouped.
|
||||
for (let variant = 0; variant < 4; variant++) {
|
||||
for (const identity of [1, 2, 3]) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(identity, variant)));
|
||||
}
|
||||
}
|
||||
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(3);
|
||||
|
||||
// Every face of one identity must share a person id.
|
||||
const rows = await db('photo_faces').where({ event_id: eventId }).select('id', 'person_id');
|
||||
const byPerson = new Map();
|
||||
for (const r of rows) {
|
||||
byPerson.set(r.person_id, (byPerson.get(r.person_id) || 0) + 1);
|
||||
}
|
||||
expect([...byPerson.values()].sort()).toEqual([4, 4, 4]);
|
||||
});
|
||||
|
||||
it('leaves low-quality faces unassigned instead of spawning junk people', async () => {
|
||||
const eventId = await seedEvent('quality-floor');
|
||||
const good = await insertFace(eventId, makeEmbedding(5));
|
||||
// Tiny bbox — below the 40px floor.
|
||||
const tiny = await insertFace(eventId, makeEmbedding(6), { bbox_w: 12, bbox_h: 12 });
|
||||
// Weak detection score.
|
||||
const weak = await insertFace(eventId, makeEmbedding(7), { det_score: 0.2 });
|
||||
|
||||
await clustering.assignFaces(eventId, [good, tiny, weak]);
|
||||
|
||||
const rows = await db('photo_faces')
|
||||
.whereIn('id', [good.id, tiny.id, weak.id])
|
||||
.select('id', 'person_id');
|
||||
const map = Object.fromEntries(rows.map((r) => [r.id, r.person_id]));
|
||||
|
||||
expect(map[good.id]).not.toBeNull();
|
||||
// Still stored — they show in "this photo contains" — just unassigned.
|
||||
expect(map[tiny.id]).toBeNull();
|
||||
expect(map[weak.id]).toBeNull();
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never mixes embedding spaces from different model versions', async () => {
|
||||
const eventId = await seedEvent('model-version');
|
||||
const a = await insertFace(eventId, makeEmbedding(9), { model_version: 'v1' });
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
|
||||
// Same vector, different model. Comparable numerically, meaningless
|
||||
// semantically — it must NOT join the v1 cluster.
|
||||
const b = await insertFace(eventId, makeEmbedding(9), { model_version: 'v2' });
|
||||
await clustering.assignFaces(eventId, [b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId });
|
||||
expect(people).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge and split', () => {
|
||||
it('merge moves every face and removes the source person', async () => {
|
||||
const eventId = await seedEvent('merge');
|
||||
const f1 = await insertFace(eventId, makeEmbedding(11));
|
||||
const f2 = await insertFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [f1, f2]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
const remaining = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(remaining.face_count_total).toBe(2);
|
||||
const orphaned = await db('photo_faces')
|
||||
.where({ event_id: eventId }).whereNull('person_id');
|
||||
expect(orphaned).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('split pulls the named faces into a new person', async () => {
|
||||
const eventId = await seedEvent('split');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 4; v++) faces.push(await insertFace(eventId, makeEmbedding(13, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(4);
|
||||
|
||||
const newId = await clustering.splitPerson(eventId, person.id, [faces[0].id, faces[1].id]);
|
||||
expect(newId).toBeTruthy();
|
||||
|
||||
const original = await db('event_people').where({ id: person.id }).first();
|
||||
const created = await db('event_people').where({ id: newId }).first();
|
||||
expect(original.face_count_total).toBe(2);
|
||||
expect(created.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('deletes a person left with no faces rather than keeping a ghost', async () => {
|
||||
const eventId = await seedEvent('empty-person');
|
||||
const f = await insertFace(eventId, makeEmbedding(15));
|
||||
await clustering.assignFaces(eventId, [f]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
await db('photo_faces').where({ id: f.id }).update({ person_id: null });
|
||||
await clustering.recomputeCentroid(person.id);
|
||||
|
||||
expect(await db('event_people').where({ id: person.id }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regressions from external review', () => {
|
||||
it('merge carries a name and suppression onto the survivor', async () => {
|
||||
// A merge used to move the faces and delete the source outright, so a
|
||||
// photographer-entered name vanished and a person they had hidden came
|
||||
// back guest-visible.
|
||||
const eventId = await seedEvent('merge-metadata');
|
||||
const a = await insertFace(eventId, makeEmbedding(61));
|
||||
const b = await insertFace(eventId, makeEmbedding(62));
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const [p1, p2] = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
// Target is unnamed and visible; the SOURCE carries the human state.
|
||||
await db('event_people').where({ id: p2.id }).update({ label: 'Anna', is_hidden: true });
|
||||
|
||||
await clustering.mergePeople(eventId, [p2.id], p1.id);
|
||||
|
||||
const survivor = await db('event_people').where({ id: p1.id }).first();
|
||||
expect(survivor.label).toBe('Anna');
|
||||
expect(!!survivor.is_hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('recluster keeps hidden/ignored on people that were never named', async () => {
|
||||
// The old query remembered only rows with a label, so a suppressed
|
||||
// bystander came back visible after one "Re-group people".
|
||||
const eventId = await seedEvent('recluster-suppression');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) faces.push(await insertFace(eventId, makeEmbedding(71, v)));
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.label).toBeNull();
|
||||
await db('event_people').where({ id: person.id }).update({ is_ignored: true });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(after.every((p) => !!p.is_ignored)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recluster', () => {
|
||||
it('re-derives clusters and preserves photographer-assigned names', async () => {
|
||||
// This is the property that makes re-clustering safe to offer as a
|
||||
// button: without it, one click silently discards every typed name.
|
||||
const eventId = await seedEvent('recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push(await insertFace(eventId, makeEmbedding(31, v)));
|
||||
faces.push(await insertFace(eventId, makeEmbedding(32, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people).toHaveLength(2);
|
||||
await db('event_people').where({ id: people[0].id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: people[1].id }).update({ label: 'Ben' });
|
||||
|
||||
const count = await clustering.recluster(eventId);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId });
|
||||
const labels = after.map((p) => p.label).filter(Boolean).sort();
|
||||
expect(labels).toEqual(['Anna', 'Ben']);
|
||||
});
|
||||
|
||||
it('is stable across repeated runs', async () => {
|
||||
const eventId = await seedEvent('recluster-stable');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
for (const id of [41, 42]) faces.push(await insertFace(eventId, makeEmbedding(id, v)));
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const first = await clustering.recluster(eventId);
|
||||
const second = await clustering.recluster(eventId);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidate', () => {
|
||||
it('refuses to merge two people the photographer named differently', async () => {
|
||||
// A human assertion this heuristic does not get to overrule.
|
||||
const eventId = await seedEvent('consolidate-labels');
|
||||
const a = await insertFace(eventId, makeEmbedding(51));
|
||||
await clustering.assignFaces(eventId, [a]);
|
||||
const first = await db('event_people').where({ event_id: eventId }).first();
|
||||
|
||||
// A near-identical centroid that would otherwise merge.
|
||||
const [inserted] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(makeEmbedding(51, 0.01)),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const secondId = typeof inserted === 'object' ? inserted.id : inserted;
|
||||
|
||||
await db('event_people').where({ id: first.id }).update({ label: 'Anna' });
|
||||
await db('event_people').where({ id: secondId }).update({ label: 'Ben' });
|
||||
|
||||
await clustering.consolidate(eventId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* External imports are queued for face scanning, in the right order (#1090).
|
||||
*
|
||||
* Managed uploads are enqueued by photoProcessor, which writes face_status
|
||||
* 'pending' once a photo is processed (photoProcessor.js:573 — "the only
|
||||
* correct place to enqueue"). External media never goes through photoProcessor:
|
||||
* adminExternalMedia inserts rows directly, so they stayed NULL and were only
|
||||
* ever picked up by a manual Re-scan.
|
||||
*
|
||||
* The ordering matters as much as the enqueue. events.external_path is written
|
||||
* only AFTER the whole import loop, so marking rows 'pending' as they are
|
||||
* inserted publishes claimable work while the event still points at the old
|
||||
* directory — or none at all, on a first import. The face worker polls
|
||||
* continuously, would resolve those photos against the wrong path, and mark
|
||||
* them permanently 'failed', a state only an explicit Re-scan clears.
|
||||
*
|
||||
* This drives the real route rather than re-implementing it, so removing the
|
||||
* enqueue fails the first test and moving it back onto the insert fails the
|
||||
* second.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('external import queues faces (#1090)', () => {
|
||||
let tmpDir; let db; let app; let mediaRoot;
|
||||
// Recorded from inside the per-photo thumbnail call, i.e. mid-loop.
|
||||
let pendingSeenDuringLoop = 0;
|
||||
let externalPathDuringLoop;
|
||||
// When set to an event id, the mocked thumbnail call turns detection on
|
||||
// mid-loop, standing in for an admin flipping the toggle during an import.
|
||||
let flipFacesOnDuringLoop = null;
|
||||
// Stands in for a concurrent Re-scan completing a row mid-import.
|
||||
let markDoneDuringLoop = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extenq-'));
|
||||
mediaRoot = path.join(tmpDir, 'media');
|
||||
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
|
||||
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
|
||||
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extenq-secret';
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Runs once per photo, inside the import loop — the only hook that can
|
||||
// observe the intermediate state the ordering bug would expose.
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn(async () => {
|
||||
const { db: liveDb } = require('../../src/database/db');
|
||||
const rows = await liveDb('photos').where({ face_status: 'pending' });
|
||||
pendingSeenDuringLoop += rows.length;
|
||||
const ev = await liveDb('events').first();
|
||||
externalPathDuringLoop = ev ? ev.external_path : undefined;
|
||||
if (markDoneDuringLoop) {
|
||||
const rows = await liveDb('photos').orderBy('id', 'asc').limit(1);
|
||||
if (rows.length) {
|
||||
await liveDb('photos').where({ id: rows[0].id }).update({ face_status: 'done' });
|
||||
}
|
||||
}
|
||||
if (flipFacesOnDuringLoop) {
|
||||
await liveDb('events').where({ id: flipFacesOnDuringLoop })
|
||||
.update({ face_recognition_enabled: true });
|
||||
}
|
||||
return 'thumbnails/mock.jpg';
|
||||
}),
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
// bootCrmDb runs every migrations/core/*.up() directly — knex's Migrator
|
||||
// deadlocks on 001_init's nested initializeDatabase() call.
|
||||
({ db } = await require('./helpers/crmDb').bootCrmDb());
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.destroy?.();
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedEvent({ facesEnabled, flagOn }) {
|
||||
await db('feature_flags').insert({ key: 'faces', value: flagOn })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: flagOn }); });
|
||||
// The flag read is TTL-cached (requireFeatureFlag.js:26-34); production
|
||||
// invalidates after every write, and so must this.
|
||||
require('../../src/middleware/requireFeatureFlag').invalidateFeatureFlagCache();
|
||||
|
||||
await db('photos').del();
|
||||
await db('events').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `extenq-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'extenq',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `extenq-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: facesEnabled,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
|
||||
pendingSeenDuringLoop = 0;
|
||||
externalPathDuringLoop = undefined;
|
||||
markDoneDuringLoop = false;
|
||||
return typeof e === 'object' ? e.id : e;
|
||||
}
|
||||
|
||||
async function runImport(eventId) {
|
||||
return request(app)
|
||||
.post(`/api/admin/external-media/events/${eventId}/import-external`)
|
||||
.send({ external_path: 'nas', recursive: true });
|
||||
}
|
||||
|
||||
it('queues imported photos when detection is on', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
const res = await runImport(eventId);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
// The regression: these stayed NULL and waited for a manual Re-scan.
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not publish claimable rows before events.external_path is written', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Observed from inside the loop: nothing is claimable yet. The event path
|
||||
// is already committed (see the test above), so this is no longer load
|
||||
// bearing for correctness — but keeping the enqueue at the end is what lets
|
||||
// the feature setting be read after the loop, so the invariant stays.
|
||||
expect(pendingSeenDuringLoop).toBe(0);
|
||||
|
||||
// ...and afterwards both are in place.
|
||||
const ev = await db('events').where({ id: eventId }).first();
|
||||
expect(ev.external_path).toBe('nas');
|
||||
expect((await db('photos').where({ event_id: eventId, face_status: 'pending' })).length)
|
||||
.toBe((await db('photos').where({ event_id: eventId })).length);
|
||||
});
|
||||
|
||||
it('honours a toggle flipped DURING the import', async () => {
|
||||
// The setting is read after the loop, not before: on a large library the
|
||||
// loop runs for minutes, and the toggle endpoint only queues rows that
|
||||
// already existed when it fired. Reading it up front would strand every
|
||||
// photo imported after that moment at NULL forever.
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
flipFacesOnDuringLoop = eventId;
|
||||
|
||||
await runImport(eventId);
|
||||
flipFacesOnDuringLoop = null;
|
||||
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('commits events.external_path before the first row is inserted', async () => {
|
||||
// enqueueEvent accepts processing_status NULL (faceProcessor.js:243-246),
|
||||
// which these inserts leave unset — so a toggle or Re-scan firing mid-import
|
||||
// can queue partial rows. If the event still pointed at the old directory
|
||||
// they would resolve against it and burn to 'failed'. Setting the path
|
||||
// first also means a half-finished import leaves rows that still resolve,
|
||||
// instead of rows stranded against the previous path.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
|
||||
await runImport(eventId);
|
||||
|
||||
// Sampled from inside the per-photo thumbnail call, i.e. while rows are
|
||||
// still being inserted.
|
||||
expect(externalPathDuringLoop).toBe('nas');
|
||||
});
|
||||
|
||||
it('does not re-queue rows a concurrent scan already handled', async () => {
|
||||
// Committing the event path before the loop means a toggle or Re-scan
|
||||
// firing mid-import can now genuinely queue and even finish some of these
|
||||
// rows. A blanket update at the end would drag 'done' rows back to
|
||||
// 'pending' for a duplicate sidecar scan and knock 'processing' rows out
|
||||
// from under the worker.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: true });
|
||||
markDoneDuringLoop = true;
|
||||
|
||||
await runImport(eventId);
|
||||
markDoneDuringLoop = false;
|
||||
|
||||
const done = await db('photos').where({ event_id: eventId, face_status: 'done' });
|
||||
expect(done.length).toBeGreaterThan(0); // the concurrent scan's work survived
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the per-event toggle is off', async () => {
|
||||
const eventId = await seedEvent({ facesEnabled: false, flagOn: true });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves face_status untouched when the global flag is off', async () => {
|
||||
// Installs without the feature must never accumulate face_status rows —
|
||||
// the same invariant photoProcessor's guard protects.
|
||||
const eventId = await seedEvent({ facesEnabled: true, flagOn: false });
|
||||
await runImport(eventId);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos.every((p) => p.face_status === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* External / reference photos are scannable (#1090).
|
||||
*
|
||||
* faceProcessor used to short-circuit every photo with source_origin
|
||||
* 'external' or 'reference' to 'skipped', because resolvePhotoStorageKey
|
||||
* returns null for anything outside managed storage and ensurePreviewImage
|
||||
* could not build a preview for it. #1078 removed that limitation —
|
||||
* ensurePreviewImage now reads externals straight off the mount and writes
|
||||
* the preview into managed storage — but the guard stayed, so the whole
|
||||
* feature was a no-op on external-media installs. The reporter's gallery sat
|
||||
* at 0/3230 with every row 'skipped' and no error.
|
||||
*
|
||||
* These pin both halves: the guard is gone, and a photo whose source is
|
||||
* genuinely missing still fails rather than being quietly skipped — the blanket
|
||||
* skip used to absorb that case too, so a real breakage looked like an
|
||||
* unsupported one.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceext-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceext-test-secret';
|
||||
// A real, existing media root. getExternalMediaRoot only honours the env var
|
||||
// if the directory exists and caches it on first call, so this has to be set
|
||||
// up before anything requires externalMediaService.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(path.dirname(process.env.TEST_DATABASE_PATH), 'media');
|
||||
fs.mkdirSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual'), { recursive: true });
|
||||
// Non-empty on purpose: an empty directory is read as an unmounted share
|
||||
// (faceTransientSource.test.js), so a "healthy storage, dead photo" fixture
|
||||
// needs a sibling present or it defers instead of failing.
|
||||
fs.writeFileSync(path.join(process.env.EXTERNAL_MEDIA_ROOT, 'share', 'individual', 'sibling.jpg'), 'x');
|
||||
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
// Set per-test: what ensurePreviewImage returns for the photo under test.
|
||||
let previewKeyResult; // eslint-disable-line prefer-const
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
const mockDetectFaces = jest.fn();
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: (...args) => mockDetectFaces(...args),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto({ sourceOrigin = 'managed', sourceMode = 'managed' } = {}) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `ext-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'ext',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `ext-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: sourceMode,
|
||||
external_path: 'share',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'ext.jpg',
|
||||
path: '/tmp/ext.jpg',
|
||||
type: 'individual',
|
||||
width: 1920,
|
||||
height: 1440,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: sourceOrigin,
|
||||
external_relpath: sourceOrigin === 'managed' ? null : 'individual/ext.jpg',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face scanning of external/reference photos (#1090)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
mockDetectFaces.mockClear();
|
||||
previewKeyResult = 'previews/preview_ext.jpg';
|
||||
mockDetectFaces.mockResolvedValue({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: [100, 100, 50, 50],
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['external', 'reference'])('scans a %s photo instead of skipping it', async (origin) => {
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: origin, sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
// The regression: this used to return 'skipped' without ever building a
|
||||
// preview or contacting the sidecar.
|
||||
expect(result.status).not.toBe('skipped');
|
||||
expect(mockEnsurePreviewImage).toHaveBeenCalled();
|
||||
expect(mockDetectFaces).toHaveBeenCalled();
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('done');
|
||||
expect(await db('photo_faces').where({ photo_id: photoId }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails, not skips, when the external source is genuinely gone', async () => {
|
||||
// A missing file is a property of that photo, so it should be visible as a
|
||||
// failure the admin can act on — not silently absorbed the way the old
|
||||
// blanket skip did.
|
||||
//
|
||||
// The containing directory exists here on purpose. An absent directory is
|
||||
// a dropped mount, which defers rather than fails
|
||||
// (faceTransientSource.test.js); this is the other case — healthy storage,
|
||||
// dead photo.
|
||||
previewKeyResult = null;
|
||||
const { photoId } = await seedPhoto({ sourceOrigin: 'external', sourceMode: 'reference' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(mockDetectFaces).not.toHaveBeenCalled();
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* Automatic consolidation reporting and the suggestion band (#1107).
|
||||
*
|
||||
* Centroids are built to an EXACT cosine similarity rather than jittered
|
||||
* towards one, because every assertion here is about which side of a threshold
|
||||
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
|
||||
* product is the requested number to floating-point precision, and each pair
|
||||
* is built on its own orthogonal basis so two different pairs are never
|
||||
* accidentally similar to each other.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facesuggest-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
|
||||
// suggestion band is [0.60, 0.68).
|
||||
const THRESHOLDS = {
|
||||
face_match_threshold: 0.6,
|
||||
face_quality_min_score: 0.7,
|
||||
face_quality_min_px: 40,
|
||||
};
|
||||
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
const orth = Math.sqrt(1 - target * target);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = orth;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 5,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** One person with one real face, so merge/split have something to move. */
|
||||
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
|
||||
const personId = await insertPerson(eventId, centroid, overrides);
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return personId;
|
||||
}
|
||||
|
||||
/** An additional face on an existing person, so a split has something to move. */
|
||||
async function addFaceTo(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof f === 'object' ? f.id : f;
|
||||
}
|
||||
|
||||
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
describe('face merge suggestions (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the band', () => {
|
||||
it('suggests a pair between the match and auto-merge thresholds', async () => {
|
||||
const eventId = await seedEvent('band-inside');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
|
||||
expect(out[0].score).toBeCloseTo(0.64, 4);
|
||||
});
|
||||
|
||||
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
|
||||
const eventId = await seedEvent('band-above');
|
||||
const [a, b] = pairAtSimilarity(0.75, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays silent below the match threshold — further apart than one face would join', async () => {
|
||||
const eventId = await seedEvent('band-below');
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it refuses to ask about', () => {
|
||||
it('never questions two people the photographer named differently', async () => {
|
||||
const eventId = await seedEvent('named-apart');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b, { label: 'Beatrix' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still asks when only one of the two is named', async () => {
|
||||
const eventId = await seedEvent('one-named');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a, { label: 'Anna' });
|
||||
await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips a person marked "not a real person" — that answer was already given', async () => {
|
||||
const eventId = await seedEvent('ignored');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { is_ignored: true });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never crosses embedding spaces', async () => {
|
||||
const eventId = await seedEvent('model-skew');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
await insertPerson(eventId, a);
|
||||
await insertPerson(eventId, b, { model_version: 'test-v2' });
|
||||
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissal', () => {
|
||||
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
|
||||
const eventId = await seedEvent('dismissal');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
expect(await suggest(eventId)).toHaveLength(1);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
|
||||
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
|
||||
// double-click, not an error.
|
||||
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
|
||||
dismissed: true,
|
||||
});
|
||||
expect(await suggest(eventId)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The swallow-the-duplicate branch has to discriminate, because the failure
|
||||
* it must NOT swallow looks identical to the caller: returning
|
||||
* "kept separate" for a decision that was never written means the pair
|
||||
* silently comes back after the next scan.
|
||||
*
|
||||
* Tested on the predicate directly — provoking a read-only database or a
|
||||
* dropped table mid-suite would corrupt the shared fixture for every other
|
||||
* case in this file.
|
||||
*/
|
||||
it.each([
|
||||
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
|
||||
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
|
||||
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
|
||||
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
|
||||
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
|
||||
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
|
||||
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
|
||||
['no error at all', null, false],
|
||||
])('%s → swallowed: %s', (_name, err, expected) => {
|
||||
expect(clustering.isUniqueViolation(err)).toBe(expected);
|
||||
});
|
||||
|
||||
/**
|
||||
* The dismissal read is the only thing standing between the automatic pass
|
||||
* and a pair the photographer explicitly separated. If it fails open, a
|
||||
* timeout silently restores the merge that "Not the same" was supposed to
|
||||
* prevent — so anything other than a missing table must stop the pass.
|
||||
*/
|
||||
it('refuses to consolidate when the dismissal list cannot be read', async () => {
|
||||
const eventId = await seedEvent('dismissals-unreadable');
|
||||
// Well above the auto-merge threshold, so only a refusal keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
// Break the read for real rather than mocking knex: dropping a selected
|
||||
// column makes the query fail with something that is NOT "missing
|
||||
// table", which is exactly the class that must not fail open.
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
|
||||
try {
|
||||
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
|
||||
.rejects.toThrow();
|
||||
|
||||
// Nothing merged: the pass gave up rather than overriding a decision
|
||||
// it could not read.
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
} finally {
|
||||
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
|
||||
t.integer('person_b_id').notNullable().defaultTo(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
|
||||
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
|
||||
// The one that matters: a missing COLUMN is a broken query, not a
|
||||
// pre-migration install, and must NOT be allowed to fail open.
|
||||
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
|
||||
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
|
||||
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
|
||||
])('missing-table check — %s → %s', (_name, err, expected) => {
|
||||
expect(clustering.isMissingTable(err)).toBe(expected);
|
||||
});
|
||||
|
||||
it('normalizes the pair so one row covers both orderings', async () => {
|
||||
const eventId = await seedEvent('dismissal-normalized');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idB, idA);
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
|
||||
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
|
||||
});
|
||||
});
|
||||
|
||||
describe('one suggestion per person per round', () => {
|
||||
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
|
||||
const eventId = await seedEvent('three-way');
|
||||
// Three mutually similar centroids, all inside the band.
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const people = [];
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const v = new Float32Array(DIM);
|
||||
v[0] = 0.9;
|
||||
v[1 + k] = Math.sqrt(1 - 0.81);
|
||||
people.push(await insertPerson(eventId, v));
|
||||
}
|
||||
await insertPerson(eventId, base);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
// Every returned pair must name people not already spoken for: accepting
|
||||
// the first suggestion must never leave a second one pointing at a person
|
||||
// that the merge just deleted.
|
||||
const seen = new Set();
|
||||
for (const s of out) {
|
||||
expect(seen.has(s.person_a_id)).toBe(false);
|
||||
expect(seen.has(s.person_b_id)).toBe(false);
|
||||
seen.add(s.person_a_id);
|
||||
seen.add(s.person_b_id);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers the most similar pair first', async () => {
|
||||
const eventId = await seedEvent('ordering');
|
||||
const [a1, b1] = pairAtSimilarity(0.62, 0);
|
||||
const [a2, b2] = pairAtSimilarity(0.67, 10);
|
||||
await insertPerson(eventId, a1);
|
||||
await insertPerson(eventId, b1);
|
||||
await insertPerson(eventId, a2);
|
||||
await insertPerson(eventId, b2);
|
||||
|
||||
const out = await suggest(eventId);
|
||||
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].score).toBeGreaterThan(out[1].score);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual splits survive the automatic pass', () => {
|
||||
/**
|
||||
* The regression that matters most once consolidation runs on every scan:
|
||||
* a photographer splitting a wrongly-merged cluster produces two people
|
||||
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
|
||||
* merge threshold and the very next scan would put them straight back.
|
||||
*/
|
||||
it('records a split as a separation, so consolidation leaves it alone', async () => {
|
||||
const eventId = await seedEvent('split-protected');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
|
||||
// One cluster holding two near-identical faces.
|
||||
const personId = await insertPersonWithFace(eventId, base);
|
||||
const extraFaceId = await addFaceTo(eventId, personId, base);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
|
||||
.toEqual([personId, newPersonId].sort());
|
||||
|
||||
// Identical centroids — nothing but the recorded separation can stop
|
||||
// this merge.
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consolidation reporting', () => {
|
||||
it('records what an automatic pass merged, so it is not silent', async () => {
|
||||
const eventId = await seedEvent('report-merged');
|
||||
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
expect(merged).toHaveLength(1);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(1);
|
||||
expect(event.faces_last_consolidated_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
|
||||
const eventId = await seedEvent('consolidate-ignored');
|
||||
// Well above the auto-merge threshold: only the is_ignored flag can
|
||||
// stop this pair.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const real = await insertPersonWithFace(eventId, a);
|
||||
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
// Both still standing, and the real person is still guest-visible —
|
||||
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
|
||||
// cluster would have hidden a real person from the gallery.
|
||||
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
|
||||
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
|
||||
const realRow = survivors.find((p) => p.id === real);
|
||||
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
|
||||
});
|
||||
|
||||
it('never merges a pair the photographer said was not the same person', async () => {
|
||||
const eventId = await seedEvent('consolidate-dismissed');
|
||||
// Also above the auto-merge threshold: the dismissal is the only thing
|
||||
// standing between these two, which is the point — a human "no" has to
|
||||
// outrank the automatic pass, not just the suggestion list.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPersonWithFace(eventId, a);
|
||||
const idB = await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
|
||||
.toEqual(expect.objectContaining({ c: 2 }));
|
||||
});
|
||||
|
||||
// NOT covered by a test: reporting what a pass merged before it died
|
||||
// partway. `consolidate` calls `mergePeople` through the module-local
|
||||
// binding, so a spy on the export cannot intercept it, and no realistic
|
||||
// database failure lands on the second merge only. The recording therefore
|
||||
// sits in a `finally` — each mergePeople is its own transaction, so a pass
|
||||
// that throws has still committed what it did, and the alternative is a
|
||||
// real merge going unreported. Verified by reading, not by assertion.
|
||||
|
||||
it('clears a previous count when a later pass merges nothing', async () => {
|
||||
const eventId = await seedEvent('report-cleared');
|
||||
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
|
||||
|
||||
const [a, b] = pairAtSimilarity(0.5, 0);
|
||||
await insertPersonWithFace(eventId, a);
|
||||
await insertPersonWithFace(eventId, b);
|
||||
|
||||
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(Number(event.faces_last_consolidated_count)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* Privacy and visibility guarantees for face recognition (#1074).
|
||||
*
|
||||
* These are the tests that matter most in this feature. Two of them cover
|
||||
* defects that would be invisible in normal use:
|
||||
*
|
||||
* - The people strip is computed from face rows, which have no concept of
|
||||
* photo visibility. Handing a guest a raw count leaks how many hidden
|
||||
* photos someone appears in, and a cover face picked without scoping
|
||||
* renders a crop of a photo the guest may not open.
|
||||
*
|
||||
* - Face embeddings are biometric data. They must not ride along in a
|
||||
* .picpeak export, which gets handed to clients and moved between
|
||||
* operators.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-faceprivacy-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'faceprivacy-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering; let peopleService; let faceProcessor;
|
||||
|
||||
function makeEmbedding(id, variant = 0, dim = 64) {
|
||||
const vec = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) {
|
||||
vec[i] = Math.sin((i + 1) * (id + 1) * 0.7) + variant * 0.02 * Math.cos(i * 3.1);
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function addPhotoWithFace(eventId, embedding, { visibility = 'visible', score = 0.99 } = {}) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
visibility,
|
||||
processing_status: 'complete',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
|
||||
const row = {
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
|
||||
det_score: score,
|
||||
embedding: clustering.packEmbedding(embedding),
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const [f] = await db('photo_faces').insert(row).returning('id');
|
||||
return { photoId, face: { ...row, id: typeof f === 'object' ? f.id : f } };
|
||||
}
|
||||
|
||||
describe('face privacy and visibility (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
peopleService = require('../../src/services/facePeopleService');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('visibility scoping', () => {
|
||||
it('counts only photos the audience can actually see', async () => {
|
||||
const eventId = await seedEvent('visibility-count');
|
||||
const faces = [];
|
||||
// Same person: 3 visible photos, 4 hidden ones.
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v))).face);
|
||||
}
|
||||
for (let v = 3; v < 7; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(1, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
|
||||
expect(guestView).toHaveLength(1);
|
||||
// The leak this test exists to prevent: 3, never 7.
|
||||
expect(guestView[0].face_count).toBe(3);
|
||||
expect(clientView[0].face_count).toBe(7);
|
||||
});
|
||||
|
||||
it('never returns face_count_total to a guest', async () => {
|
||||
const eventId = await seedEvent('no-total-leak');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(2));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(person).not.toHaveProperty('total_face_count');
|
||||
expect(person).not.toHaveProperty('is_hidden');
|
||||
});
|
||||
|
||||
it('picks a cover face from a photo the guest may open', async () => {
|
||||
const eventId = await seedEvent('cover-scoping');
|
||||
// The BEST face (highest score) is in a hidden photo — a naive
|
||||
// implementation would hand its crop to the guest.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(3, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(3, 1), {
|
||||
visibility: 'visible', score: 0.80,
|
||||
});
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [guestPerson] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestPerson.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestPerson.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('prefers the cover the photographer chose (#1096)', async () => {
|
||||
const eventId = await seedEvent('chosen-cover');
|
||||
// The auto-pick would take the 0.99 face. The photographer picked the
|
||||
// other one — without this the PATCH saved, the toast said so, and the
|
||||
// avatar reverted on the very next read.
|
||||
const best = await addPhotoWithFace(eventId, makeEmbedding(9, 0), { score: 0.99 });
|
||||
const chosen = await addPhotoWithFace(eventId, makeEmbedding(9, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [best.face, chosen.face]);
|
||||
|
||||
const [before] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(before.cover.photo_id).toBe(best.photoId);
|
||||
// Clustering must not have written one: an automatic seed here would be
|
||||
// indistinguishable from a real choice the moment listPeople honours it.
|
||||
const seeded = await db('event_people').where({ id: before.id }).first();
|
||||
expect(seeded.cover_face_id).toBeFalsy();
|
||||
|
||||
await db('event_people').where({ id: before.id }).update({ cover_face_id: chosen.face.id });
|
||||
|
||||
const [after] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(after.cover.photo_id).toBe(chosen.photoId);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a merge', async () => {
|
||||
const eventId = await seedEvent('cover-merge');
|
||||
const a = await addPhotoWithFace(eventId, makeEmbedding(20, 0), { score: 0.90 });
|
||||
const b = await addPhotoWithFace(eventId, makeEmbedding(60, 0), { score: 0.95 });
|
||||
await clustering.assignFaces(eventId, [a.face]);
|
||||
await clustering.assignFaces(eventId, [b.face]);
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
expect(people.length).toBeGreaterThan(1);
|
||||
|
||||
// The SOURCE carries the choice; the target has none.
|
||||
await db('event_people').where({ id: people[1].id }).update({ cover_face_id: b.face.id });
|
||||
await clustering.mergePeople(eventId, [people[1].id], people[0].id);
|
||||
|
||||
const target = await db('event_people').where({ id: people[0].id }).first();
|
||||
expect(target.cover_face_id).toBe(b.face.id);
|
||||
});
|
||||
|
||||
it('carries a chosen cover through a recluster', async () => {
|
||||
const eventId = await seedEvent('cover-recluster');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(21, v), { score: 0.9 - v * 0.1 })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
const [person] = await db('event_people').where({ event_id: eventId });
|
||||
// Pick the WORST-scoring face, so an automatic re-pick would differ.
|
||||
const chosen = faces[2].id;
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: chosen });
|
||||
|
||||
await clustering.recluster(eventId);
|
||||
|
||||
const after = await db('event_people').where({ event_id: eventId }).whereNotNull('cover_face_id');
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].cover_face_id).toBe(chosen);
|
||||
});
|
||||
|
||||
it('falls back to a visible face when the chosen cover is hidden from this audience', async () => {
|
||||
const eventId = await seedEvent('chosen-cover-hidden');
|
||||
// Choosing a cover must never override the visibility scoping — that
|
||||
// would hand a guest a crop of a photo they cannot open.
|
||||
const hidden = await addPhotoWithFace(eventId, makeEmbedding(10, 0), {
|
||||
visibility: 'hidden', score: 0.99,
|
||||
});
|
||||
const visible = await addPhotoWithFace(eventId, makeEmbedding(10, 1), { score: 0.70 });
|
||||
await clustering.assignFaces(eventId, [hidden.face, visible.face]);
|
||||
|
||||
const [person] = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
await db('event_people').where({ id: person.id }).update({ cover_face_id: hidden.face.id });
|
||||
|
||||
const [guestView] = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView.cover.photo_id).toBe(visible.photoId);
|
||||
expect(guestView.cover.photo_id).not.toBe(hidden.photoId);
|
||||
});
|
||||
|
||||
it('drops a person entirely when all their photos are hidden', async () => {
|
||||
const eventId = await seedEvent('all-hidden');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(4, v), { visibility: 'hidden' })).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const clientView = await peopleService.listPeople(eventId, { isClient: true, minClusterSize: 1 });
|
||||
expect(clientView).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits hidden and ignored people from the guest response', async () => {
|
||||
const eventId = await seedEvent('hidden-people');
|
||||
const a = (await addPhotoWithFace(eventId, makeEmbedding(5))).face;
|
||||
const b = (await addPhotoWithFace(eventId, makeEmbedding(6))).face;
|
||||
await clustering.assignFaces(eventId, [a, b]);
|
||||
|
||||
const people = await db('event_people').where({ event_id: eventId }).orderBy('id');
|
||||
await db('event_people').where({ id: people[0].id }).update({ is_hidden: true });
|
||||
await db('event_people').where({ id: people[1].id }).update({ is_ignored: true });
|
||||
|
||||
const guestView = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 1 });
|
||||
expect(guestView).toHaveLength(0);
|
||||
const adminView = await peopleService.listPeople(eventId, { isClient: true, forAdmin: true });
|
||||
expect(adminView).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not attach a hidden person to a photo a guest can see', async () => {
|
||||
const eventId = await seedEvent('person-ids-hidden');
|
||||
const { photoId, face } = await addPhotoWithFace(eventId, makeEmbedding(7));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
await db('event_people').where({ id: person.id }).update({ is_hidden: true });
|
||||
|
||||
const guestMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: false });
|
||||
expect(guestMap.get(photoId)).toBeUndefined();
|
||||
|
||||
const adminMap = await peopleService.getPersonIdsByPhoto(eventId, [photoId], { forAdmin: true });
|
||||
expect(adminMap.get(photoId)).toEqual([person.id]);
|
||||
});
|
||||
|
||||
it('respects the minimum cluster size so one-off bystanders stay out', async () => {
|
||||
const eventId = await seedEvent('min-cluster');
|
||||
const solo = (await addPhotoWithFace(eventId, makeEmbedding(8))).face;
|
||||
const crowd = [];
|
||||
for (let v = 0; v < 4; v++) {
|
||||
crowd.push((await addPhotoWithFace(eventId, makeEmbedding(9, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, [solo, ...crowd]);
|
||||
|
||||
const people = await peopleService.listPeople(eventId, { isClient: false, minClusterSize: 3 });
|
||||
expect(people).toHaveLength(1);
|
||||
expect(people[0].face_count).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('erasure', () => {
|
||||
it('purgeEvent removes every face row and resets the photos', async () => {
|
||||
const eventId = await seedEvent('purge');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(10, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
await db('photos').where({ event_id: eventId }).update({ face_status: 'done', face_count: 1 });
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).not.toHaveLength(0);
|
||||
|
||||
await faceProcessor.purgeEvent(eventId);
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
expect(photos.every((p) => p.face_status === null && p.face_count === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('purgePhotoFaces removes face rows WITHOUT relying on the FK cascade', async () => {
|
||||
// The regression this guards: PicPeak does not enable
|
||||
// `PRAGMA foreign_keys` on SQLite, so ON DELETE CASCADE never fires
|
||||
// there and biometric embeddings outlived the photo. The pragma is
|
||||
// explicitly OFF here so the assertion can only pass if the deletion
|
||||
// path purges the rows itself.
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
|
||||
const eventId = await seedEvent('purge-no-cascade');
|
||||
const faces = [];
|
||||
for (let v = 0; v < 3; v++) {
|
||||
faces.push((await addPhotoWithFace(eventId, makeEmbedding(20, v))).face);
|
||||
}
|
||||
await clustering.assignFaces(eventId, faces);
|
||||
|
||||
const person = await db('event_people').where({ event_id: eventId }).first();
|
||||
expect(person.face_count_total).toBe(3);
|
||||
|
||||
const victim = faces[0];
|
||||
await faceProcessor.purgePhotoFaces(victim.photo_id);
|
||||
|
||||
expect(await db('photo_faces').where({ photo_id: victim.photo_id })).toHaveLength(0);
|
||||
// …and the person it belonged to was rebuilt, not left with a stale count.
|
||||
const after = await db('event_people').where({ id: person.id }).first();
|
||||
expect(after.face_count_total).toBe(2);
|
||||
});
|
||||
|
||||
it('purging the last face of a person removes the person too', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = OFF');
|
||||
const eventId = await seedEvent('purge-last-face');
|
||||
const { face, photoId } = await addPhotoWithFace(eventId, makeEmbedding(21));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(1);
|
||||
|
||||
await faceProcessor.purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleting an event removes its people and faces', async () => {
|
||||
await db.raw('PRAGMA foreign_keys = ON');
|
||||
const eventId = await seedEvent('event-delete');
|
||||
const { face } = await addPhotoWithFace(eventId, makeEmbedding(11));
|
||||
await clustering.assignFaces(eventId, [face]);
|
||||
|
||||
await db('photos').where({ event_id: eventId }).del();
|
||||
await db('events').where({ id: eventId }).del();
|
||||
|
||||
expect(await db('photo_faces').where({ event_id: eventId })).toHaveLength(0);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-in-one image block (#1042 / PR #1068)', () => {
|
||||
// Blocked for performance: the AIO image runs backend, frontend, SQLite
|
||||
// and every worker in one container, with no ML sidecar to talk to. The
|
||||
// failure there would not be loud — just a slow install that looks
|
||||
// broken — so the gate is asserted rather than assumed.
|
||||
const faceSettings = require('../../src/services/faceSettings');
|
||||
|
||||
afterEach(() => { delete process.env.PICPEAK_SINGLE_CONTAINER; });
|
||||
|
||||
it('reports the feature off regardless of the flag row', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
// Even with the flag ON in the database.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => {
|
||||
await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
});
|
||||
expect(await faceSettings.isFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses per-event detection too', async () => {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
|
||||
const eventId = await seedEvent('aio-block');
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
expect(event.face_recognition_enabled).toBeTruthy();
|
||||
expect(await faceSettings.isEnabledForEvent(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts only explicit truthy markers', () => {
|
||||
for (const v of ['true', '1', 'yes', 'TRUE']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(true);
|
||||
}
|
||||
for (const v of ['false', '0', '', 'no']) {
|
||||
process.env.PICPEAK_SINGLE_CONTAINER = v;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
}
|
||||
delete process.env.PICPEAK_SINGLE_CONTAINER;
|
||||
expect(faceSettings.isSingleContainerImage()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export and backup exclusion', () => {
|
||||
it('excludes both face tables from .picpeak exports', () => {
|
||||
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
|
||||
expect(EXCLUDED_TABLES.has('photo_faces')).toBe(true);
|
||||
expect(EXCLUDED_TABLES.has('event_people')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes both face tables from the database backup table list', async () => {
|
||||
const databaseBackup = require('../../src/services/databaseBackup');
|
||||
const service = databaseBackup.DatabaseBackupService
|
||||
? new databaseBackup.DatabaseBackupService()
|
||||
: databaseBackup;
|
||||
if (typeof service.getTables !== 'function') return; // shape differs; covered by the export test
|
||||
|
||||
const tables = await service.getTables();
|
||||
expect(tables).not.toContain('photo_faces');
|
||||
expect(tables).not.toContain('event_people');
|
||||
// Sanity: the filter didn't eat everything.
|
||||
expect(tables).toContain('events');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Bounding-box coordinate space (#1074).
|
||||
*
|
||||
* The sidecar reports boxes in the pixel space of the image it was HANDED —
|
||||
* the ≤1920px preview — while every consumer (the strip's avatar crop, the
|
||||
* admin manager, the auto-category portrait rule) compares them against
|
||||
* photos.width/height, the ORIGINAL dimensions. faceProcessor scales once so
|
||||
* everything downstream can assume original-image coordinates.
|
||||
*
|
||||
* This is the defect that survived longest in review, and it is invisible on
|
||||
* any photo already under 1920px — the entire demo gallery was 750px, so the
|
||||
* scale factor was always exactly 1.0 and the correction never ran. Verified
|
||||
* by hand afterwards on a real 4000x3000 upload (stored box moved from
|
||||
* 1493,204 to 3110,426 — a factor of 2.083, exactly 4000/1920). This test
|
||||
* exists so that verification does not have to be repeated by hand.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facescale-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facescale-test-secret';
|
||||
|
||||
// A 1920x1440 JPEG standing in for the preview rendition. faceProcessor reads
|
||||
// its dimensions with sharp to derive the scale, so it must be a real image.
|
||||
const sharp = require('sharp');
|
||||
|
||||
let mockPreviewBuffer;
|
||||
const mockSidecarBox = [1493, 204, 131, 161]; // what the sidecar sees on the preview
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: jest.fn(async () => 'previews/preview_test.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({
|
||||
getStorage: () => ({ get: async () => mockPreviewBuffer }),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({
|
||||
model_version: 'test-v1',
|
||||
faces: [{
|
||||
bbox: mockSidecarBox,
|
||||
score: 0.99,
|
||||
landmarks: [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
|
||||
yaw: 0, pitch: 0, blur: 500,
|
||||
embedding: Array.from({ length: 64 }, (_, i) => (i === 0 ? 1 : 0)),
|
||||
}],
|
||||
})),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedPhoto(width, height) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `scale-${width}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'scale',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `scale-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'big.jpg',
|
||||
path: '/tmp/big.jpg',
|
||||
type: 'individual',
|
||||
width,
|
||||
height,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('face bbox coordinate space (#1074)', () => {
|
||||
beforeAll(async () => {
|
||||
mockPreviewBuffer = await sharp({
|
||||
create: { width: 1920, height: 1440, channels: 3, background: { r: 20, g: 40, b: 80 } },
|
||||
}).jpeg().toBuffer();
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// The faces flag gates everything; turn it on for this suite.
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('scales preview-space boxes into ORIGINAL image coordinates', async () => {
|
||||
// 4000px original, 1920px preview -> every coordinate must grow by 4000/1920.
|
||||
const { photoId } = await seedPhoto(4000, 3000);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
const scale = 4000 / 1920;
|
||||
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0] * scale, 1);
|
||||
expect(face.bbox_y).toBeCloseTo(mockSidecarBox[1] * scale, 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2] * scale, 1);
|
||||
expect(face.bbox_h).toBeCloseTo(mockSidecarBox[3] * scale, 1);
|
||||
|
||||
// The regression this guards: the raw preview-space value being stored.
|
||||
expect(face.bbox_x).not.toBeCloseTo(mockSidecarBox[0], 1);
|
||||
// And a sanity check that it lands inside the original frame.
|
||||
expect(face.bbox_x + face.bbox_w).toBeLessThanOrEqual(4000);
|
||||
});
|
||||
|
||||
it('leaves boxes untouched when the photo is already preview-sized', async () => {
|
||||
// The case that hid the bug: no downscale, so scale is exactly 1 and the
|
||||
// stored box equals what the sidecar reported.
|
||||
const { photoId } = await seedPhoto(1920, 1440);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
expect(face.bbox_w).toBeCloseTo(mockSidecarBox[2], 1);
|
||||
});
|
||||
|
||||
it('falls back to unscaled rather than corrupting when width is unknown', async () => {
|
||||
// Pre-dimension-migration rows have no width. Storing a box scaled by
|
||||
// NaN/0 would be worse than storing an unscaled one.
|
||||
const { photoId } = await seedPhoto(null, null);
|
||||
await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
const face = await db('photo_faces').where({ photo_id: photoId }).first();
|
||||
expect(Number.isFinite(face.bbox_x)).toBe(true);
|
||||
expect(face.bbox_x).toBeCloseTo(mockSidecarBox[0], 1);
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* A deferred photo must not stall the queue.
|
||||
*
|
||||
* claimNextPhoto orders by id ascending, and the queue defaults to a single
|
||||
* worker. So returning an unreachable photo to 'pending' — the obvious way to
|
||||
* say "try again later" — makes that same row the oldest pending one forever:
|
||||
* the worker reclaims it after every backoff and never reaches a higher id.
|
||||
* One dead mount would stall face scanning for the entire install, including
|
||||
* unrelated events and fresh uploads.
|
||||
*
|
||||
* The row is instead left parked in 'processing' with its face_started_at
|
||||
* intact. It is not claimable, so the worker advances; the existing janitor
|
||||
* returns it to 'pending' after STUCK_TIMEOUT_MS, which is the retry.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-defer-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'defer-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let faceProcessor;
|
||||
|
||||
describe('deferred photos do not block the queue', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('exports TransientSourceError for the queue to branch on', () => {
|
||||
// The queue imports this from faceProcessor; if the export is dropped the
|
||||
// instanceof check silently becomes false and every deferral turns back
|
||||
// into a permanent failure.
|
||||
expect(typeof faceProcessor.TransientSourceError).toBe('function');
|
||||
expect(new faceProcessor.TransientSourceError(1, 'x'))
|
||||
.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('does NOT return a deferred row to pending', () => {
|
||||
// Source inspection, deliberately. workerLoop is an unexported infinite
|
||||
// loop, so the branch cannot be driven directly, and asserting on database
|
||||
// state alone does not distinguish the fix from the bug — a version that
|
||||
// re-queues the row passes every state assertion in this file. What
|
||||
// actually matters is that this one branch does not call releaseToPending,
|
||||
// so that is what is pinned. Same approach as the contract tests added for
|
||||
// #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'services', 'faceQueue.js'), 'utf8'
|
||||
);
|
||||
|
||||
const marker = 'if (err instanceof TransientSourceError) {';
|
||||
const start = src.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
|
||||
// The branch body, up to its closing brace.
|
||||
const body = src.slice(start, src.indexOf('\n }', start));
|
||||
expect(body).not.toMatch(/releaseToPending/);
|
||||
expect(body).toMatch(/continue/);
|
||||
|
||||
// And the sidecar branch, which SHOULD still release, so this test fails
|
||||
// if the two branches are ever collapsed back together.
|
||||
const sideStart = src.indexOf('if (err instanceof SidecarUnavailableError) {');
|
||||
expect(sideStart).toBeGreaterThan(-1);
|
||||
const sideBody = src.slice(sideStart, src.indexOf('\n }', sideStart));
|
||||
expect(sideBody).toMatch(/releaseToPending/);
|
||||
});
|
||||
|
||||
it('leaves a deferred row claimable-later, not claimable-now', async () => {
|
||||
// A row parked in 'processing' is invisible to claimNextPhoto, which only
|
||||
// ever selects face_status='pending' — that is what lets the worker move
|
||||
// past it instead of spinning on it.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `defer-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'defer',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `defer-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [stuck] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'stuck.jpg',
|
||||
path: 'd/stuck.jpg',
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
face_started_at: new Date().toISOString(),
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
const stuckId = typeof stuck === 'object' ? stuck.id : stuck;
|
||||
|
||||
const parked = await db('photos')
|
||||
.where({ id: stuckId, face_status: 'pending' })
|
||||
.first();
|
||||
expect(parked).toBeUndefined(); // not claimable while parked
|
||||
|
||||
// The janitor's contract is what turns the park into a retry: it resets
|
||||
// 'processing' rows whose face_started_at is older than the stuck timeout.
|
||||
// Backdate past it and the row becomes claimable again.
|
||||
const longAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
await db('photos').where({ id: stuckId }).update({ face_started_at: longAgo });
|
||||
|
||||
const cutoff = new Date(Date.now() - 600000).toISOString();
|
||||
const reset = await db('photos')
|
||||
.where('face_status', 'processing')
|
||||
.where('face_started_at', '<', cutoff)
|
||||
.update({ face_status: 'pending', face_started_at: null });
|
||||
|
||||
expect(reset).toBeGreaterThan(0);
|
||||
const after = await db('photos').where({ id: stuckId }).first();
|
||||
expect(after.face_status).toBe('pending');
|
||||
});
|
||||
|
||||
it('claimNextPhoto skips events inside their backoff window', async () => {
|
||||
// The per-event cooldown is what stops the janitor handing a whole dead
|
||||
// gallery back every sweep. Without the exclusion the worker walks all of
|
||||
// it again — one slow stat per photo against a possibly hard-mounted
|
||||
// share — before reaching any healthy event.
|
||||
const mk = async (name) => {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `cd-${name}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: name,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `cd-${name}-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${name}.jpg`,
|
||||
path: `cd/${name}.jpg`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: 'external',
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p2 === 'object' ? p2.id : p2 };
|
||||
};
|
||||
|
||||
await db('photos').del();
|
||||
const dead = await mk('dead'); // lower id -> would win the FIFO
|
||||
const healthy = await mk('healthy');
|
||||
|
||||
// Without exclusion the dead event's row is claimed first...
|
||||
const first = await faceQueue.claimNextPhoto([]);
|
||||
expect(first.id).toBe(dead.photoId);
|
||||
await db('photos').where({ id: dead.photoId }).update({ face_status: 'pending' });
|
||||
|
||||
// ...and with it, the worker reaches the healthy event instead.
|
||||
const second = await faceQueue.claimNextPhoto([dead.eventId]);
|
||||
expect(second.id).toBe(healthy.photoId);
|
||||
});
|
||||
|
||||
it('backoff spares managed rows in a mixed-source event', async () => {
|
||||
// A reference event can hold managed uploads alongside imported external
|
||||
// ones. Excluding the whole event id would leave those unscanned for as
|
||||
// long as external rows keep renewing the cooldown — indefinitely, during
|
||||
// a real outage — even though their local source is fine.
|
||||
await db('photos').del();
|
||||
const [e] = await db('events').insert({
|
||||
slug: `mix-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'mix',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `mix-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const add = async (origin, name) => {
|
||||
const [p2] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `mix/${name}`,
|
||||
type: 'individual',
|
||||
processing_status: 'complete',
|
||||
face_status: 'pending',
|
||||
source_origin: origin,
|
||||
}).returning('id');
|
||||
return typeof p2 === 'object' ? p2.id : p2;
|
||||
};
|
||||
await add('external', 'ext.jpg'); // lower id, would win the FIFO
|
||||
const managedId = await add('managed', 'man.jpg');
|
||||
|
||||
// Event is in backoff: the external row is skipped, the managed one is not.
|
||||
const claimed = await faceQueue.claimNextPhoto([eventId]);
|
||||
expect(claimed).toBeTruthy();
|
||||
expect(claimed.id).toBe(managedId);
|
||||
});
|
||||
|
||||
it('startQueue is exported and does not throw on import', () => {
|
||||
// faceQueue requires faceProcessor for TransientSourceError while
|
||||
// faceProcessor is itself required by the routes — a circular require here
|
||||
// would surface as an undefined export rather than a crash, so assert the
|
||||
// module actually loaded something usable.
|
||||
expect(faceQueue).toBeTruthy();
|
||||
expect(Object.keys(faceQueue).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* "The scan finished" is not a thing this queue is told (#1107).
|
||||
*
|
||||
* It claims photos one at a time, so a backfill is just a lot of independent
|
||||
* claims and the only available signal is a worker finding nothing left. That
|
||||
* signal is NOT sufficient on its own — with concurrency above one the other
|
||||
* workers may still be busy, and a photo released back to `pending` by a down
|
||||
* sidecar is still owed — so the drain is tested against the queue directly.
|
||||
*
|
||||
* These are the cases that decide whether consolidation runs too early (a
|
||||
* wasted pass over half-formed clusters) or never (the feature silently does
|
||||
* nothing, which is the state #1107 was filed about).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facedrain-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facedrain-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceQueue; let clustering;
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug,
|
||||
event_type: 'wedding',
|
||||
event_name: slug,
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `${slug}-share`,
|
||||
expires_at: new Date().toISOString(),
|
||||
// The drain rechecks this before consolidating, so the fixture has to be
|
||||
// a gallery that actually has detection on.
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** Both halves of the "two deliberate actions" rule have to be on. */
|
||||
async function enableFacesGlobally() {
|
||||
const existing = await db('feature_flags').where({ key: 'faces' }).first();
|
||||
if (existing) await db('feature_flags').where({ key: 'faces' }).update({ value: true });
|
||||
else await db('feature_flags').insert({ key: 'faces', value: true });
|
||||
}
|
||||
|
||||
async function insertPhoto(eventId, faceStatus) {
|
||||
const [row] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: `${Math.random()}.jpg`,
|
||||
path: '/tmp/x.jpg',
|
||||
type: 'individual',
|
||||
face_status: faceStatus,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
describe('faceQueue drain consolidation (#1107)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
faceQueue = require('../../src/services/faceQueue');
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
await enableFacesGlobally();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(() => {
|
||||
faceQueue.touchedEvents.clear();
|
||||
faceQueue.consolidationRetryAt.clear();
|
||||
faceQueue.inFlightByEvent.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does nothing at all when no photo has been scanned', async () => {
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits while the event still has photos queued', async () => {
|
||||
const eventId = await seedEvent('drain-pending');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'pending');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Still owed, so it must keep its place for the next idle tick — dropping
|
||||
// it here would mean the gallery never consolidates at all.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('waits while a photo is still being processed by another worker', async () => {
|
||||
const eventId = await seedEvent('drain-processing');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'processing');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate');
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it('consolidates once the queue is empty, and does not repeat itself', async () => {
|
||||
const eventId = await seedEvent('drain-empty');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await insertPhoto(eventId, 'failed');
|
||||
await insertPhoto(eventId, 'skipped');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(eventId);
|
||||
// Drained and handled, so a second idle tick must not pay for it again.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a failing consolidation never propagates into the worker loop, and is retried', async () => {
|
||||
const eventId = await seedEvent('drain-throws');
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(faceQueue.drainConsolidation()).resolves.toBeUndefined();
|
||||
|
||||
// A transient database error must not cost the gallery its consolidation
|
||||
// outright — the event keeps its place so a later tick retries.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
// ...but not on the very next tick. The worker idles every couple of
|
||||
// seconds, so an immediate retry would hot-loop a permanently broken event
|
||||
// and warn every time.
|
||||
expect(faceQueue.consolidationRetryAt.get(eventId)).toBeGreaterThan(Date.now());
|
||||
const callsBefore = spy.mock.calls.length;
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(callsBefore);
|
||||
|
||||
// Once the backoff elapses it really does try again, and succeeds.
|
||||
faceQueue.consolidationRetryAt.set(eventId, Date.now() - 1);
|
||||
spy.mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
expect(faceQueue.consolidationRetryAt.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('waits while another worker is still inside processPhotoFaces', async () => {
|
||||
const eventId = await seedEvent('drain-inflight');
|
||||
// Every row already reads as drained: the last photo is committed 'done'
|
||||
// inside the transaction, and auto-categorisation runs afterwards. Only
|
||||
// the in-flight count knows a worker is still there.
|
||||
await insertPhoto(eventId, 'done');
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
faceQueue.inFlightByEvent.set(eventId, 1);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// Consolidating here would record its count, and the busy worker would
|
||||
// then re-mark the event — the next pass merges nothing and overwrites the
|
||||
// real number with zero.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(true);
|
||||
|
||||
faceQueue.inFlightByEvent.delete(eventId);
|
||||
await faceQueue.drainConsolidation();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not consolidate an event whose detection was switched off mid-drain', async () => {
|
||||
const eventId = await seedEvent('drain-disabled');
|
||||
await insertPhoto(eventId, 'done');
|
||||
await db('events').where({ id: eventId }).update({ face_recognition_enabled: false });
|
||||
faceQueue.touchedEvents.add(eventId);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
// An earlier photo legitimately marked the event before the toggle went
|
||||
// off. Merging someone's clusters just after they disabled the feature is
|
||||
// not a thing to do quietly.
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// Dropped rather than retried — it is not coming back on its own.
|
||||
expect(faceQueue.touchedEvents.has(eventId)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats events independently — a busy gallery does not hold up a finished one', async () => {
|
||||
const busy = await seedEvent('drain-busy');
|
||||
const done = await seedEvent('drain-done');
|
||||
await insertPhoto(busy, 'pending');
|
||||
await insertPhoto(done, 'done');
|
||||
faceQueue.touchedEvents.add(busy);
|
||||
faceQueue.touchedEvents.add(done);
|
||||
|
||||
const spy = jest.spyOn(clustering, 'consolidate').mockResolvedValue([]);
|
||||
await faceQueue.drainConsolidation();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(done);
|
||||
expect(faceQueue.touchedEvents.has(busy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
/**
|
||||
* "Not the same person" has to outlive re-derivation (#1132).
|
||||
*
|
||||
* The decision used to be stored as a pair of event_people.id, and neither
|
||||
* person ids nor face ids survive:
|
||||
*
|
||||
* - recluster() deletes every person and re-assigns, so person ids die but
|
||||
* photo_faces.id survives
|
||||
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
|
||||
*
|
||||
* The embedding is the only stable handle, so that is what the separation is
|
||||
* keyed on. These tests simulate both kinds of re-derivation by destroying the
|
||||
* ids and rebuilding from the same vectors — which is exactly what the real
|
||||
* paths do — and assert the constraint still binds.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sep-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let clustering;
|
||||
|
||||
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
|
||||
const DIM = 64;
|
||||
|
||||
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
||||
function pairAtSimilarity(target, basis) {
|
||||
const a = new Float32Array(DIM);
|
||||
const b = new Float32Array(DIM);
|
||||
a[basis] = 1;
|
||||
b[basis] = target;
|
||||
b[basis + 1] = Math.sqrt(1 - target * target);
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
async function seedEvent(slug) {
|
||||
const [row] = await db('events').insert({
|
||||
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
|
||||
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
||||
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
async function insertPerson(eventId, centroid, overrides = {}) {
|
||||
const [row] = await db('event_people').insert({
|
||||
event_id: eventId,
|
||||
centroid: clustering.packEmbedding(centroid),
|
||||
face_count_total: 1,
|
||||
model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
}).returning('id');
|
||||
return typeof row === 'object' ? row.id : row;
|
||||
}
|
||||
|
||||
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
|
||||
function mirrorAtSimilarity(target, basis) {
|
||||
const b = new Float32Array(DIM);
|
||||
b[basis] = target;
|
||||
b[basis + 1] = -Math.sqrt(1 - target * target);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function insertFaceWithPhoto(eventId, personId, centroid) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId, person_id: personId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(centroid),
|
||||
model_version: 'test-v1', created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return { faceId: typeof f === 'object' ? f.id : f, photoId };
|
||||
}
|
||||
|
||||
async function insertFace(eventId, personId, centroid) {
|
||||
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
|
||||
return faceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a re-scan does to identity: the people are gone and the faces come back
|
||||
* with brand-new ids. Same vectors, nothing else preserved.
|
||||
*/
|
||||
async function simulateRescan(eventId, vectors) {
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const ids = [];
|
||||
for (const vec of vectors) {
|
||||
const personId = await insertPerson(eventId, vec);
|
||||
await insertFace(eventId, personId, vec);
|
||||
ids.push(personId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('separations survive re-derivation (#1132)', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
clustering = require('../../src/services/faceClustering');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
describe('the matcher', () => {
|
||||
it('binds a pair that still looks like the one that was separated', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('binds regardless of which way round the candidates arrive', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Neither the stored pair nor the candidate pair has a meaningful order.
|
||||
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('lapses once a side has drifted past recognition', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// A cluster reshaped far enough is no longer the cluster the
|
||||
// photographer pointed at, so the constraint should stop applying rather
|
||||
// than bind something they never saw.
|
||||
const drifted = new Float32Array(DIM);
|
||||
drifted[10] = 1;
|
||||
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not bind two clusters that are both the SAME side', () => {
|
||||
// A split leaves two halves of one cluster, so the pair it records is
|
||||
// often similar to itself — here 0.95. Two candidates that are plainly
|
||||
// both side A (0.97 to each other) each clear the bar against BOTH
|
||||
// stored sides, so a test that only asks "does each side match
|
||||
// something" says yes and refuses to let that person cluster with
|
||||
// itself. It fragments into singletons — the person the split was not
|
||||
// even about.
|
||||
const [a, b] = pairAtSimilarity(0.95, 0);
|
||||
const x = new Float32Array(DIM); x[0] = 1;
|
||||
const y = mirrorAtSimilarity(0.97, 0);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
// The pair it was actually about still binds.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a separation recorded under a different embedding model', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
// Vectors from another model are meaningless here, not merely stale —
|
||||
// the same rule assignment and consolidation apply to person centroids.
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(false);
|
||||
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
|
||||
{ modelVersion: 'test-v1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an unrelated pair entirely', () => {
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const [x, y] = pairAtSimilarity(0.64, 20);
|
||||
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('across a re-scan', () => {
|
||||
it('still refuses to merge the pair after every id has changed', async () => {
|
||||
const eventId = await seedEvent('sep-rescan');
|
||||
// Well above the auto-merge threshold: only the separation keeps them apart.
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
const newIds = await simulateRescan(eventId, [a, b]);
|
||||
// The premise: nothing the old row named still exists.
|
||||
expect(newIds).not.toContain(idA);
|
||||
expect(newIds).not.toContain(idB);
|
||||
|
||||
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
||||
|
||||
expect(merged).toEqual([]);
|
||||
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the pair out of the suggestion list too', async () => {
|
||||
const eventId = await seedEvent('sep-rescan-suggest');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
|
||||
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
|
||||
it('a split still binds after the ids it recorded are gone', async () => {
|
||||
const eventId = await seedEvent('sep-split-rescan');
|
||||
// Two faces that look alike enough to have been clustered together, but
|
||||
// are not the same vector — which is what a split is FOR, and the only
|
||||
// case it can survive re-derivation in. Two byte-identical embeddings
|
||||
// carry no information about which side is which, so a separation
|
||||
// between them has nothing to key on once the ids are gone.
|
||||
const [base, other] = pairAtSimilarity(0.96, 0);
|
||||
const personId = await insertPerson(eventId, base);
|
||||
await insertFace(eventId, personId, base);
|
||||
const extra = await insertFace(eventId, personId, other);
|
||||
|
||||
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
|
||||
expect(newPersonId).toBeTruthy();
|
||||
|
||||
// The snapshot must have been taken AFTER recomputeCentroid — before it,
|
||||
// the new person has no centroid at all.
|
||||
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(row.centroid_a).toBeTruthy();
|
||||
expect(row.centroid_b).toBeTruthy();
|
||||
|
||||
await simulateRescan(eventId, [base, other]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a photo is hard-deleted', () => {
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
|
||||
it('drops the separation when one side has no photos left', async () => {
|
||||
const eventId = await seedEvent('sep-purge-gone');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
// Person B is gone with its only photo. The row held a COPY of its
|
||||
// centroid, so leaving it standing would keep a vector derived from a
|
||||
// deleted photo alive in a table nothing else touches.
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the constraint when a side still has another cluster on it', async () => {
|
||||
const eventId = await seedEvent('sep-purge-descendant');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Re-derivation can leave one stored side represented by more than one
|
||||
// current person. Deleting the photo behind ONE of them must not throw
|
||||
// the whole decision away — the other still stands for that side, and the
|
||||
// pair would be free to merge again.
|
||||
const twin = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
|
||||
twin[6] = Math.sqrt(1 - 0.98 ** 2);
|
||||
const survivor = await insertPerson(eventId, twin);
|
||||
await insertFace(eventId, survivor, twin);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
||||
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
||||
expect(rows).toHaveLength(1);
|
||||
// Re-anchored onto the survivor, so it still binds.
|
||||
expect(clustering.separationForbids(a, twin, [{
|
||||
a: clustering.unpackEmbedding(rows[0].centroid_a),
|
||||
b: clustering.unpackEmbedding(rows[0].centroid_b),
|
||||
}])).toBe(true);
|
||||
});
|
||||
|
||||
it('re-takes the snapshot from what is left when the person survives', async () => {
|
||||
const eventId = await seedEvent('sep-purge-survives');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
// A second face on B, close enough that B stays recognisably B — so
|
||||
// purging it moves B's centroid rather than deleting the person, and the
|
||||
// side still resolves to B afterwards.
|
||||
const other = new Float32Array(DIM);
|
||||
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
|
||||
other[5] = Math.sqrt(1 - 0.95 ** 2);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
|
||||
await clustering.recomputeCentroid(idB);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
||||
expect(after).toBeTruthy();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
|
||||
// It now equals the recomputed centroid — nothing of the deleted face left.
|
||||
const person = await db('event_people').where({ id: idB }).first();
|
||||
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the photographer changes their mind', () => {
|
||||
it('a manual merge clears the separation between the merged people', async () => {
|
||||
const eventId = await seedEvent('sep-merge-overrules');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await insertFace(eventId, idA, a);
|
||||
await insertFace(eventId, idB, b);
|
||||
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
// ...and then decides they ARE the same person after all.
|
||||
await clustering.mergePeople(eventId, [idB], idA);
|
||||
|
||||
// The row is keyed on the centroids as well as the ids, so leaving it
|
||||
// would survive the ids it names: the next recluster would recognise
|
||||
// those two sides and pull the merge apart again.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup after the ids have already died', () => {
|
||||
// The rows these paths must find are exactly the ones whose person ids no
|
||||
// longer resolve — that is the state this whole feature creates. Matching
|
||||
// on ids alone walks past them, which is worse than not cleaning up at
|
||||
// all: the surviving row still enforces its vectors.
|
||||
|
||||
it('a merge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-merge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A recluster: same vectors, brand-new people. The row now names nobody.
|
||||
const [newA, newB] = await simulateRescan(eventId, [a, b]);
|
||||
expect([newA, newB]).not.toContain(idA);
|
||||
|
||||
await clustering.mergePeople(eventId, [newB], newA);
|
||||
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
// And it stays merged through the next re-derivation.
|
||||
await simulateRescan(eventId, [a, b]);
|
||||
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a purge clears a separation that had already outlived its ids', async () => {
|
||||
const eventId = await seedEvent('sep-purge-stale');
|
||||
const [a, b] = pairAtSimilarity(0.64, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// Same recluster, then hard-delete the photo behind the B side.
|
||||
await db('photo_faces').where({ event_id: eventId }).del();
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
const newA = await insertPerson(eventId, a);
|
||||
await insertFace(eventId, newA, a);
|
||||
const newB = await insertPerson(eventId, b);
|
||||
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
|
||||
|
||||
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
||||
await purgePhotoFaces(photoId);
|
||||
|
||||
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
|
||||
// The row named idA/idB, neither of which exists — but its centroid_b is
|
||||
// a copy of a vector derived from the photo that was just destroyed.
|
||||
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the whole gallery is deleted', () => {
|
||||
it('deleteEventCascade clears the separations too', () => {
|
||||
// Source inspection, deliberately. deleteEventCascade takes an admin
|
||||
// context and does filesystem cleanup, so driving it here would test the
|
||||
// scaffolding rather than the contract. The contract is narrow and
|
||||
// absolute: this table now holds centroid BLOBs, it has no event FK by
|
||||
// design, and nothing else in the codebase would ever reach it — so the
|
||||
// one delete has to be in the cascade or the embeddings outlive the
|
||||
// gallery. Same approach as the contract tests added for #596.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
|
||||
);
|
||||
const body = src.slice(src.indexOf('async function deleteEventCascade'));
|
||||
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
|
||||
// Guarded, not caught: a failed statement aborts the transaction on PG.
|
||||
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
|
||||
});
|
||||
|
||||
it('permanent archive deletion clears the face data too', () => {
|
||||
// Same contract, second door. This route deletes the event row directly
|
||||
// and leans on the FK cascade, which is inert on SQLite — and no FK
|
||||
// reaches the dismissals table on either engine. archiveEvent's purge is
|
||||
// nonfatal, so an event really can arrive here still holding embeddings.
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
|
||||
);
|
||||
expect(src).toContain('event_people_merge_dismissals');
|
||||
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
|
||||
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('during assignment', () => {
|
||||
it('will not put a new face into a cluster it was separated from', async () => {
|
||||
const eventId = await seedEvent('sep-assign');
|
||||
const [a, b] = pairAtSimilarity(0.97, 0);
|
||||
const idA = await insertPerson(eventId, a);
|
||||
const idB = await insertPerson(eventId, b);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
|
||||
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
|
||||
// far above the 0.6 match threshold), and before #1132 it would simply
|
||||
// have joined — reforming the pair the photographer pulled apart, because
|
||||
// assignment consulted no separations at all.
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const faceId = typeof f === 'object' ? f.id : f;
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
||||
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
// It opened its own person rather than being forced into the wrong one.
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('holds back a face that is only loosely like the side it belongs to', async () => {
|
||||
const eventId = await seedEvent('sep-assign-loose');
|
||||
// The separated sides are CENTROIDS; an individual face sits well below
|
||||
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
|
||||
// face 0.85-like its own side would clear no strict bar against it, and
|
||||
// before this it walked straight into the other person during a
|
||||
// recluster, which is the exact merge the photographer undid.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ id: idB }).del();
|
||||
|
||||
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
|
||||
// and 0.85 to side B, which is where it actually belongs.
|
||||
const face = new Float32Array(DIM);
|
||||
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
expect(assignments[0].personId).not.toBe(idA);
|
||||
expect(assignments[0].personId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('binds while the clusters are still being rebuilt one face at a time', async () => {
|
||||
const eventId = await seedEvent('sep-assign-rebuild');
|
||||
// recluster() empties event_people and re-assigns from scratch, so for
|
||||
// the first faces of a batch the "person" on the other side of the
|
||||
// comparison is a cluster of ONE. A settled centroid it is not, and
|
||||
// holding it to the strict threshold meant the pair was already merged
|
||||
// by the time the constraint could bind — with nothing left to split it.
|
||||
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
||||
const idA = await insertPerson(eventId, sideA);
|
||||
const idB = await insertPerson(eventId, sideB);
|
||||
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
||||
await db('event_people').where({ event_id: eventId }).del();
|
||||
|
||||
// Two faces, one per side, each a little off its own side's centroid —
|
||||
// 0.91, just under the strict bar — and 0.66 to each other, over the
|
||||
// match threshold. Exactly the pair that must not re-form.
|
||||
const off = Math.sqrt(1 - 0.91 ** 2);
|
||||
const faceA = new Float32Array(DIM);
|
||||
faceA[0] = 0.91; faceA[3] = off;
|
||||
const faceB = new Float32Array(DIM);
|
||||
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
|
||||
|
||||
const rows = [];
|
||||
for (const vec of [faceA, faceB]) {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
|
||||
}
|
||||
|
||||
// The premise: they are close enough to each other to cluster together.
|
||||
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
|
||||
|
||||
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
|
||||
expect(assignments[0].personId).not.toBe(assignments[1].personId);
|
||||
});
|
||||
|
||||
it('leaves ordinary assignment alone when no separation applies', async () => {
|
||||
const eventId = await seedEvent('sep-assign-clean');
|
||||
const base = new Float32Array(DIM); base[0] = 1;
|
||||
const personId = await insertPerson(eventId, base);
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
|
||||
}).returning('id');
|
||||
const photoId = typeof p === 'object' ? p.id : p;
|
||||
const [f] = await db('photo_faces').insert({
|
||||
photo_id: photoId, event_id: eventId,
|
||||
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
||||
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
|
||||
const assignments = await clustering.assignFaces(
|
||||
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
|
||||
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
||||
{ thresholds: THRESHOLDS },
|
||||
);
|
||||
|
||||
// The whole point of the strict threshold: a constraint that fires when
|
||||
// it should not would quietly wreck ordinary clustering.
|
||||
expect(assignments[0].personId).toBe(personId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* A dropped mount defers a scan; a dead photo fails it.
|
||||
*
|
||||
* ensurePreviewImage returns null for both "this JPEG is corrupt" and "the
|
||||
* NFS share is gone", and #1090 made that distinction matter: external
|
||||
* libraries now reach this path, and network mounts drop far more often than
|
||||
* local disks. Failing on an outage strands the photo — faceQueue only ever
|
||||
* claims 'pending', and nothing re-queues a failure automatically, so a mount
|
||||
* that blinked mid-scan would cost an entire gallery a manual Re-scan.
|
||||
*
|
||||
* The probe checks the containing DIRECTORY rather than the file, because that
|
||||
* is what separates the two cases: a missing file inside a healthy directory
|
||||
* is a broken photo, an unreachable directory is broken storage.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-transient-'));
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'transient-test-secret';
|
||||
// Created BEFORE anything requires externalMediaService: getExternalMediaRoot
|
||||
// only honours the env var if the directory already exists, and caches the
|
||||
// result on first call — set it later and every path silently resolves
|
||||
// against a fallback root instead.
|
||||
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpRoot, 'media');
|
||||
fs.mkdirSync(process.env.EXTERNAL_MEDIA_ROOT, { recursive: true });
|
||||
|
||||
let previewKeyResult = null;
|
||||
const mockEnsurePreviewImage = jest.fn(async () => previewKeyResult);
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => ({
|
||||
...jest.requireActual('../../src/services/imageProcessor'),
|
||||
ensurePreviewImage: (...args) => mockEnsurePreviewImage(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/faceClient', () => ({
|
||||
detectFaces: jest.fn(async () => ({ model_version: 'test-v1', faces: [] })),
|
||||
SidecarUnavailableError: class extends Error {},
|
||||
}));
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db; let cleanup; let faceProcessor;
|
||||
|
||||
async function seedExternalPhoto({ externalPath, relpath = 'individual/a.jpg' }) {
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'tr',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
source_mode: 'reference',
|
||||
external_path: externalPath,
|
||||
}).returning('id');
|
||||
const eventId = typeof e === 'object' ? e.id : e;
|
||||
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: 'a.jpg',
|
||||
path: 'tr/a.jpg',
|
||||
type: 'individual',
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'external',
|
||||
// Stored relative to EXTERNAL_MEDIA_ROOT (#1163), which is what the import
|
||||
// route writes — `relpath` above is expressed relative to the event's
|
||||
// folder only because that reads better at the call sites.
|
||||
external_relpath: path.join(externalPath, relpath),
|
||||
}).returning('id');
|
||||
return { eventId, photoId: typeof p === 'object' ? p.id : p };
|
||||
}
|
||||
|
||||
describe('transient source vs dead photo', () => {
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await db('feature_flags').insert({ key: 'faces', value: true })
|
||||
.onConflict('key').merge()
|
||||
.catch(async () => { await db('feature_flags').where({ key: 'faces' }).update({ value: true }); });
|
||||
faceProcessor = require('../../src/services/faceProcessor');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previewKeyResult = null; // i.e. ensurePreviewImage could not build one
|
||||
mockEnsurePreviewImage.mockClear();
|
||||
});
|
||||
|
||||
it('defers, not fails, when the source directory is unreachable', async () => {
|
||||
// Nothing was ever created under EXTERNAL_MEDIA_ROOT for this path, so the
|
||||
// directory does not resolve — the shape a dropped mount presents.
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'vanished-share' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
// Critically: still claimable. A 'failed' here is what stranded the photo.
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('defers when the event root survives an unmount but is empty', async () => {
|
||||
// The common NFS/SMB shape: unmounting leaves the mountpoint behind as an
|
||||
// ordinary empty directory, so fs.access succeeds on storage that is
|
||||
// entirely gone. The EVENT ROOT is the thing that goes empty — the photo's
|
||||
// own subdirectory vanishes with it.
|
||||
const emptyRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'unmounted');
|
||||
await fs.promises.mkdir(emptyRoot, { recursive: true });
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'unmounted' });
|
||||
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).not.toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the directory is healthy but the file is gone', async () => {
|
||||
// Directory exists, file does not — a genuinely broken photo, which should
|
||||
// surface as a failure the admin can see rather than retry forever.
|
||||
const live = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'live-share', 'individual');
|
||||
await fs.promises.mkdir(live, { recursive: true });
|
||||
// Non-empty: an empty directory is now read as an unmounted share, so the
|
||||
// "healthy storage, dead photo" case needs a sibling file present.
|
||||
await fs.promises.writeFile(path.join(live, 'sibling.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'live-share' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
expect(photo.face_status).toBe('failed');
|
||||
expect(photo.face_error).toMatch(/preview/i);
|
||||
});
|
||||
|
||||
it('fails a missing subdirectory rather than deferring the whole event', async () => {
|
||||
// individual/ deleted while collages/ is fine. Probing only the photo's own
|
||||
// directory reports ENOENT and would read as a mount-wide outage, deferring
|
||||
// the event and starving every healthy sibling folder. The root is
|
||||
// populated, so the mount is up and this is a broken path.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'partial');
|
||||
await fs.promises.mkdir(path.join(root, 'collages'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(root, 'collages', 'kept.jpg'), 'x');
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'partial' });
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(photoId);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('defers a file that exists but cannot be read', async () => {
|
||||
// EACCES / EIO / ESTALE on the file itself, with the mount up: a transient
|
||||
// condition wearing a per-file disguise. Only ENOENT means genuinely gone.
|
||||
const root = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'locked');
|
||||
const dir = path.join(root, 'individual');
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, 'a.jpg');
|
||||
await fs.promises.writeFile(file, 'x');
|
||||
await fs.promises.chmod(file, 0o000);
|
||||
|
||||
const { photoId } = await seedExternalPhoto({ externalPath: 'locked' });
|
||||
try {
|
||||
await expect(faceProcessor.processPhotoFaces(photoId))
|
||||
.rejects.toBeInstanceOf(faceProcessor.TransientSourceError);
|
||||
} finally {
|
||||
await fs.promises.chmod(file, 0o644).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('still fails managed photos without probing the mount', async () => {
|
||||
// The probe is scoped to external/reference rows: a managed photo with no
|
||||
// preview is broken, and there is no mount to blame.
|
||||
const [e] = await db('events').insert({
|
||||
slug: `tr-m-${Math.random().toString(36).slice(2, 8)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'trm',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'h@example.com',
|
||||
admin_email: 'a@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `tr-m-${Math.random()}`,
|
||||
expires_at: new Date().toISOString(),
|
||||
face_recognition_enabled: true,
|
||||
}).returning('id');
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: typeof e === 'object' ? e.id : e,
|
||||
filename: 'm.jpg',
|
||||
path: 'trm/m.jpg',
|
||||
type: 'individual',
|
||||
width: 100,
|
||||
height: 100,
|
||||
processing_status: 'complete',
|
||||
face_status: 'processing',
|
||||
source_origin: 'managed',
|
||||
}).returning('id');
|
||||
|
||||
const result = await faceProcessor.processPhotoFaces(typeof p === 'object' ? p.id : p);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
|
||||
*
|
||||
* Every filter token on /photos is an OR of two halves: what THIS viewer
|
||||
* marked, and what ANYONE marked. The response fields built from the second
|
||||
* half — like_count, comment_count, color_label_count — are all gated on
|
||||
* show_feedback_to_guests. The FILTER was not.
|
||||
*
|
||||
* So with the setting off, the numbers were hidden but `?filter=liked` still
|
||||
* returned exactly the photos other people had liked: the same information as
|
||||
* a set instead of a count, one token at a time. These tests pin the gate on
|
||||
* every token, and pin that the viewer's own half is never gated — filtering
|
||||
* by what you yourself marked is yours to do regardless.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
|
||||
|
||||
const SLUG = 'filter-visibility-event';
|
||||
const ME = 'guest-me-identifier';
|
||||
const SOMEONE_ELSE = 'guest-other-identifier';
|
||||
|
||||
describe('guest filters and show_feedback_to_guests (#1044)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
let eventId;
|
||||
let mine;
|
||||
let theirs;
|
||||
let myGuestRowId;
|
||||
|
||||
const galleryToken = () => jwt.sign(
|
||||
{ eventId, eventSlug: SLUG, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
const setVisibility = (visible) => db('event_feedback_settings')
|
||||
.where({ event_id: eventId })
|
||||
.update({ show_feedback_to_guests: visible });
|
||||
|
||||
// A real verified guest, which is how the viewer's own feedback is actually
|
||||
// identified — NOT the `guest_id` query parameter the frontend invents.
|
||||
const guestToken = () => jwt.sign(
|
||||
{ type: 'guest', guestId: myGuestRowId, eventId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
|
||||
// The photo payload itself, not just the filtered id list — `is_liked` and
|
||||
// the aggregate counts live here (#1286).
|
||||
const payload = async ({ as = 'me' } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return Object.fromEntries((photos || []).map((p) => [p.id, p]));
|
||||
};
|
||||
|
||||
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
|
||||
const req = request(app)
|
||||
.get(`/api/gallery/${SLUG}/photos`)
|
||||
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
|
||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||
if (as === 'me') req.set('x-guest-token', guestToken());
|
||||
const res = await req;
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const inserted = await db('events').insert({
|
||||
slug: SLUG,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Filter Visibility',
|
||||
event_date: '2026-08-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/${SLUG}/share`,
|
||||
share_token: 'filter-visibility-share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1,
|
||||
is_archived: 0,
|
||||
is_draft: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = inserted[0]?.id ?? inserted[0];
|
||||
|
||||
const addPhoto = async (name) => {
|
||||
const p = await db('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: name,
|
||||
path: `events/filter/${name}`,
|
||||
type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return p[0]?.id ?? p[0];
|
||||
};
|
||||
mine = await addPhoto('mine.jpg');
|
||||
theirs = await addPhoto('theirs.jpg');
|
||||
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: eventId,
|
||||
feedback_enabled: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_ratings: true,
|
||||
allow_favorites: true,
|
||||
allow_color_labels: true,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
});
|
||||
|
||||
const guestRow = await db('gallery_guests').insert({
|
||||
event_id: eventId,
|
||||
name: 'Me',
|
||||
identifier: ME,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: new Date().toISOString(),
|
||||
is_deleted: false,
|
||||
}).returning('id');
|
||||
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
|
||||
|
||||
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
guest_identifier: who,
|
||||
// Submission links to the per-person guest row when one is present, and
|
||||
// that is the column the viewer's own half resolves through.
|
||||
guest_id: who === ME ? myGuestRowId : null,
|
||||
feedback_type: type,
|
||||
is_approved: true,
|
||||
is_hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
|
||||
await feedback(mine, ME, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'like');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'favorite');
|
||||
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
|
||||
await feedback(theirs, SOMEONE_ELSE, 'color_label', { color_label: 'green' });
|
||||
|
||||
// The denormalized counters the aggregate half of the filter reads.
|
||||
await db('photos').where('id', theirs).update({
|
||||
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5, color_label_count: 1,
|
||||
});
|
||||
await db('photos').where('id', mine).update({ like_count: 1 });
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/gallery', require('../../src/routes/gallery'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('with feedback visible to guests', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('shows other people\'s marks through every token, as before', async () => {
|
||||
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
|
||||
expect(await filter('favorited')).toEqual([theirs]);
|
||||
expect(await filter('rated')).toEqual([theirs]);
|
||||
expect(await filter('commented')).toEqual([theirs]);
|
||||
expect(await filter('color:green')).toEqual([theirs]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback hidden from guests', () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('stops every token from selecting on other people\'s marks', async () => {
|
||||
// `theirs` is the photo only other guests marked. It must not come back
|
||||
// through any token — a filter that selects on hidden feedback reports
|
||||
// that feedback just as surely as a count would.
|
||||
expect(await filter('favorited')).toEqual([]);
|
||||
expect(await filter('rated')).toEqual([]);
|
||||
expect(await filter('commented')).toEqual([]);
|
||||
expect(await filter('color:green')).toEqual([]);
|
||||
});
|
||||
|
||||
it('still filters by what the viewer marked themselves', async () => {
|
||||
// The viewer's own half is never gated: this is their own action, and
|
||||
// hiding it would break "show me the ones I liked" for no privacy gain.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('drops the viewer\'s own feedback once an admin hides it', async () => {
|
||||
// Moderation has to reach the filter too. getPhotoFeedback excludes
|
||||
// hidden rows for the guest's OWN feedback, so a photo matching here
|
||||
// would come back with nothing visible on it to explain why.
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
expect(await filter('liked')).toEqual([]);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
});
|
||||
|
||||
it('ignores a guest_id supplied by the caller', async () => {
|
||||
// The own-half is resolved from the request identity. If it honoured the
|
||||
// query string instead, anyone holding another guest's identifier could
|
||||
// read that guest's hidden memberships one token at a time — straight
|
||||
// back through the gate this file exists to pin.
|
||||
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
expect(await filter('color:green', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
|
||||
// And an anonymous caller claiming to be me gets nothing of mine.
|
||||
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
|
||||
});
|
||||
});
|
||||
// #1286 — the viewer's OWN like is not other people's feedback.
|
||||
describe("a guest's own likes with feedback hidden (#1286)", () => {
|
||||
beforeAll(() => setVisibility(false));
|
||||
|
||||
it('still reports is_liked on the photo the viewer liked', async () => {
|
||||
// The regression: every heart came back empty on a gallery with
|
||||
// sharing off, so the grid looked like it had discarded the guest's
|
||||
// choices on every reload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report is_liked for someone else's like", async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the aggregate like_count hidden', async () => {
|
||||
// The count IS other people's feedback and must stay gated — the fix
|
||||
// must not leak it back through the same payload.
|
||||
const photos = await payload();
|
||||
expect(photos[mine].like_count).toBe(0);
|
||||
expect(photos[theirs].like_count).toBe(0);
|
||||
expect(photos[theirs].has_feedback).toBe(false);
|
||||
});
|
||||
|
||||
it('reports nothing as liked for a viewer who liked nothing', async () => {
|
||||
const photos = await payload({ as: 'anon' });
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
});
|
||||
|
||||
it("still respects an admin hiding the viewer's own like (#1150)", async () => {
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: true });
|
||||
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(false);
|
||||
|
||||
await db('photo_feedback')
|
||||
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
|
||||
.update({ is_hidden: false });
|
||||
});
|
||||
|
||||
it('matches what the liked filter already returned', async () => {
|
||||
// The filter half was never gated; the payload flag was. After the fix
|
||||
// the two agree, which is what makes the grid and the Likes chip show
|
||||
// the same set.
|
||||
expect(await filter('liked')).toEqual([mine]);
|
||||
const photos = await payload();
|
||||
const flagged = Object.values(photos).filter((p) => p.is_liked).map((p) => p.id);
|
||||
expect(flagged).toEqual([mine]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with feedback visible again (#1286 regression guard)', () => {
|
||||
beforeAll(() => setVisibility(true));
|
||||
|
||||
it('is_liked and the counts both come back', async () => {
|
||||
const photos = await payload();
|
||||
expect(photos[mine].is_liked).toBe(true);
|
||||
expect(photos[theirs].is_liked).toBe(false);
|
||||
expect(photos[theirs].like_count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user