Compare commits
232 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe772b52d6 | |||
| f29b77998b | |||
| f843e4c25c | |||
| ea20446a79 | |||
| 41f9b6d45d | |||
| 7b5916d3b9 | |||
| 657c205a4d | |||
| fe7d45dd12 | |||
| c05ae5b0b9 | |||
| dab012c3d1 | |||
| 32492c5a91 | |||
| 2add85eccf | |||
| 5edfb44776 | |||
| eedb0fe49c | |||
| 3c7dc2013f | |||
| 617e778a48 | |||
| e3c3c4c951 | |||
| 0e3b50d1b6 | |||
| bd8b885f7f | |||
| 3cdc0ea715 | |||
| a2ff9eae3f | |||
| 3f7631cd95 | |||
| 53b8764ed7 | |||
| 082d8ab205 | |||
| 749100c92a | |||
| 3798662722 | |||
| fa1397cb8c | |||
| cc1ddfd42c | |||
| 049837f9d6 | |||
| 29dc2a3cf1 | |||
| d2cba449b0 | |||
| e0bd19a74d | |||
| 0ab8cbde7f | |||
| 3a8d53f492 | |||
| 804a964ba0 | |||
| d2cd1aa933 | |||
| d7ecf83d32 | |||
| ebb2ce6065 | |||
| 1931d73b60 | |||
| 0d5ce48dcc | |||
| 4872ef71f8 | |||
| b83f4272b5 | |||
| 5df64992c4 | |||
| 7e5e004270 | |||
| 09ce2b80d0 | |||
| 476fcce13f | |||
| c030e87213 | |||
| e6dd89e969 | |||
| e85a68a386 | |||
| 0acce6ab08 | |||
| 92a1c7a2df | |||
| edc57bfbbe | |||
| 37d4e1cb61 | |||
| 0d36a273bb | |||
| a19e218e40 | |||
| 61c53fb24e | |||
| dc8cf9a9a2 | |||
| 16b3ab039a | |||
| 6a6c2cd34d | |||
| 856d53343c | |||
| d9da98c355 | |||
| 892e47d017 | |||
| 007e46edb9 | |||
| 542887c2e5 | |||
| 4651783d4d | |||
| b706eeb5d3 | |||
| 40ee67171d | |||
| 6033461be1 | |||
| f3c2cee362 | |||
| 0da45e699a | |||
| 97455ab047 | |||
| fbd7b67016 | |||
| 3424bd22ee | |||
| 77a4bfd499 | |||
| 64ceb20431 | |||
| e0204aeeee | |||
| 7df481f7ea | |||
| 03bd6cef93 | |||
| da5ae0ef10 | |||
| 7c7498385f | |||
| 1ae63890ff | |||
| 5f1affafd8 | |||
| 8315c11d34 | |||
| 0043f2aaf4 | |||
| d494eda301 | |||
| a59a4232ff | |||
| 77326a91ca | |||
| 0d95eab86a | |||
| f3482a9a78 | |||
| 68a9dc5749 | |||
| 8c87f1537b | |||
| 97e54355fb | |||
| 9a75f1c929 | |||
| bce5f749b1 | |||
| 584cfb11df | |||
| f327f4cbcd | |||
| 14c4bc17f3 | |||
| 3d0a4564b6 | |||
| e85d1bf72a | |||
| bd3aa6206b | |||
| a971eee7b9 | |||
| 8e8dd358bf | |||
| ee1aa7e5cb | |||
| f446335e81 | |||
| d91ab436e8 | |||
| 0745b11745 | |||
| 97589a7c5f | |||
| 9f04da6956 | |||
| 62e6a67cb7 | |||
| b2ce011545 | |||
| 2f0fd7e360 | |||
| ae93755dbb | |||
| b2626918d3 | |||
| 41628b0578 | |||
| 8826fb7a12 | |||
| f29e9db99d | |||
| 81416737e8 | |||
| d2e97567a9 | |||
| 69538b86ea | |||
| f6f1c31369 | |||
| b76e45cb54 | |||
| 5b5e431b08 | |||
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e | |||
| 8c690155bf | |||
| 1b1e4f715d | |||
| 68eb9ba552 | |||
| 7040865154 | |||
| 013be18d98 | |||
| 3c2a79a31a | |||
| f20472ca26 | |||
| a1e9fb6ffc | |||
| 87f4526220 | |||
| 665ce5a6e7 | |||
| d42a11680f | |||
| 8c41dd626d | |||
| 38dd74b893 | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d | |||
| e0865b81b6 | |||
| d4404e39bd | |||
| 8611206396 | |||
| 39d2244e1e | |||
| eb626be22c | |||
| aaaf59817b | |||
| 2a4d38813f | |||
| ad9c6d63d3 | |||
| 8c77b30de6 | |||
| e51347d0a1 | |||
| 71e7179145 | |||
| bda76ff513 | |||
| 097ce2c205 | |||
| 1d8be3d840 | |||
| aebb8e66cb | |||
| ed2a278da2 | |||
| db2f5da66a | |||
| 19f8facc49 | |||
| b03760ab01 | |||
| 526dcd8dfc | |||
| 5b2561b6f1 | |||
| 3a6d06192a | |||
| 4b64b80b20 | |||
| ff89f96e31 | |||
| 465f997752 | |||
| 6948aaa92a | |||
| 4c7b49a5f6 | |||
| 6368f1027f | |||
| d64e7d08de | |||
| eb3751cb52 | |||
| 9fda54bd06 | |||
| 0d77a3a0a8 | |||
| 0618b78725 | |||
| 0178e71c67 | |||
| aa9b3a0227 | |||
| 410a33fecf | |||
| 05ebaaeedb | |||
| 84d0f63d36 | |||
| 6a4b549d9f | |||
| f3604b438b | |||
| 531831e84b | |||
| 90bb21e38b | |||
| 2f1a137342 | |||
| adf576fbe1 | |||
| 4264026bbe | |||
| 24b4a314a9 | |||
| ba825823a0 | |||
| fb16b7bbb8 | |||
| 8404125ff0 | |||
| 61ad2d61c1 | |||
| 9fd6b44487 | |||
| 9fe10bcce2 | |||
| f2abb40987 | |||
| 3697344cd0 | |||
| 4aa0ff705f | |||
| dc482e614a | |||
| 448882cfef | |||
| 7f9cb33a40 | |||
| 798f6211e0 | |||
| b992b151d3 | |||
| 87b8414e44 | |||
| ee13556c5c | |||
| afeb35a446 | |||
| ab324f1928 | |||
| 78ab0ad2e9 | |||
| 49c77785e7 |
File diff suppressed because it is too large
Load Diff
-114
@@ -1,114 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# Build Backend Docker Image
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
|
||||
# Build Frontend Docker Image
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
- ${DRONE_BRANCH}-latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG:-dev}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
steps:
|
||||
# Build Backend Release
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# Build Frontend Release
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: registry.local.nothaft.cloud
|
||||
|
||||
# -------- NEW: Publish Docker images to GitHub Container Registry --------
|
||||
- name: push-backend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
|
||||
- name: push-frontend-ghcr
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ghcr.io/the-luap/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
registry: ghcr.io
|
||||
username:
|
||||
from_secret: GITHUB_USERNAME
|
||||
password:
|
||||
from_secret: GITHUB_TOKEN
|
||||
build_args:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
@@ -50,9 +50,25 @@ VITE_API_URL=/api
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
# 'beta' uses the :beta tag for pre-release versions
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# Update Check Configuration
|
||||
# Set to 'false' to disable update notifications in admin UI
|
||||
UPDATE_CHECK_ENABLED=true
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Runtime user mapping for Docker (optional)
|
||||
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
|
||||
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
|
||||
PUID=1001
|
||||
PGID=1001
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
# Note: For GitHub fine-grained tokens, ensure the token has:
|
||||
# - Repository access to the-luap/picpeak
|
||||
# - Repository permissions: Contents (Read and Write), Metadata (Read)
|
||||
# For classic tokens: repo scope is sufficient
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history for proper mirroring
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Remove sensitive files and directories
|
||||
run: |
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/install-gitea-runner.sh || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf storage/ || true
|
||||
rm -rf events/ || true
|
||||
rm -rf .playwright-mcp/
|
||||
rm -rf .swarm || true
|
||||
rm -rf .claude-flow || true
|
||||
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
|
||||
# Add and commit the cleanup if there are changes
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||
echo "✅ Committed cleanup of sensitive files"
|
||||
else
|
||||
echo "✅ No sensitive files to remove"
|
||||
fi
|
||||
|
||||
echo "Final file structure (top level):"
|
||||
ls -la | head -10 || true
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
|
||||
echo ""
|
||||
echo "For fine-grained tokens:"
|
||||
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
|
||||
echo " - Create token with repository access to the-luap/picpeak"
|
||||
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
|
||||
echo ""
|
||||
echo "For classic tokens:"
|
||||
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
|
||||
echo " - Create token with 'repo' scope"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
# Try to detect token type (fine-grained tokens are typically longer)
|
||||
if [ ${#GITHUBTOKEN} -gt 80 ]; then
|
||||
echo "📌 Token appears to be a fine-grained personal access token"
|
||||
else
|
||||
echo "📌 Token appears to be a classic personal access token"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
GIT_TRACE: 1 # Enable Git trace for debugging if needed
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Configure Git to use the token for authentication
|
||||
# This method works for both classic and fine-grained tokens
|
||||
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
|
||||
# Add GitHub remote (clean URL without credentials)
|
||||
git remote add github https://github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote configuration:"
|
||||
git remote -v
|
||||
|
||||
# Push to GitHub main branch with error handling
|
||||
echo "Pushing to GitHub..."
|
||||
if git push github main --force 2>&1; then
|
||||
echo "✅ Push to GitHub completed successfully!"
|
||||
else
|
||||
echo "❌ Push to GitHub failed!"
|
||||
echo ""
|
||||
echo "Common issues and solutions:"
|
||||
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
|
||||
echo "2. Token expiration: Check if your token has expired"
|
||||
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
|
||||
echo ""
|
||||
echo "For fine-grained tokens, required permissions:"
|
||||
echo " - Repository access: the-luap/picpeak"
|
||||
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
|
||||
echo ""
|
||||
echo "For classic tokens, required scope: 'repo'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the git config after push
|
||||
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||
echo "🔒 Sensitive files have been removed from the mirror"
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Test and Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend linting
|
||||
working-directory: ./backend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm test || true # Continue on test failures for now
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ./frontend
|
||||
run: npm run lint || true # Continue on lint errors for now
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
@@ -1,267 +0,0 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
set -e # Exit on error
|
||||
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
|
||||
# First, ensure we have the latest changes
|
||||
echo "Fetching latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Check if we're behind and need to update
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
echo "Local is behind remote, pulling changes..."
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
# Pull latest changes before pushing to avoid conflicts
|
||||
echo "Pulling latest changes from origin/main..."
|
||||
if ! git pull --rebase origin main; then
|
||||
echo "Rebase failed, attempting to resolve..."
|
||||
# If rebase fails, abort and try a regular merge
|
||||
git rebase --abort || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
# Push the changes with retry logic
|
||||
echo "Pushing version bump..."
|
||||
PUSH_SUCCESS=false
|
||||
|
||||
for i in 1 2 3; do
|
||||
echo "Push attempt $i of 3..."
|
||||
|
||||
# Try to push
|
||||
if git push origin main 2>&1; then
|
||||
echo "Successfully pushed version bump on attempt $i"
|
||||
PUSH_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "Push failed on attempt $i"
|
||||
|
||||
if [ $i -lt 3 ]; then
|
||||
echo "Waiting 5 seconds before retry..."
|
||||
sleep 5
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Try rebase first, fall back to merge
|
||||
if ! git rebase origin/main; then
|
||||
echo "Rebase failed, trying merge..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$PUSH_SUCCESS" = "false" ]; then
|
||||
echo "ERROR: Failed to push after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
needs: version-bump
|
||||
if: needs.version-bump.outputs.version_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -1,13 +1,19 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
# This workflow is triggered by:
|
||||
# - Push to main/develop branches (builds 'latest' or branch-tagged images)
|
||||
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
|
||||
# - GitHub Releases (created by Release Please)
|
||||
# - Manual workflow dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*.*.*' ]
|
||||
branches: [ main, beta ]
|
||||
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, beta ]
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
@@ -31,18 +37,49 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# Only build ARM64 for tagged releases (v*.*.*)
|
||||
# QEMU emulation is too slow/unreliable for npm operations on regular builds
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.platforms.outputs.skip_qemu != 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -63,20 +100,23 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
build-args: |
|
||||
@@ -86,7 +126,7 @@ jobs:
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
@@ -96,8 +136,8 @@ jobs:
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
@@ -108,18 +148,49 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# Only build ARM64 for tagged releases (v*.*.*)
|
||||
# QEMU emulation is too slow/unreliable for npm operations on regular builds
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.platforms.outputs.skip_qemu != 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -140,20 +211,23 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
build-args: |
|
||||
@@ -163,7 +237,7 @@ jobs:
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
@@ -173,8 +247,8 @@ jobs:
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
@@ -220,4 +294,4 @@ jobs:
|
||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [beta]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: beta
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+13
@@ -75,6 +75,19 @@ 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
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.0.0-beta.0"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "2.3.2"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+480
@@ -0,0 +1,480 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to PicPeak will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
|
||||
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
|
||||
|
||||
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Deployment now requires external reverse proxy for SSL/HTTPS
|
||||
|
||||
### Features
|
||||
|
||||
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
|
||||
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
|
||||
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
|
||||
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
|
||||
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
|
||||
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
|
||||
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
|
||||
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) – gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
|
||||
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
|
||||
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
|
||||
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
|
||||
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
|
||||
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
|
||||
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
|
||||
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
|
||||
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
|
||||
* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
|
||||
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
|
||||
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
|
||||
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
|
||||
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
|
||||
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
|
||||
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
|
||||
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
|
||||
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
|
||||
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
|
||||
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
|
||||
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
|
||||
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
|
||||
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
|
||||
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
|
||||
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
|
||||
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
|
||||
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
|
||||
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
|
||||
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
|
||||
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
|
||||
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
|
||||
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
|
||||
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
|
||||
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
|
||||
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
|
||||
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
|
||||
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
|
||||
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
|
||||
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
|
||||
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
|
||||
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
|
||||
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
|
||||
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
|
||||
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
|
||||
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
|
||||
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
|
||||
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
|
||||
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
|
||||
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
|
||||
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
|
||||
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
|
||||
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
|
||||
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
|
||||
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
|
||||
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
|
||||
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
|
||||
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
|
||||
* handle auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
|
||||
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
|
||||
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
|
||||
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
|
||||
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
|
||||
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
|
||||
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
|
||||
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
|
||||
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
|
||||
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
|
||||
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
|
||||
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
|
||||
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
|
||||
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
|
||||
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
|
||||
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
|
||||
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
|
||||
* remove description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
|
||||
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
|
||||
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
|
||||
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
|
||||
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
|
||||
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
|
||||
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
|
||||
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
|
||||
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
|
||||
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
|
||||
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
|
||||
* resolve CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
|
||||
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
|
||||
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
|
||||
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
|
||||
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
|
||||
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
|
||||
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
|
||||
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
|
||||
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
|
||||
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
|
||||
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
|
||||
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
|
||||
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
|
||||
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
|
||||
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
|
||||
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
|
||||
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
|
||||
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
|
||||
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
|
||||
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
|
||||
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
|
||||
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
|
||||
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
|
||||
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
|
||||
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
|
||||
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
|
||||
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
|
||||
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
|
||||
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
|
||||
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
|
||||
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
|
||||
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
|
||||
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
|
||||
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
|
||||
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
|
||||
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
|
||||
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
|
||||
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
|
||||
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
|
||||
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
|
||||
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
|
||||
|
||||
## [2.3.0](https://github.com/the-luap/picpeak/compare/v2.2.4...v2.3.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
|
||||
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
|
||||
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
|
||||
## [2.2.4](https://github.com/the-luap/picpeak/compare/v2.2.3...v2.2.4) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
|
||||
## [2.2.3](https://github.com/the-luap/picpeak/compare/v2.2.2...v2.2.3) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
|
||||
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
|
||||
|
||||
## [2.2.2](https://github.com/the-luap/picpeak/compare/v2.2.1...v2.2.2) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
|
||||
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
|
||||
|
||||
## [2.2.1](https://github.com/the-luap/picpeak/compare/v2.2.0...v2.2.1) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
|
||||
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
|
||||
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
|
||||
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
|
||||
|
||||
## [2.2.0](https://github.com/the-luap/picpeak/compare/v2.1.1...v2.2.0) (2026-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
|
||||
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
|
||||
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
|
||||
|
||||
## [2.1.1](https://github.com/the-luap/picpeak/compare/v2.1.0...v2.1.1) (2026-01-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
|
||||
|
||||
## [2.1.0](https://github.com/the-luap/picpeak/compare/v2.0.0...v2.1.0) (2026-01-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
|
||||
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
|
||||
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
|
||||
|
||||
## [2.0.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v2.0.0) (2026-01-03)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Deployment now requires external reverse proxy for SSL/HTTPS
|
||||
|
||||
### Features
|
||||
|
||||
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
|
||||
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
|
||||
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
|
||||
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
|
||||
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
|
||||
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
|
||||
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) – gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
|
||||
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
|
||||
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
|
||||
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
|
||||
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
|
||||
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
|
||||
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
|
||||
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
|
||||
* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
|
||||
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
|
||||
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
|
||||
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
|
||||
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
|
||||
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
|
||||
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
|
||||
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
|
||||
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
|
||||
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
|
||||
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
|
||||
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
|
||||
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
|
||||
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
|
||||
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
|
||||
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
|
||||
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
|
||||
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
|
||||
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
|
||||
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
|
||||
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
|
||||
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
|
||||
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
|
||||
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
|
||||
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
|
||||
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
|
||||
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
|
||||
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
|
||||
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
|
||||
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
|
||||
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
|
||||
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
|
||||
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
|
||||
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
|
||||
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
|
||||
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
|
||||
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
|
||||
* handle auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
|
||||
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
|
||||
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
|
||||
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
|
||||
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
|
||||
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
|
||||
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
|
||||
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
|
||||
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
|
||||
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
|
||||
* remove description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
|
||||
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
|
||||
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
|
||||
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
|
||||
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
|
||||
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
|
||||
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
|
||||
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
|
||||
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
|
||||
* resolve CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
|
||||
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
|
||||
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
|
||||
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
|
||||
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
|
||||
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
|
||||
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
|
||||
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
|
||||
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
|
||||
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
|
||||
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
|
||||
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
|
||||
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
|
||||
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
|
||||
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
|
||||
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
|
||||
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
|
||||
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
|
||||
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
|
||||
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
|
||||
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
|
||||
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
|
||||
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
|
||||
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
|
||||
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
|
||||
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
|
||||
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
|
||||
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
|
||||
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
|
||||
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
|
||||
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
|
||||
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
|
||||
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
|
||||
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
|
||||
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
|
||||
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
|
||||
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
|
||||
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
|
||||
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
|
||||
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
|
||||
|
||||
## [1.2.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v1.2.0) (2026-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
* **Event Rename**: Safe event renaming with automatic slug updates, old URL redirects via `slug_redirects` table, and optional email notifications to clients
|
||||
* **Optional Event Fields**: Make customer name, email, and admin email fields optional via admin settings with "(optional)" labels in forms
|
||||
* **Photo Filtering**: Filter photos by rating, likes, favorites, and comments with a new PhotoFilterPanel component
|
||||
* **Photo Export**: Export filtered photo selections as ZIP, generate Capture One/Lightroom-compatible XMP sidecar files, or export metadata lists
|
||||
* **Custom CSS Templates**: 3 customizable CSS template slots with live preview, XSS-safe sanitization, and per-event template assignment
|
||||
* **Apple Liquid Glass Theme**: Starter CSS template inspired by iOS 26 / macOS Tahoe Liquid Glass design with glass morphism effects, Apple SF Pro fonts, and responsive layout
|
||||
* **Liquid Glass Dark Theme**: Neon-accented dark glass theme with animated gradient backgrounds
|
||||
* **Image Security Settings**: Per-event download protection with configurable protection levels (basic, standard, enhanced, maximum), canvas rendering, DevTools detection, and right-click prevention
|
||||
* **Automated Releases**: Release Please integration for automatic versioning, changelog generation, and GitHub releases that trigger Docker image builds
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Date Parsing**: Fix event date formatting in slugs (now uses YYYY-MM-DD format correctly)
|
||||
* **Search Placeholder**: Fix search field placeholder visibility in glass-styled sidebar
|
||||
* **Vite Proxy**: Fix Vite dev server proxy port configuration
|
||||
* **Photo Export Button**: Fix export button staying disabled when photos are selected
|
||||
* **Boolean Parsing**: Fix boolean parsing in publicSettings.js for optional fields
|
||||
* **Translation Keys**: Add missing `common.optional` translation key in locales
|
||||
|
||||
### Security
|
||||
|
||||
* Fix critical vulnerabilities and harden application security
|
||||
* Add CSS sanitizer utility blocking XSS vectors in custom templates
|
||||
* Implement secure gallery CSS endpoint for template delivery
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* Add Photo and Settings service layers for better code organization
|
||||
* Phase 1 code consolidation with service layer architecture
|
||||
* Modular settings page with feature-based tab components
|
||||
* Create photoFilterBuilder utility for query construction
|
||||
* Add eventRenameService for safe event operations
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add comprehensive REFACTORING_PLAN.md for codebase improvement roadmap
|
||||
* Update README roadmap with implemented features (Download Protection, Gallery Templates, Filtering & Export)
|
||||
* Add test specification documents for all new features
|
||||
|
||||
### Database Migrations
|
||||
|
||||
* `049_add_slug_redirects.js` - Store old slugs for URL redirects after rename
|
||||
* `050_add_optional_event_fields_settings.js` - Settings for optional form fields
|
||||
* `051_add_photo_filter_indexes.js` - Performance indexes for photo filtering
|
||||
* `052_add_css_templates.js` - CSS template storage with 3 slots
|
||||
* `053_add_liquid_glass_templates.js` - Apple Liquid Glass and Dark theme starter templates
|
||||
|
||||
---
|
||||
|
||||
## [1.1.15] - Previous Release
|
||||
|
||||
Initial stable release with core functionality.
|
||||
@@ -1,386 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Product Overview
|
||||
|
||||
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
|
||||
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
|
||||
- **Storage**: File-based with active/archived separation
|
||||
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
|
||||
- **Analytics**: Umami integration for engagement tracking
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
cd backend
|
||||
npm install # Install dependencies
|
||||
npm run migrate # Initialize database schema
|
||||
npm run dev # Start with hot-reload (port 3001)
|
||||
npm test # Run Jest tests
|
||||
npm run lint # ESLint checks
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
```bash
|
||||
cd backend
|
||||
npm test -- path/to/test.test.js
|
||||
npm test -- --testNamePattern="test name"
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
|
||||
- Docker Compose deployment
|
||||
- PM2 deployment
|
||||
- Manual installation
|
||||
- Non-nginx deployment options
|
||||
- SSL/HTTPS setup
|
||||
- Troubleshooting guide
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
1. **File-Based System**: Drop photos in folders → automatic gallery creation
|
||||
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
|
||||
3. **Password Protection**: Secure access with customizable passwords
|
||||
4. **Automatic Archiving**: ZIP compression and storage after expiration
|
||||
5. **Email Notifications**: Creation, warning, and expiration notifications
|
||||
6. **Analytics**: Umami tracking for views, downloads, and engagement
|
||||
|
||||
### Folder Structure
|
||||
```
|
||||
/events/
|
||||
├── active/
|
||||
│ ├── wedding-smith-jones-2024-06-15/
|
||||
│ │ ├── collages/
|
||||
│ │ └── individual/
|
||||
│ └── birthday-emma-2024-07-20/
|
||||
└── archived/
|
||||
└── wedding-smith-jones-2024-06-15.zip
|
||||
```
|
||||
|
||||
## Frontend Implementation Requirements
|
||||
|
||||
### Design Style (scrappbook.de-inspired)
|
||||
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
|
||||
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
|
||||
- **Layout**: Minimalist, modular sections with grid-based photo displays
|
||||
- **Aesthetic**: Professional yet approachable, photographer-focused
|
||||
|
||||
### Key Frontend Components to Build
|
||||
1. **Landing Page**: Password entry with event preview
|
||||
2. **Gallery View**:
|
||||
- Responsive photo grid with lazy loading
|
||||
- Toggle between collages/individual photos
|
||||
- Prominent expiration banner
|
||||
- Download urgency indicators
|
||||
3. **Photo Lightbox**: Full-screen viewing with zoom
|
||||
4. **Mobile-First**: Responsive design with touch gestures
|
||||
5. **Personalization**: Dynamic theming per event type
|
||||
|
||||
### User Experience Priorities
|
||||
- Clear expiration warnings (sticky banner)
|
||||
- One-click "Download All" for urgent galleries
|
||||
- Smooth image loading with skeleton screens
|
||||
- Intuitive navigation between photo categories
|
||||
- Professional presentation matching photographer branding
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
### Authentication Flow
|
||||
- JWT-based with separate tokens for admin and gallery access
|
||||
- Gallery tokens include event-specific claims
|
||||
- Auth middleware: `backend/src/middleware/auth.js`
|
||||
- `adminAuth` - Admin panel protection
|
||||
- `photoAuth` - Protected photo access
|
||||
- `verifyGalleryAccess` - Gallery-specific validation
|
||||
|
||||
### Database Schema (Knex/SQLite)
|
||||
Main tables:
|
||||
- `events` - Gallery metadata with expiration, custom messages, themes
|
||||
- `photos` - Photo records linked to events
|
||||
- `access_logs` - IP-based usage tracking
|
||||
- `email_queue` - Async email processing
|
||||
- `admin_users` - Admin authentication
|
||||
|
||||
### Service Architecture
|
||||
Background services run as separate processes:
|
||||
- **emailService**: Processes email queue with retry logic
|
||||
- **archiveService**: Creates ZIP archives of expired events
|
||||
- **expirationChecker**: Cron job for expiration warnings
|
||||
- **fileWatcher**: Monitors for new photo uploads
|
||||
- **backupService**: Scheduled backups with checksum-based change detection
|
||||
|
||||
### API Structure
|
||||
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
|
||||
- `/api/gallery/*` - Public gallery endpoints
|
||||
- `/api/auth/*` - Authentication endpoints
|
||||
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **Security**: All gallery access requires valid JWT with event-specific claims
|
||||
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
|
||||
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
|
||||
4. **File Processing**: Sharp library for thumbnail generation (300x300)
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
- `JWT_SECRET` - Token signing
|
||||
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
|
||||
- `SMTP_*` - Email configuration
|
||||
- `DB_*` - PostgreSQL credentials (production)
|
||||
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
|
||||
- `UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
|
||||
### Frontend (.env)
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- `VITE_UMAMI_URL` - Umami analytics URL
|
||||
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
|
||||
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
|
||||
|
||||
## Testing Approach
|
||||
- Jest with Supertest for API testing
|
||||
- Test files in `__tests__` directories
|
||||
- Database migrations run before tests
|
||||
- Mock email sending in tests
|
||||
|
||||
## Umami Analytics Integration
|
||||
|
||||
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
|
||||
|
||||
### Tracked Events:
|
||||
- **Gallery Events**:
|
||||
- `gallery_password_entry` - Password attempts (success/failure)
|
||||
- `gallery_photo_view` - Individual photo views
|
||||
- `gallery_photo_download` - Single photo downloads
|
||||
- `gallery_bulk_download` - Bulk/all photo downloads
|
||||
- `gallery_expired` - Expired gallery access attempts
|
||||
- **Admin Events**:
|
||||
- `admin_login` - Admin authentication
|
||||
- `admin_event_created` - New event creation
|
||||
- `admin_event_archived` - Event archiving
|
||||
- `admin_event_deleted` - Event deletion
|
||||
- `admin_settings_updated` - Settings changes
|
||||
- **User Behavior**:
|
||||
- Search queries (with debouncing)
|
||||
- Expiration warning views
|
||||
- Page views with automatic tracking
|
||||
|
||||
### Setup:
|
||||
1. Install Umami (self-hosted or cloud)
|
||||
2. Create a website in Umami dashboard
|
||||
3. Set environment variables:
|
||||
```
|
||||
VITE_UMAMI_URL=https://your-umami-instance.com
|
||||
VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
|
||||
```
|
||||
|
||||
### Analytics Dashboard:
|
||||
- Admin panel includes analytics page at `/admin/analytics`
|
||||
- Summary view with key metrics
|
||||
- Option to embed full Umami dashboard
|
||||
- Real-time event tracking
|
||||
|
||||
## Accessibility & Performance Features
|
||||
|
||||
### Accessibility (WCAG 2.1 AA Compliance)
|
||||
- **Error Boundaries**: Graceful error handling with recovery options
|
||||
- **Skip Links**: Skip to main content for keyboard navigation
|
||||
- **ARIA Labels**: Proper labeling for screen readers
|
||||
- **Focus Management**: Focus trap in modals, visible focus indicators
|
||||
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
|
||||
- **Loading States**: Skeleton screens instead of spinners for better UX
|
||||
- **Offline Support**: Visual indicator when offline
|
||||
- **Form Validation**: Accessible error messages with aria-describedby
|
||||
|
||||
### Performance Optimizations
|
||||
- **Lazy Loading**: Images load on scroll with Intersection Observer
|
||||
- **Skeleton Screens**: Instant visual feedback during loading
|
||||
- **Error Recovery**: Component-level error boundaries prevent full page crashes
|
||||
- **Optimistic Updates**: Immediate UI updates with background sync
|
||||
- **Debounced Search**: Prevents excessive API calls
|
||||
- **Analytics**: Non-blocking Umami integration
|
||||
|
||||
### Component Library Enhancements
|
||||
- `<ErrorBoundary>` - Catches and displays errors gracefully
|
||||
- `<PageErrorBoundary>` - Full-page error recovery
|
||||
- `<Skeleton>` - Flexible skeleton loader with variants
|
||||
- `<OfflineIndicator>` - Network status monitoring
|
||||
- `<SkipLink>` - Accessibility navigation
|
||||
- `useFocusTrap` - Modal focus management hook
|
||||
- `useOnlineStatus` - Network status hook
|
||||
|
||||
## Theme System & Branding
|
||||
|
||||
### Theme Features
|
||||
- **Dynamic Theming**: CSS variables for runtime theme switching
|
||||
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
|
||||
- **Customization Options**:
|
||||
- Primary/Accent/Background/Text colors
|
||||
- Font family selection
|
||||
- Border radius (none, sm, md, lg)
|
||||
- Custom logo upload
|
||||
- Custom CSS injection
|
||||
- **Event-Specific Themes**: Override global theme per gallery
|
||||
- **Live Preview**: Real-time theme changes in admin panel
|
||||
|
||||
### Theme Context API
|
||||
```typescript
|
||||
const { theme, setTheme, setThemeByName } = useTheme();
|
||||
```
|
||||
|
||||
### Branding Settings
|
||||
- Company name, tagline, and support email
|
||||
- Custom footer text
|
||||
- Optional watermarking on downloads
|
||||
- Logo upload for gallery header
|
||||
|
||||
### CSS Variables
|
||||
```css
|
||||
--color-primary: #5C8762;
|
||||
--color-primary-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
--font-family: 'Inter', sans-serif;
|
||||
--border-radius: 0.5rem;
|
||||
```
|
||||
|
||||
## Backup Service
|
||||
|
||||
### Overview
|
||||
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
|
||||
|
||||
### Features
|
||||
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
|
||||
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
|
||||
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
|
||||
- **Email Notifications**: Alerts on backup failure, optional success notifications
|
||||
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
|
||||
- **Progress Tracking**: Database storage of backup history, file states, and statistics
|
||||
|
||||
### Configuration
|
||||
Backup settings are stored in `app_settings` table with `backup_` prefix:
|
||||
- `backup_enabled`: Enable/disable the service
|
||||
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
|
||||
- `backup_destination_type`: 'local', 'rsync', or 's3'
|
||||
- `backup_retention_days`: How long to keep backup history
|
||||
- `backup_include_archived`: Whether to backup archived events
|
||||
- `backup_exclude_patterns`: File patterns to exclude
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/admin/backup/config` - Get current configuration
|
||||
- `PUT /api/admin/backup/config` - Update configuration
|
||||
- `GET /api/admin/backup/status` - Get backup status and history
|
||||
- `POST /api/admin/backup/run` - Trigger manual backup
|
||||
- `POST /api/admin/backup/test-connection` - Test destination connectivity
|
||||
|
||||
### Testing
|
||||
Run backup service test: `npm run test-backup`
|
||||
|
||||
### Database Tables
|
||||
- `backup_runs`: Tracks each backup execution with statistics
|
||||
- `backup_file_states`: Stores file checksums for change detection
|
||||
|
||||
## Thumbnail Generation
|
||||
|
||||
### Square Thumbnail Implementation (Issue #12 Fix)
|
||||
The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid:
|
||||
|
||||
- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion
|
||||
- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning
|
||||
- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format`
|
||||
- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings
|
||||
- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails
|
||||
|
||||
### Thumbnail Settings API
|
||||
- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration
|
||||
- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration)
|
||||
- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings
|
||||
- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress
|
||||
|
||||
## Success Metrics (from PRD)
|
||||
- Time to generate gallery: <2 minutes
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
- always use docker deployment for testing
|
||||
+185
-148
@@ -2,37 +2,36 @@
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
|
||||
## 📋 Table of Contents
|
||||
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, use our unified setup script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
- Choice between Docker or Native installation
|
||||
- OS detection and dependency installation
|
||||
- Database setup and service configuration
|
||||
- SSL/HTTPS setup (optional)
|
||||
|
||||
Perfect for:
|
||||
- Small to medium deployments
|
||||
- Local or VPS installations
|
||||
- Users new to server management
|
||||
- Quick testing and evaluation
|
||||
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Compose Deployment
|
||||
|
||||
### Option 1: Using Pre-built Images (Recommended)
|
||||
|
||||
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
@@ -43,34 +42,40 @@ cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Use pre-built images deployment
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
The production compose file uses:
|
||||
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
|
||||
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
Available tags:
|
||||
- `latest` - Latest stable release
|
||||
- `main` - Latest main branch build
|
||||
- `develop` - Development branch (may be unstable)
|
||||
- `v1.0.0` - Specific version tags
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 2: Building from Source
|
||||
### Option 3: Build from Source
|
||||
|
||||
If you need to customize the application or the pre-built images aren't available, you can build locally:
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
## 📋 Table of Contents
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Deployment](#deployment)
|
||||
- [First Login](#first-login)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -79,69 +84,6 @@ If you need to customize the application or the pre-built images aren't availabl
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Method 1: Using Pre-built Images (Fastest)
|
||||
|
||||
1. **Clone the repository for configs**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy using pre-built images**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
### Method 2: Building from Source
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Build and deploy**
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
@@ -163,6 +105,15 @@ openssl rand -base64 32 | tr -d '$'
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
@@ -172,14 +123,17 @@ Update `.env` with:
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
@@ -308,14 +262,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password
|
||||
# Reset the admin password to a new random password (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- The admin password is only displayed once during initial setup
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
@@ -373,7 +329,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
@@ -386,10 +352,79 @@ ADMIN_EMAIL=your-email@yourdomain.com
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
|
||||
### Routing Schema
|
||||
|
||||
PicPeak consists of two services that need to be routed correctly:
|
||||
|
||||
| Path | Service | Port | Description |
|
||||
|------|---------|------|-------------|
|
||||
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||
|
||||
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||
|
||||
### Option 1: Nginx
|
||||
|
||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||
@@ -408,39 +443,32 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend (serves UI and /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
# Backend: API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
|
||||
# Backend: Protected media files
|
||||
location ~ ^/(photos|thumbnails|uploads)/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -470,10 +498,16 @@ services:
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API endpoints
|
||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||
# Protected media files
|
||||
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||
```
|
||||
|
||||
### Option 3: Caddy
|
||||
@@ -482,21 +516,12 @@ Create a `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
your-domain.com {
|
||||
# Frontend
|
||||
handle /* {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
# Backend API and admin
|
||||
# Backend: API endpoints
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
handle /admin/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Protected resources
|
||||
# Backend: Protected media files
|
||||
handle /photos/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
@@ -508,6 +533,11 @@ your-domain.com {
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -587,20 +617,27 @@ docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version Updates
|
||||
#### Specific Version or Channel Updates
|
||||
|
||||
To use a specific version of the images:
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.production.yml to specify version tags
|
||||
# Change: ghcr.io/the-luap/picpeak/backend:latest
|
||||
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
@@ -28,11 +28,13 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
@@ -45,6 +47,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
@@ -70,14 +73,81 @@ docker-compose up -d
|
||||
# Access at http://localhost:3005
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Switching Channels
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml pull
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
@@ -109,6 +179,31 @@ Perfect for:
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
@@ -184,7 +279,6 @@ These features are currently in beta testing and may have limited functionality
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
|
||||
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||
|
||||
### 📋 Future Enhancements
|
||||
@@ -192,11 +286,14 @@ These features are currently in beta testing and may have limited functionality
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
|
||||
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
@@ -233,4 +330,4 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
+25
-22
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --docker
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --native
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./setup.sh
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
@@ -87,7 +87,7 @@ The script will prompt you to choose:
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --docker --unattended \
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --native --unattended \
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
@@ -292,7 +292,8 @@ sudo systemctl stop picpeak-backend picpeak-workers
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
sudo ./setup.sh --update
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -305,9 +306,9 @@ docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/backend/.env`:
|
||||
Edit `/opt/picpeak/app/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/backend/.env
|
||||
sudo nano /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
@@ -369,7 +370,7 @@ tar -czf photos-backup.tar.gz storage/events/
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
|
||||
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/
|
||||
@@ -384,14 +385,14 @@ docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./setup.sh --uninstall
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
@@ -446,10 +447,12 @@ ls -la ~/picpeak/storage/events/
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/backend
|
||||
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:**
|
||||
@@ -458,11 +461,11 @@ sudo -u picpeak node scripts/reset-admin-password.js
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
|
||||
- [Full Documentation](https://github.com/the-luap/picpeak)
|
||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
@@ -498,7 +501,7 @@ services:
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
@@ -507,13 +510,13 @@ sudo systemctl restart picpeak-backend
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./setup.sh --native --email admin@local.com
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./setup.sh --docker \
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
@@ -522,7 +525,7 @@ sudo ./setup.sh --docker \
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./setup.sh --native \
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
@@ -540,4 +543,4 @@ sudo ./setup.sh --native \
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
|
||||
|
||||
+17
-6
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
@@ -11,22 +11,33 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
@@ -37,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Make wait script executable
|
||||
RUN chmod +x wait-for-db.sh
|
||||
# Ensure all source files are readable and wait script is executable
|
||||
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin photos in reference mode', () => {
|
||||
let tmpDir;
|
||||
let storagePath;
|
||||
let db;
|
||||
let app;
|
||||
let categoryId;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||
validateUploadedFiles: (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: () => true,
|
||||
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}));
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
db = dbModule.db;
|
||||
|
||||
await db.schema.dropTableIfExists('photo_feedback');
|
||||
await db.schema.dropTableIfExists('photos');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
await db.schema.dropTableIfExists('events');
|
||||
|
||||
await db.schema.createTable('events', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.string('source_mode').notNullable();
|
||||
table.string('external_path');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.string('slug').notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photos', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable();
|
||||
table.string('filename').notNullable();
|
||||
table.string('path').notNullable();
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
table.string('source_origin');
|
||||
table.string('external_relpath');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('photo_id');
|
||||
table.string('feedback_type');
|
||||
table.boolean('is_approved');
|
||||
table.boolean('is_hidden');
|
||||
});
|
||||
|
||||
await db('events').insert({
|
||||
id: 1,
|
||||
slug: 'test-event',
|
||||
event_name: 'Test Event',
|
||||
source_mode: 'reference',
|
||||
external_path: 'external/library'
|
||||
});
|
||||
|
||||
const insertedCategory = await db('photo_categories').insert({
|
||||
name: 'Highlights',
|
||||
slug: 'highlights',
|
||||
is_global: true
|
||||
});
|
||||
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||
|
||||
const router = require('../../src/routes/adminPhotos');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
}
|
||||
resetModules();
|
||||
delete process.env.TEST_DATABASE_PATH;
|
||||
delete process.env.STORAGE_PATH;
|
||||
if (tmpDir) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stores managed uploads with category information and managed origin', async () => {
|
||||
const uploadResponse = await request(app)
|
||||
.post(`/api/admin/events/1/upload`)
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
const photo = await db('photos').first();
|
||||
expect(photo).toBeTruthy();
|
||||
expect(photo.category_id).toBe(categoryId);
|
||||
expect(photo.source_origin).toBe('managed');
|
||||
expect(photo.external_relpath).toBeNull();
|
||||
});
|
||||
|
||||
it('returns numeric category metadata when listing photos', async () => {
|
||||
await db('photos').insert({
|
||||
event_id: 1,
|
||||
filename: 'external.jpg',
|
||||
path: 'test-event/external.jpg',
|
||||
thumbnail_path: null,
|
||||
type: 'individual',
|
||||
size_bytes: 123,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'individual/external.jpg'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||
expect(managedPhoto).toBeTruthy();
|
||||
expect(managedPhoto.category_name).toBe('Highlights');
|
||||
|
||||
const filtered = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.query({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
|
||||
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes category updates', async () => {
|
||||
const photo = await db('photos').first();
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: '0' })
|
||||
.expect(200);
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
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;
|
||||
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const path = require('path');
|
||||
const mockPath = path;
|
||||
|
||||
jest.mock('../../src/services/externalMediaService', () => ({
|
||||
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||
}));
|
||||
|
||||
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||
|
||||
describe('resolvePhotoFilePath', () => {
|
||||
const backendRoot = path.resolve(__dirname, '../../');
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (typeof originalStoragePath === 'string') {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
} else {
|
||||
delete process.env.STORAGE_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('delegates external photos to external media resolver', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||
});
|
||||
|
||||
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||
});
|
||||
|
||||
it('falls back to managed storage when external metadata is missing', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { path: 'fashion-show/new-upload.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).not.toHaveBeenCalled();
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
|
||||
});
|
||||
|
||||
it('throws when external photo is missing relative path data', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external' };
|
||||
|
||||
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
if (!process.env.SKIP_S3_TESTS) {
|
||||
process.env.SKIP_S3_TESTS = 'true';
|
||||
}
|
||||
if (!process.env.STORAGE_PATH) {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
+102
-25
@@ -3,6 +3,54 @@ require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const resolveSqliteFilename = (filenameEnv) => {
|
||||
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(__dirname, trimmed);
|
||||
} else {
|
||||
resolved = path.join(__dirname, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: resolveSqliteFilename(filenameEnv)
|
||||
});
|
||||
|
||||
const baseSqliteConfig = {
|
||||
client: 'sqlite3',
|
||||
connection: sqliteConnection(),
|
||||
useNullAsDefault: true,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
@@ -13,7 +61,7 @@ const config = {
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
migrations: {
|
||||
@@ -24,36 +72,65 @@ const config = {
|
||||
}
|
||||
},
|
||||
|
||||
test: (() => {
|
||||
const client = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
const isPostgres = client === 'pg';
|
||||
|
||||
return {
|
||||
...baseSqliteConfig,
|
||||
client,
|
||||
useNullAsDefault: !isPostgres,
|
||||
connection: isPostgres
|
||||
? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing_test'
|
||||
}
|
||||
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
|
||||
};
|
||||
})(),
|
||||
|
||||
production: {
|
||||
client: process.env.DATABASE_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
},
|
||||
pool: {
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
},
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
}
|
||||
: {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
}
|
||||
: undefined,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
acquireConnectionTimeout: 60000
|
||||
}
|
||||
};
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||
module.exports = config[env] || config.development;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Migration 041: Add external media reference support
|
||||
* - events.source_mode: 'managed' | 'reference'
|
||||
* - events.external_path: relative path under external media root
|
||||
* - photos.source_origin: 'managed' | 'external'
|
||||
* - photos.external_relpath: relative path within event.external_path
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 041_add_external_media');
|
||||
|
||||
// events.source_mode (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => {
|
||||
table.string('source_mode').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// events.external_path (nullable)
|
||||
await addColumnIfNotExists(knex, 'events', 'external_path', (table) => {
|
||||
table.text('external_path');
|
||||
});
|
||||
|
||||
// photos.source_origin (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => {
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// photos.external_relpath (nullable)
|
||||
await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => {
|
||||
table.text('external_relpath');
|
||||
});
|
||||
|
||||
// Helpful index for queries
|
||||
try {
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)");
|
||||
} else {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.index(['event_id', 'source_origin'], 'photos_event_source_idx');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Index creation skipped or failed (may already exist):', e.message);
|
||||
}
|
||||
|
||||
console.log('Migration 041_add_external_media completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 041_add_external_media');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
async function ensureColumn(knex, tableName, columnName, alterFn) {
|
||||
const exists = await knex.schema.hasColumn(tableName, columnName);
|
||||
if (!exists) {
|
||||
logger.info(`Adding column ${tableName}.${columnName}`);
|
||||
await knex.schema.table(tableName, alterFn);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await ensureColumn(knex, 'events', 'host_name', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
|
||||
table.integer('upload_category_id');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
|
||||
table.text('watermark_text');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
|
||||
table.string('uploaded_by').defaultTo('admin');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// Non destructive migration; no rollback
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
} = require('../../src/constants/publicSiteDefaults');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const defaults = [
|
||||
{
|
||||
setting_key: 'general_public_site_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_html',
|
||||
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_custom_css',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'general'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of defaults) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
exports.up = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('require_password').notNullable().defaultTo(true);
|
||||
});
|
||||
await knex('events').update({ require_password: true });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('require_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
const settingKey = 'general_max_files_per_upload';
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Normalize existing value into allowed bounds
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
|
||||
} catch {
|
||||
parsedValue = existing.setting_value;
|
||||
}
|
||||
|
||||
const numeric = Number(parsedValue);
|
||||
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
if (Number.isFinite(numeric) && numeric >= 1) {
|
||||
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
|
||||
}
|
||||
|
||||
if (normalized !== numeric) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.update({
|
||||
setting_value: JSON.stringify(normalized),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: settingKey,
|
||||
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
|
||||
table.string('customer_name');
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
|
||||
table.string('customer_email');
|
||||
});
|
||||
|
||||
// Backfill new columns from legacy host_* fields
|
||||
const client = knex?.client?.config?.client;
|
||||
|
||||
if (client === 'pg') {
|
||||
await knex.raw(`
|
||||
UPDATE events
|
||||
SET customer_name = COALESCE(customer_name, host_name),
|
||||
customer_email = COALESCE(customer_email, host_email)
|
||||
`);
|
||||
} else {
|
||||
// SQLite fallback
|
||||
await knex('events').update({
|
||||
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
|
||||
customer_email: knex.raw('COALESCE(customer_email, host_email)')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
|
||||
if (hasCustomerName) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_name');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
|
||||
if (hasCustomerEmail) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
// Add tls_reject_unauthorized column to email_configs table
|
||||
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
|
||||
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
|
||||
table.boolean('tls_reject_unauthorized').defaultTo(true);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('email_configs', (table) => {
|
||||
table.dropColumn('tls_reject_unauthorized');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
/**
|
||||
* Migration: Add video support to photos table
|
||||
* - Adds columns for video metadata (media_type, duration, codecs, dimensions)
|
||||
* - Updates existing photos to have media_type 'image'
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 042_add_video_support');
|
||||
|
||||
// Add media_type column (image or video)
|
||||
await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => {
|
||||
table.string('media_type').defaultTo('image');
|
||||
});
|
||||
|
||||
// Add mime_type column if not exists
|
||||
await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => {
|
||||
table.string('mime_type');
|
||||
});
|
||||
|
||||
// Add duration column (for videos, in seconds)
|
||||
await addColumnIfNotExists(knex, 'photos', 'duration', (table) => {
|
||||
table.integer('duration');
|
||||
});
|
||||
|
||||
// Add video codec information
|
||||
await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => {
|
||||
table.string('video_codec');
|
||||
});
|
||||
|
||||
// Add audio codec information
|
||||
await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => {
|
||||
table.string('audio_codec');
|
||||
});
|
||||
|
||||
// Add width dimension
|
||||
await addColumnIfNotExists(knex, 'photos', 'width', (table) => {
|
||||
table.integer('width');
|
||||
});
|
||||
|
||||
// Add height dimension
|
||||
await addColumnIfNotExists(knex, 'photos', 'height', (table) => {
|
||||
table.integer('height');
|
||||
});
|
||||
|
||||
// Update existing photos to have media_type 'image' if not set
|
||||
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
|
||||
if (hasMediaType) {
|
||||
await knex('photos')
|
||||
.whereNull('media_type')
|
||||
.orWhere('media_type', '')
|
||||
.update({ media_type: 'image' });
|
||||
console.log('Updated existing photos to have media_type "image"');
|
||||
}
|
||||
|
||||
console.log('Migration 042_add_video_support completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back migration: 042_add_video_support');
|
||||
|
||||
// Remove video support columns
|
||||
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
|
||||
if (hasMediaType) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('media_type');
|
||||
});
|
||||
}
|
||||
|
||||
const hasDuration = await knex.schema.hasColumn('photos', 'duration');
|
||||
if (hasDuration) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('duration');
|
||||
});
|
||||
}
|
||||
|
||||
const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec');
|
||||
if (hasVideoCodec) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('video_codec');
|
||||
});
|
||||
}
|
||||
|
||||
const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec');
|
||||
if (hasAudioCodec) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('audio_codec');
|
||||
});
|
||||
}
|
||||
|
||||
const hasWidth = await knex.schema.hasColumn('photos', 'width');
|
||||
if (hasWidth) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('width');
|
||||
});
|
||||
}
|
||||
|
||||
const hasHeight = await knex.schema.hasColumn('photos', 'height');
|
||||
if (hasHeight) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('height');
|
||||
});
|
||||
}
|
||||
|
||||
// Note: We don't drop mime_type as it may be used by images as well
|
||||
|
||||
console.log('Rollback of 042_add_video_support completed');
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Migration: Add slug_redirects table for event rename feature
|
||||
* This table stores old slugs that should redirect to new slugs
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.createTable('slug_redirects', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('old_slug', 255).notNullable().unique();
|
||||
table.string('new_slug', 255).notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Index for fast lookup
|
||||
table.index('old_slug');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('slug_redirects');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Migration: Add optional event fields settings
|
||||
* These settings control whether customer name, customer email, and admin email
|
||||
* are required when creating new events.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const settings = [
|
||||
{ setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Migration: Add indexes for photo filtering performance
|
||||
* These indexes optimize queries that filter by feedback metrics
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add comment_count column if it doesn't exist
|
||||
const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count');
|
||||
if (!hasCommentCount) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.integer('comment_count').defaultTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes for common filter queries
|
||||
// Note: PostgreSQL supports partial indexes, SQLite does not
|
||||
const client = knex.client.config.client;
|
||||
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
// Partial indexes for PostgreSQL
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
|
||||
ON photos(event_id, average_rating)
|
||||
WHERE average_rating > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
|
||||
ON photos(event_id, like_count)
|
||||
WHERE like_count > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
|
||||
ON photos(event_id, favorite_count)
|
||||
WHERE favorite_count > 0
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
|
||||
ON photos(event_id, comment_count)
|
||||
WHERE comment_count > 0
|
||||
`);
|
||||
} else {
|
||||
// Regular indexes for SQLite
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
|
||||
ON photos(event_id, average_rating)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
|
||||
ON photos(event_id, like_count)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
|
||||
ON photos(event_id, favorite_count)
|
||||
`);
|
||||
|
||||
await knex.raw(`
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
|
||||
ON photos(event_id, comment_count)
|
||||
`);
|
||||
}
|
||||
|
||||
// Create export_jobs table for tracking large exports
|
||||
const hasExportJobs = await knex.schema.hasTable('export_jobs');
|
||||
if (!hasExportJobs) {
|
||||
await knex.schema.createTable('export_jobs', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('job_id', 50).unique().notNullable();
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.string('format', 20).notNullable();
|
||||
table.string('status', 20).defaultTo('pending');
|
||||
table.integer('progress').defaultTo(0);
|
||||
table.integer('total_photos');
|
||||
table.json('options');
|
||||
table.string('file_path', 500);
|
||||
table.bigInteger('file_size');
|
||||
table.text('error_message');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('completed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Drop indexes
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter');
|
||||
await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter');
|
||||
|
||||
// Drop export_jobs table
|
||||
await knex.schema.dropTableIfExists('export_jobs');
|
||||
|
||||
// Note: We don't remove comment_count column as it might have data
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Migration: Add CSS Templates feature
|
||||
* Creates css_templates table and adds css_template_id to events table
|
||||
*/
|
||||
|
||||
// Default CSS template content
|
||||
const DEFAULT_CSS_TEMPLATE = `/*
|
||||
* PicPeak Custom CSS Template: Elegant Dark
|
||||
*
|
||||
* Available CSS Custom Properties:
|
||||
* --gallery-bg: Background color
|
||||
* --gallery-text: Primary text color
|
||||
* --gallery-accent: Accent/highlight color
|
||||
* --gallery-border: Border color
|
||||
* --gallery-shadow: Box shadow value
|
||||
* --gallery-radius: Border radius value
|
||||
* --gallery-spacing: Base spacing unit
|
||||
*/
|
||||
|
||||
/* ===== Base Theme Variables ===== */
|
||||
.gallery-page {
|
||||
--gallery-bg: #1a1a2e;
|
||||
--gallery-bg-secondary: #16213e;
|
||||
--gallery-text: #eaeaea;
|
||||
--gallery-text-muted: #8b8b9a;
|
||||
--gallery-accent: #e94560;
|
||||
--gallery-accent-hover: #ff6b6b;
|
||||
--gallery-border: #2d2d44;
|
||||
--gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
--gallery-radius: 12px;
|
||||
--gallery-spacing: 16px;
|
||||
}
|
||||
|
||||
/* ===== Page Background ===== */
|
||||
.gallery-page {
|
||||
background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--gallery-text);
|
||||
}
|
||||
|
||||
/* ===== Gallery Header ===== */
|
||||
.gallery-header {
|
||||
background: rgba(22, 33, 62, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--gallery-border);
|
||||
padding: calc(var(--gallery-spacing) * 2);
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
color: var(--gallery-text);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ===== Photo Grid ===== */
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--gallery-spacing);
|
||||
padding: calc(var(--gallery-spacing) * 2);
|
||||
}
|
||||
|
||||
/* ===== Photo Cards ===== */
|
||||
.photo-card {
|
||||
background: var(--gallery-bg-secondary);
|
||||
border-radius: var(--gallery-radius);
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid var(--gallery-border);
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--gallery-shadow);
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.photo-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
.gallery-btn {
|
||||
background: var(--gallery-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: calc(var(--gallery-radius) / 2);
|
||||
padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.gallery-btn:hover {
|
||||
background: var(--gallery-accent-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== Lightbox ===== */
|
||||
.lightbox-overlay {
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* ===== Responsive Adjustments ===== */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-page {
|
||||
--gallery-spacing: 12px;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Create css_templates table
|
||||
const hasTable = await knex.schema.hasTable('css_templates');
|
||||
if (!hasTable) {
|
||||
await knex.schema.createTable('css_templates', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('slot_number').notNullable();
|
||||
table.string('name', 50).notNullable().defaultTo('Untitled');
|
||||
table.text('css_content').notNullable().defaultTo('');
|
||||
table.boolean('is_enabled').notNullable().defaultTo(false);
|
||||
table.boolean('is_default').notNullable().defaultTo(false);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.unique('slot_number');
|
||||
});
|
||||
|
||||
// Insert default templates
|
||||
await knex('css_templates').insert([
|
||||
{
|
||||
slot_number: 1,
|
||||
name: 'Elegant Dark',
|
||||
css_content: DEFAULT_CSS_TEMPLATE,
|
||||
is_enabled: true,
|
||||
is_default: true
|
||||
},
|
||||
{
|
||||
slot_number: 2,
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false
|
||||
},
|
||||
{
|
||||
slot_number: 3,
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Add css_template_id to events table
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove css_template_id from events table
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('css_template_id');
|
||||
});
|
||||
}
|
||||
|
||||
// Drop css_templates table
|
||||
await knex.schema.dropTableIfExists('css_templates');
|
||||
};
|
||||
|
||||
// Export default template for use in reset functionality
|
||||
module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE;
|
||||
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* Migration: Add Liquid Glass CSS Templates
|
||||
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
|
||||
*
|
||||
* These are starter example templates for new installations.
|
||||
* Users can edit or replace them as needed.
|
||||
*/
|
||||
|
||||
const APPLE_LIQUID_GLASS = `/*
|
||||
* PicPeak Custom CSS Template: Apple Liquid Glass
|
||||
* Authentic iOS 26 / macOS Tahoe Liquid Glass Design
|
||||
*/
|
||||
|
||||
/* ===== Apple System Fonts ===== */
|
||||
.gallery-page,
|
||||
.gallery-page *,
|
||||
.gallery-sidebar,
|
||||
.gallery-sidebar * {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== CSS Variables ===== */
|
||||
:root {
|
||||
--glass-blur: 20px;
|
||||
--glass-blur-heavy: 40px;
|
||||
--glass-saturation: 180%;
|
||||
--glass-bg: rgba(255, 255, 255, 0.08);
|
||||
--glass-bg-medium: rgba(255, 255, 255, 0.18);
|
||||
--glass-bg-solid: rgba(255, 255, 255, 0.25);
|
||||
--glass-border: rgba(255, 255, 255, 0.2);
|
||||
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
|
||||
--glass-inset: inset 0 1px 1px rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
|
||||
--gallery-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
--gallery-text: #1a1a2e;
|
||||
--gallery-text-light: #ffffff;
|
||||
--gallery-accent: #667eea;
|
||||
--gallery-radius: 20px;
|
||||
--gallery-radius-sm: 12px;
|
||||
}
|
||||
|
||||
/* ===== Page Background ===== */
|
||||
.gallery-page {
|
||||
background: var(--gallery-gradient) !important;
|
||||
background-attachment: fixed !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gallery-page::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 600px 400px at 15% 85%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 500px 350px at 85% 15%, rgba(255, 255, 255, 0.15) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== TOP BAR / HEADER - Liquid Glass ===== */
|
||||
.gallery-page .gallery-header,
|
||||
.gallery-page header {
|
||||
background: var(--glass-bg-medium) !important;
|
||||
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
|
||||
border-bottom: 1px solid var(--glass-border) !important;
|
||||
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
|
||||
}
|
||||
|
||||
.gallery-page .gallery-header > div {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR - Liquid Glass ===== */
|
||||
.gallery-sidebar {
|
||||
background: var(--glass-bg-medium) !important;
|
||||
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
|
||||
border-right: 1px solid var(--glass-border) !important;
|
||||
box-shadow: 4px 0 32px rgba(31, 38, 135, 0.1), var(--glass-inset) !important;
|
||||
}
|
||||
|
||||
.gallery-sidebar h2,
|
||||
.gallery-sidebar h3 {
|
||||
color: var(--gallery-text) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* ===== HERO LAYOUT - Transform to Glass Title Box ===== */
|
||||
/* Target the hero wrapper */
|
||||
.gallery-page .relative.-mt-6 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
/* Target the hero section (first child with h-[60vh]) */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child {
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 2rem !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Hide the hero background image */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > img,
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide the dark overlay */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.bg-black {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Style the content area as glass title box */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
background: var(--glass-bg-medium) !important;
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
border: 1px solid var(--glass-border-light) !important;
|
||||
border-radius: var(--gallery-radius) !important;
|
||||
padding: 2rem 3rem !important;
|
||||
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
|
||||
max-width: 600px !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* Hide logo in glass title box */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child .mb-6 {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Style title text */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
|
||||
color: var(--gallery-text) !important;
|
||||
text-shadow: none !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 2.25rem !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Style date text */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child .text-white\\/90 {
|
||||
color: var(--gallery-text) !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Hide scroll down button */
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.bottom-8,
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > button.absolute {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== PHOTO GRID ===== */
|
||||
.gallery-page .photo-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
|
||||
gap: 1.25rem !important;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
/* ===== PHOTO CARDS - Liquid Glass ===== */
|
||||
.gallery-page .photo-card {
|
||||
background: var(--glass-bg) !important;
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
border-radius: var(--gallery-radius) !important;
|
||||
overflow: hidden !important;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), var(--glass-inset) !important;
|
||||
}
|
||||
|
||||
.gallery-page .photo-card:hover {
|
||||
transform: translateY(-6px) scale(1.02) !important;
|
||||
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.25),
|
||||
0 8px 16px rgba(0, 0, 0, 0.1),
|
||||
var(--glass-inset) !important;
|
||||
border-color: var(--glass-border-light) !important;
|
||||
}
|
||||
|
||||
.gallery-page .photo-card img {
|
||||
transition: transform 0.4s ease !important;
|
||||
}
|
||||
|
||||
.gallery-page .photo-card:hover img {
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
/* ===== BUTTONS - Glass Pill Style ===== */
|
||||
.gallery-page button,
|
||||
.gallery-page [role="button"],
|
||||
.gallery-sidebar button {
|
||||
background: var(--glass-bg) !important;
|
||||
backdrop-filter: blur(12px) saturate(150%) !important;
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
border-radius: 9999px !important;
|
||||
color: var(--gallery-text) !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.gallery-page button:hover,
|
||||
.gallery-page [role="button"]:hover,
|
||||
.gallery-sidebar button:hover {
|
||||
background: var(--glass-bg-medium) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
|
||||
.gallery-page button[class*="bg-primary"],
|
||||
.gallery-page .gallery-btn-download {
|
||||
background: linear-gradient(135deg, var(--gallery-accent) 0%, #764ba2 100%) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* ===== INPUT FIELDS ===== */
|
||||
.gallery-page input,
|
||||
.gallery-page select,
|
||||
.gallery-sidebar input,
|
||||
.gallery-sidebar select {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
-webkit-backdrop-filter: blur(8px) !important;
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
border-radius: var(--gallery-radius-sm) !important;
|
||||
color: var(--gallery-text) !important;
|
||||
}
|
||||
|
||||
/* Input placeholder text - make it visible */
|
||||
.gallery-page input::placeholder,
|
||||
.gallery-sidebar input::placeholder {
|
||||
color: rgba(26, 26, 46, 0.6) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Input focus state */
|
||||
.gallery-page input:focus,
|
||||
.gallery-sidebar input:focus {
|
||||
background: rgba(255, 255, 255, 0.35) !important;
|
||||
border-color: var(--glass-border-light) !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
.gallery-page .gallery-footer,
|
||||
.gallery-page footer {
|
||||
background: var(--glass-bg) !important;
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
|
||||
border-top: 1px solid var(--glass-border) !important;
|
||||
}
|
||||
|
||||
/* ===== LIGHTBOX ===== */
|
||||
.gallery-page [class*="fixed"][class*="inset-0"][class*="z-50"] {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
backdrop-filter: blur(30px) !important;
|
||||
-webkit-backdrop-filter: blur(30px) !important;
|
||||
}
|
||||
|
||||
/* ===== SCROLLBAR ===== */
|
||||
.gallery-page ::-webkit-scrollbar,
|
||||
.gallery-sidebar ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar-track,
|
||||
.gallery-sidebar ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar-thumb,
|
||||
.gallery-sidebar ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--gallery-radius: 16px;
|
||||
--glass-blur: 16px;
|
||||
}
|
||||
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
|
||||
padding: 1.5rem 2rem !important;
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.gallery-page .photo-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ACCESSIBILITY ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gallery-page .photo-card,
|
||||
.gallery-page button {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.gallery-page .photo-card:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.gallery-page .photo-card,
|
||||
.gallery-page button,
|
||||
.gallery-sidebar {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
}`;
|
||||
|
||||
const LIQUID_GLASS_DARK = `/*
|
||||
* PicPeak Custom CSS Template: Liquid Glass Dark
|
||||
* Inspired by Apple's iOS 26 Liquid Glass Design Language
|
||||
*
|
||||
* Features:
|
||||
* - Deep translucent dark surfaces
|
||||
* - Neon accent highlights
|
||||
* - Dramatic glass reflections
|
||||
* - Subtle animated gradients
|
||||
*/
|
||||
|
||||
/* ===== Base Theme Variables ===== */
|
||||
.gallery-page {
|
||||
--glass-bg: rgba(15, 15, 35, 0.7);
|
||||
--glass-bg-elevated: rgba(25, 25, 55, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-border-highlight: rgba(255, 255, 255, 0.2);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--glass-blur: 24px;
|
||||
--glass-saturation: 150%;
|
||||
|
||||
--gallery-bg: #0a0a1a;
|
||||
--gallery-text: #f0f0f5;
|
||||
--gallery-text-muted: rgba(240, 240, 245, 0.6);
|
||||
--gallery-accent: #00d4ff;
|
||||
--gallery-accent-secondary: #ff00e5;
|
||||
--gallery-accent-hover: #00ffea;
|
||||
--gallery-radius: 20px;
|
||||
--gallery-spacing: 20px;
|
||||
|
||||
/* Neon glow variables */
|
||||
--neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2);
|
||||
--neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2);
|
||||
}
|
||||
|
||||
/* ===== Page Background ===== */
|
||||
.gallery-page {
|
||||
background: var(--gallery-bg);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated mesh gradient background */
|
||||
.gallery-page::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
bottom: -50%;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%);
|
||||
animation: gradientShift 20s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
25% { transform: translate(2%, 2%) rotate(1deg); }
|
||||
50% { transform: translate(-1%, 3%) rotate(-1deg); }
|
||||
75% { transform: translate(3%, -2%) rotate(2deg); }
|
||||
}
|
||||
|
||||
/* ===== Gallery Header ===== */
|
||||
.gallery-header {
|
||||
background: var(--glass-bg-elevated);
|
||||
backdrop-filter: blur(30px) saturate(var(--glass-saturation));
|
||||
-webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation));
|
||||
border-bottom: 1px solid var(--glass-border-highlight);
|
||||
padding: calc(var(--gallery-spacing) * 1.5);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
color: var(--gallery-text);
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ===== Photo Grid ===== */
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--gallery-spacing);
|
||||
padding: calc(var(--gallery-spacing) * 2);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ===== Photo Cards - Dark Glass Style ===== */
|
||||
.photo-card {
|
||||
position: relative;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--gallery-radius);
|
||||
overflow: hidden;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Top highlight reflection */
|
||||
.photo-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Inner glow effect */
|
||||
.photo-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--gallery-radius);
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 212, 255, 0) 0%,
|
||||
rgba(0, 212, 255, 0) 40%,
|
||||
rgba(0, 212, 255, 0.1) 100%
|
||||
);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
border-color: var(--glass-border-highlight);
|
||||
box-shadow:
|
||||
0 24px 48px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(0, 212, 255, 0.2),
|
||||
var(--neon-glow);
|
||||
}
|
||||
|
||||
.photo-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease, filter 0.4s ease;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.photo-card:hover img {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.photo-card-info {
|
||||
padding: var(--gallery-spacing);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0.2) 0%,
|
||||
rgba(0, 0, 0, 0.4) 100%
|
||||
);
|
||||
color: var(--gallery-text);
|
||||
}
|
||||
|
||||
.photo-card-info p {
|
||||
color: var(--gallery-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ===== Buttons - Neon Glass Style ===== */
|
||||
.gallery-btn {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: calc(var(--gallery-radius) / 2);
|
||||
padding: 12px 24px;
|
||||
color: var(--gallery-text);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gallery-btn:hover {
|
||||
border-color: var(--gallery-accent);
|
||||
box-shadow: var(--neon-glow);
|
||||
color: var(--gallery-accent);
|
||||
}
|
||||
|
||||
.gallery-btn-primary {
|
||||
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
.gallery-btn-primary:hover {
|
||||
box-shadow:
|
||||
0 0 30px rgba(0, 212, 255, 0.6),
|
||||
0 0 60px rgba(0, 212, 255, 0.3),
|
||||
0 0 90px rgba(255, 0, 229, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== Lightbox - Dark Glass ===== */
|
||||
.lightbox-overlay {
|
||||
background: rgba(5, 5, 15, 0.9);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
}
|
||||
|
||||
.lightbox-content {
|
||||
background: var(--glass-bg-elevated);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border-highlight);
|
||||
border-radius: var(--gallery-radius);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.5),
|
||||
var(--neon-glow);
|
||||
}
|
||||
|
||||
/* ===== Category Pills ===== */
|
||||
.category-pill {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 9999px;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--gallery-text-muted);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
border-color: var(--gallery-accent);
|
||||
color: var(--gallery-accent);
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
/* ===== Scrollbar Styling ===== */
|
||||
.gallery-page ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar-track {
|
||||
background: var(--glass-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gallery-page ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-page {
|
||||
--gallery-radius: 16px;
|
||||
--gallery-spacing: 12px;
|
||||
--glass-blur: 16px;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.photo-card img {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
/* Reduce animation complexity on mobile */
|
||||
.gallery-page::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Accessibility: Reduce Motion ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gallery-page::before {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.photo-card,
|
||||
.gallery-btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Accessibility: Reduce Transparency ===== */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.photo-card,
|
||||
.gallery-btn,
|
||||
.gallery-header {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.gallery-page {
|
||||
--glass-bg: rgba(20, 20, 40, 0.98);
|
||||
--glass-bg-elevated: rgba(30, 30, 60, 0.98);
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Update template slot 2 with Apple Liquid Glass (Light)
|
||||
await knex('css_templates')
|
||||
.where({ slot_number: 2 })
|
||||
.update({
|
||||
name: 'Apple Liquid Glass',
|
||||
css_content: APPLE_LIQUID_GLASS,
|
||||
is_enabled: true,
|
||||
is_default: false,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
|
||||
// Update template slot 3 with Liquid Glass Dark
|
||||
await knex('css_templates')
|
||||
.where({ slot_number: 3 })
|
||||
.update({
|
||||
name: 'Liquid Glass Dark',
|
||||
css_content: LIQUID_GLASS_DARK,
|
||||
is_enabled: true,
|
||||
is_default: false,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to empty templates
|
||||
await knex('css_templates')
|
||||
.where({ slot_number: 2 })
|
||||
.update({
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
|
||||
await knex('css_templates')
|
||||
.where({ slot_number: 3 })
|
||||
.update({
|
||||
name: 'Untitled',
|
||||
css_content: '',
|
||||
is_enabled: false,
|
||||
is_default: false,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
};
|
||||
|
||||
// Export templates for use elsewhere
|
||||
module.exports.APPLE_LIQUID_GLASS = APPLE_LIQUID_GLASS;
|
||||
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Migration: Add Roles Table
|
||||
* Creates the roles table for RBAC multi-administrator support.
|
||||
*
|
||||
* Default roles:
|
||||
* - super_admin (priority 100): Full system access including user management
|
||||
* - admin (priority 80): Full event and photo management
|
||||
* - editor (priority 50): Can edit events and photos but not create or delete
|
||||
* - viewer (priority 20): Read-only access to dashboard and events
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating roles table...');
|
||||
|
||||
// Check if table already exists
|
||||
const hasRolesTable = await knex.schema.hasTable('roles');
|
||||
|
||||
if (!hasRolesTable) {
|
||||
await knex.schema.createTable('roles', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
|
||||
table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
|
||||
table.text('description');
|
||||
table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
|
||||
table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Index for name lookups
|
||||
table.index(['name']);
|
||||
// Index for priority-based ordering
|
||||
table.index(['priority']);
|
||||
});
|
||||
|
||||
console.log('Roles table created');
|
||||
}
|
||||
|
||||
// Insert default system roles
|
||||
const existingRoles = await knex('roles').select('name');
|
||||
const existingRoleNames = existingRoles.map(r => r.name);
|
||||
|
||||
const defaultRoles = [
|
||||
{
|
||||
name: 'super_admin',
|
||||
display_name: 'Super Admin',
|
||||
description: 'Full system access including user management',
|
||||
is_system: true,
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
display_name: 'Admin',
|
||||
description: 'Full event and photo management',
|
||||
is_system: true,
|
||||
priority: 80
|
||||
},
|
||||
{
|
||||
name: 'editor',
|
||||
display_name: 'Editor',
|
||||
description: 'Can edit events and photos but not create or delete',
|
||||
is_system: true,
|
||||
priority: 50
|
||||
},
|
||||
{
|
||||
name: 'viewer',
|
||||
display_name: 'Viewer',
|
||||
description: 'Read-only access to dashboard and events',
|
||||
is_system: true,
|
||||
priority: 20
|
||||
}
|
||||
];
|
||||
|
||||
const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
|
||||
|
||||
if (rolesToInsert.length > 0) {
|
||||
await knex('roles').insert(rolesToInsert);
|
||||
console.log(`Inserted ${rolesToInsert.length} default roles`);
|
||||
}
|
||||
|
||||
console.log('Roles table migration completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing roles table...');
|
||||
|
||||
// Note: This will fail if there are foreign key references
|
||||
// The role_permissions and admin_users tables must be rolled back first
|
||||
await knex.schema.dropTableIfExists('roles');
|
||||
|
||||
console.log('Roles table removed');
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Migration: Add Permissions Table
|
||||
* Creates the permissions table for granular access control.
|
||||
*
|
||||
* Permission categories:
|
||||
* - events: View, create, edit, delete, archive events
|
||||
* - photos: View, upload, edit, delete, download photos
|
||||
* - archives: View, restore, download, delete archives
|
||||
* - analytics: View analytics and statistics
|
||||
* - email: View, edit, send emails
|
||||
* - branding: View and edit branding settings
|
||||
* - cms: View and edit CMS pages
|
||||
* - settings: View and edit application settings
|
||||
* - backup: View, create, restore, delete backups
|
||||
* - users: View, create, edit, delete admin users (Super Admin only)
|
||||
* - activity: View and export activity logs
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating permissions table...');
|
||||
|
||||
// Check if table already exists
|
||||
const hasPermissionsTable = await knex.schema.hasTable('permissions');
|
||||
|
||||
if (!hasPermissionsTable) {
|
||||
await knex.schema.createTable('permissions', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
|
||||
table.string('display_name', 150).notNullable();
|
||||
table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
|
||||
table.text('description');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for efficient lookups
|
||||
table.index(['name']);
|
||||
table.index(['category']);
|
||||
});
|
||||
|
||||
console.log('Permissions table created');
|
||||
}
|
||||
|
||||
// Check for existing permissions
|
||||
const existingPermissions = await knex('permissions').select('name');
|
||||
const existingPermissionNames = existingPermissions.map(p => p.name);
|
||||
|
||||
// Define all permissions
|
||||
const permissions = [
|
||||
// Events
|
||||
{ name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
|
||||
{ name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
|
||||
{ name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
|
||||
{ name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
|
||||
{ name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
|
||||
|
||||
// Photos
|
||||
{ name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
|
||||
{ name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
|
||||
{ name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
|
||||
{ name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
|
||||
{ name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
|
||||
|
||||
// Archives
|
||||
{ name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
|
||||
{ name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
|
||||
{ name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
|
||||
{ name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
|
||||
|
||||
// Analytics
|
||||
{ name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
|
||||
|
||||
// Email
|
||||
{ name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
|
||||
{ name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
|
||||
{ name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
|
||||
|
||||
// Branding & CMS
|
||||
{ name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
|
||||
{ name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
|
||||
{ name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
|
||||
{ name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
|
||||
|
||||
// Settings
|
||||
{ name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
|
||||
{ name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
|
||||
|
||||
// Backup
|
||||
{ name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
|
||||
{ name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
|
||||
{ name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
|
||||
{ name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
|
||||
|
||||
// User Management (Super Admin only)
|
||||
{ name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
|
||||
{ name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
|
||||
{ name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
|
||||
{ name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
|
||||
|
||||
// Activity Logs
|
||||
{ name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
|
||||
{ name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
|
||||
];
|
||||
|
||||
// Filter out already existing permissions
|
||||
const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
|
||||
|
||||
if (permissionsToInsert.length > 0) {
|
||||
await knex('permissions').insert(permissionsToInsert);
|
||||
console.log(`Inserted ${permissionsToInsert.length} permissions`);
|
||||
}
|
||||
|
||||
console.log('Permissions table migration completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing permissions table...');
|
||||
|
||||
// Note: This will fail if there are foreign key references
|
||||
// The role_permissions table must be rolled back first
|
||||
await knex.schema.dropTableIfExists('permissions');
|
||||
|
||||
console.log('Permissions table removed');
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Migration: Add Role Permissions Junction Table
|
||||
* Creates the junction table mapping permissions to roles.
|
||||
*
|
||||
* Role permission mappings:
|
||||
* - super_admin: All permissions
|
||||
* - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
|
||||
* - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
|
||||
* - viewer: View-only access to events, photos, archives, analytics, branding, cms
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating role_permissions junction table...');
|
||||
|
||||
// Check if table already exists
|
||||
const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
|
||||
|
||||
if (!hasRolePermissionsTable) {
|
||||
await knex.schema.createTable('role_permissions', (table) => {
|
||||
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
|
||||
table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
|
||||
table.primary(['role_id', 'permission_id']);
|
||||
|
||||
// Indexes for efficient lookups
|
||||
table.index(['role_id']);
|
||||
table.index(['permission_id']);
|
||||
});
|
||||
|
||||
console.log('Role permissions junction table created');
|
||||
}
|
||||
|
||||
// Get role and permission IDs
|
||||
const roles = await knex('roles').select('id', 'name');
|
||||
const permissions = await knex('permissions').select('id', 'name');
|
||||
|
||||
if (roles.length === 0 || permissions.length === 0) {
|
||||
console.log('No roles or permissions found, skipping permission mappings');
|
||||
return;
|
||||
}
|
||||
|
||||
const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
|
||||
const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
|
||||
|
||||
// Define role-permission mappings
|
||||
const rolePermissions = {
|
||||
super_admin: permissions.map(p => p.name), // All permissions
|
||||
admin: [
|
||||
// Events - full access
|
||||
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
|
||||
// Photos - full access
|
||||
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
|
||||
// Archives - full access
|
||||
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
|
||||
// Analytics - view only
|
||||
'analytics.view',
|
||||
// Email - full access
|
||||
'email.view', 'email.edit', 'email.send',
|
||||
// Branding - full access
|
||||
'branding.view', 'branding.edit',
|
||||
// CMS - full access
|
||||
'cms.view', 'cms.edit',
|
||||
// Settings - view only
|
||||
'settings.view',
|
||||
// Backup - view and create only
|
||||
'backup.view', 'backup.create',
|
||||
// Activity - view only
|
||||
'activity.view'
|
||||
],
|
||||
editor: [
|
||||
// Events - view, create, and edit (can only see their own events)
|
||||
'events.view', 'events.create', 'events.edit',
|
||||
// Photos - view, upload, edit (no delete)
|
||||
'photos.view', 'photos.upload', 'photos.edit',
|
||||
// Analytics - view only
|
||||
'analytics.view',
|
||||
// Activity - view only
|
||||
'activity.view'
|
||||
],
|
||||
viewer: [
|
||||
// Events - view only
|
||||
'events.view',
|
||||
// Photos - view only
|
||||
'photos.view',
|
||||
// Archives - view only
|
||||
'archives.view',
|
||||
// Analytics - view only
|
||||
'analytics.view',
|
||||
// Branding - view only
|
||||
'branding.view',
|
||||
// CMS - view only
|
||||
'cms.view'
|
||||
]
|
||||
};
|
||||
|
||||
// Check for existing mappings to avoid duplicates
|
||||
const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
|
||||
const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
|
||||
|
||||
// Build insert list
|
||||
const inserts = [];
|
||||
for (const [roleName, perms] of Object.entries(rolePermissions)) {
|
||||
for (const permName of perms) {
|
||||
if (roleMap[roleName] && permMap[permName]) {
|
||||
const key = `${roleMap[roleName]}-${permMap[permName]}`;
|
||||
if (!existingSet.has(key)) {
|
||||
inserts.push({
|
||||
role_id: roleMap[roleName],
|
||||
permission_id: permMap[permName]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inserts.length > 0) {
|
||||
// Insert in batches to avoid hitting database limits
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < inserts.length; i += batchSize) {
|
||||
const batch = inserts.slice(i, i + batchSize);
|
||||
await knex('role_permissions').insert(batch);
|
||||
}
|
||||
console.log(`Inserted ${inserts.length} role-permission mappings`);
|
||||
}
|
||||
|
||||
console.log('Role permissions junction table migration completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing role_permissions junction table...');
|
||||
|
||||
await knex.schema.dropTableIfExists('role_permissions');
|
||||
|
||||
console.log('Role permissions junction table removed');
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Migration: Add Role to Admin Users
|
||||
* Adds RBAC-related columns to the admin_users table:
|
||||
* - role_id: Foreign key to roles table
|
||||
* - created_by: Foreign key to admin_users (who invited this user)
|
||||
* - invite_token: Token for invitation acceptance (64 chars = 256 bits)
|
||||
* - invite_expires_at: When the invitation token expires
|
||||
* - invite_accepted_at: When the user accepted the invitation
|
||||
*
|
||||
* Also migrates existing admin users to super_admin role.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Adding role columns to admin_users table...');
|
||||
|
||||
// Check if columns already exist
|
||||
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
|
||||
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
|
||||
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
|
||||
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
|
||||
|
||||
// Add new columns if they don't exist
|
||||
if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
|
||||
await knex.schema.alterTable('admin_users', (table) => {
|
||||
if (!hasRoleId) {
|
||||
// Note: We add as nullable first, then set values, then alter to not null
|
||||
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
|
||||
}
|
||||
if (!hasCreatedBy) {
|
||||
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
}
|
||||
if (!hasInviteToken) {
|
||||
// 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
|
||||
table.string('invite_token', 64);
|
||||
}
|
||||
if (!hasInviteExpiresAt) {
|
||||
table.timestamp('invite_expires_at');
|
||||
}
|
||||
if (!hasInviteAcceptedAt) {
|
||||
table.timestamp('invite_accepted_at');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Role columns added to admin_users table');
|
||||
}
|
||||
|
||||
// Add index on invite_token for fast lookup
|
||||
const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||
if (hasInviteTokenIndex) {
|
||||
// Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
|
||||
try {
|
||||
await knex.schema.alterTable('admin_users', (table) => {
|
||||
table.index(['invite_token']);
|
||||
});
|
||||
} catch (e) {
|
||||
// Index may already exist
|
||||
if (!e.message.includes('already exists')) {
|
||||
console.log('Note: invite_token index may already exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get super_admin role ID
|
||||
const superAdminRole = await knex('roles').where('name', 'super_admin').first();
|
||||
|
||||
if (superAdminRole) {
|
||||
// Migrate existing admin users without a role to super_admin
|
||||
const usersWithoutRole = await knex('admin_users')
|
||||
.whereNull('role_id')
|
||||
.select('id');
|
||||
|
||||
if (usersWithoutRole.length > 0) {
|
||||
await knex('admin_users')
|
||||
.whereNull('role_id')
|
||||
.update({ role_id: superAdminRole.id });
|
||||
|
||||
console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
|
||||
}
|
||||
} else {
|
||||
console.log('Warning: super_admin role not found. Run migration 054 first.');
|
||||
}
|
||||
|
||||
console.log('Admin users role migration completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing role columns from admin_users table...');
|
||||
|
||||
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
|
||||
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
|
||||
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
|
||||
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (table) => {
|
||||
if (hasInviteAcceptedAt) {
|
||||
table.dropColumn('invite_accepted_at');
|
||||
}
|
||||
if (hasInviteExpiresAt) {
|
||||
table.dropColumn('invite_expires_at');
|
||||
}
|
||||
if (hasInviteToken) {
|
||||
table.dropColumn('invite_token');
|
||||
}
|
||||
if (hasCreatedBy) {
|
||||
table.dropColumn('created_by');
|
||||
}
|
||||
if (hasRoleId) {
|
||||
table.dropColumn('role_id');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Role columns removed from admin_users table');
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Migration: Add Admin Invitations Table
|
||||
* Creates the admin_invitations table for managing pending admin user invitations.
|
||||
*
|
||||
* Security features:
|
||||
* - Token is 64 characters (32 bytes hex = 256 bits of entropy)
|
||||
* - Tokens are unique and indexed for fast lookup
|
||||
* - Invitations have expiration timestamps
|
||||
* - Tracks who invited whom and when accepted
|
||||
* - Foreign key constraints with appropriate CASCADE behavior
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating admin_invitations table...');
|
||||
|
||||
// Check if table already exists
|
||||
const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
|
||||
|
||||
if (!hasAdminInvitationsTable) {
|
||||
await knex.schema.createTable('admin_invitations', (table) => {
|
||||
table.increments('id').primary();
|
||||
|
||||
// Email of the invited user
|
||||
table.string('email', 255).notNullable();
|
||||
|
||||
// Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
|
||||
// Cryptographically secure for one-time use tokens
|
||||
table.string('token', 64).unique().notNullable();
|
||||
|
||||
// Role to assign when invitation is accepted
|
||||
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
|
||||
|
||||
// Who created this invitation
|
||||
table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
|
||||
|
||||
// When the invitation expires (typically 7 days from creation)
|
||||
table.timestamp('expires_at').notNullable();
|
||||
|
||||
// When the invitation was accepted (null if pending)
|
||||
table.timestamp('accepted_at');
|
||||
|
||||
// The admin_user ID created when invitation was accepted (for audit trail)
|
||||
table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
|
||||
// When the invitation was created
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for efficient lookups
|
||||
table.index(['token']); // Fast token validation
|
||||
table.index(['email']); // Check for existing invitations by email
|
||||
table.index(['expires_at']); // Cleanup expired invitations
|
||||
table.index(['invited_by']); // List invitations by inviter
|
||||
table.index(['accepted_at']); // Filter pending vs accepted
|
||||
});
|
||||
|
||||
console.log('Admin invitations table created');
|
||||
}
|
||||
|
||||
console.log('Admin invitations table migration completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Removing admin_invitations table...');
|
||||
|
||||
await knex.schema.dropTableIfExists('admin_invitations');
|
||||
|
||||
console.log('Admin invitations table removed');
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Migration to add email templates for admin invitation and password reset
|
||||
* These templates support the RBAC (Role-Based Access Control) feature
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// First, ensure the email_templates table has multilingual columns
|
||||
// This is needed for fresh installations where legacy migrations don't run
|
||||
const columnInfo = await knex('email_templates').columnInfo();
|
||||
|
||||
if (!columnInfo.subject_en) {
|
||||
// Need to add multilingual columns
|
||||
console.log('Adding multilingual columns to email_templates table...');
|
||||
|
||||
// Check if we're using SQLite or PostgreSQL
|
||||
const client = knex.client.config.client;
|
||||
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
|
||||
|
||||
if (isSqlite) {
|
||||
// SQLite doesn't support column rename directly in all versions
|
||||
// We need to recreate the table with new structure
|
||||
|
||||
// Get existing data
|
||||
const existingData = await knex('email_templates').select('*');
|
||||
|
||||
// Drop the old table
|
||||
await knex.schema.dropTable('email_templates');
|
||||
|
||||
// Create new table with multilingual columns
|
||||
await knex.schema.createTable('email_templates', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('template_key').unique().notNullable();
|
||||
table.string('subject_en');
|
||||
table.string('subject_de');
|
||||
table.text('body_html_en');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_en');
|
||||
table.text('body_text_de');
|
||||
table.json('variables');
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Re-insert existing data with column mapping
|
||||
for (const row of existingData) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: row.template_key,
|
||||
subject_en: row.subject,
|
||||
subject_de: row.subject, // Copy to German as default
|
||||
body_html_en: row.body_html,
|
||||
body_html_de: row.body_html,
|
||||
body_text_en: row.body_text,
|
||||
body_text_de: row.body_text,
|
||||
variables: row.variables,
|
||||
updated_at: row.updated_at
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Migrated email_templates table to multilingual structure');
|
||||
} else {
|
||||
// PostgreSQL supports ALTER TABLE for column operations
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
|
||||
// Copy English values to German as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check which templates already exist
|
||||
const existingTemplates = await knex('email_templates')
|
||||
.select('template_key')
|
||||
.whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
|
||||
|
||||
const existingKeys = existingTemplates.map(t => t.template_key);
|
||||
|
||||
// Admin Invitation Email Template
|
||||
if (!existingKeys.includes('admin_invitation')) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'admin_invitation',
|
||||
subject_en: 'You have been invited to join PicPeak as {{role_name}}',
|
||||
subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
|
||||
body_html_en: `
|
||||
<h2>Welcome to PicPeak!</h2>
|
||||
|
||||
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
|
||||
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
|
||||
</div>
|
||||
|
||||
<p>To accept this invitation and set up your account, click the button below:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
|
||||
</div>
|
||||
|
||||
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
|
||||
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">
|
||||
If the button above does not work, copy and paste this link into your browser:<br>
|
||||
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
|
||||
</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The PicPeak Team</p>`,
|
||||
body_text_en: `Welcome to PicPeak!
|
||||
|
||||
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
|
||||
|
||||
Your Role: {{role_name}}
|
||||
This role grants you access to manage and administer the photo sharing platform.
|
||||
|
||||
To accept this invitation and set up your account, visit the following link:
|
||||
{{invite_link}}
|
||||
|
||||
IMPORTANT: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
|
||||
|
||||
If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
The PicPeak Team`,
|
||||
body_html_de: `
|
||||
<h2>Willkommen bei PicPeak!</h2>
|
||||
|
||||
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
|
||||
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
|
||||
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
|
||||
</div>
|
||||
|
||||
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
|
||||
</div>
|
||||
|
||||
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
|
||||
|
||||
<p style="color: #666; font-size: 13px; margin-top: 30px;">
|
||||
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
|
||||
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
|
||||
</p>
|
||||
|
||||
<p>Mit freundlichen Grussen,<br>
|
||||
Ihr PicPeak-Team</p>`,
|
||||
body_text_de: `Willkommen bei PicPeak!
|
||||
|
||||
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
|
||||
|
||||
Ihre Rolle: {{role_name}}
|
||||
Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
|
||||
|
||||
Um diese Einladung anzunehmen und Ihr Konto einzurichten, besuchen Sie den folgenden Link:
|
||||
{{invite_link}}
|
||||
|
||||
WICHTIG: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
|
||||
|
||||
Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
|
||||
|
||||
Mit freundlichen Grussen,
|
||||
Ihr PicPeak-Team`,
|
||||
variables: JSON.stringify(['invite_link', 'role_name', 'expires_at'])
|
||||
});
|
||||
}
|
||||
|
||||
// Admin Password Reset Email Template
|
||||
if (!existingKeys.includes('admin_password_reset')) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: 'admin_password_reset',
|
||||
subject_en: 'Your PicPeak administrator password has been reset',
|
||||
subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt',
|
||||
body_html_en: `
|
||||
<h2>Password Reset Notification</h2>
|
||||
|
||||
<p>Hello <strong>{{username}}</strong>,</p>
|
||||
|
||||
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
|
||||
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>This is a temporary password. Please change it immediately after logging in.</li>
|
||||
<li>Never share your password with anyone.</li>
|
||||
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>To log in to the admin panel, click the button below:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The PicPeak Team</p>`,
|
||||
body_text_en: `Password Reset Notification
|
||||
|
||||
Hello {{username}},
|
||||
|
||||
Your administrator password for PicPeak has been reset by a system administrator.
|
||||
|
||||
Your New Login Credentials:
|
||||
- Username: {{username}}
|
||||
- Temporary Password: {{new_password}}
|
||||
|
||||
SECURITY NOTICE:
|
||||
- This is a temporary password. Please change it immediately after logging in.
|
||||
- Never share your password with anyone.
|
||||
- If you did not request this password reset, please contact your system administrator immediately.
|
||||
|
||||
To log in to the admin panel, visit: {{admin_login_url}}
|
||||
|
||||
After logging in, navigate to your profile settings to change your password to something secure that only you know.
|
||||
|
||||
Best regards,
|
||||
The PicPeak Team`,
|
||||
body_html_de: `
|
||||
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
|
||||
|
||||
<p>Hallo <strong>{{username}}</strong>,</p>
|
||||
|
||||
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
|
||||
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
|
||||
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
|
||||
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
|
||||
|
||||
<p>Mit freundlichen Grussen,<br>
|
||||
Ihr PicPeak-Team</p>`,
|
||||
body_text_de: `Benachrichtigung uber Passwortzurucksetzung
|
||||
|
||||
Hallo {{username}},
|
||||
|
||||
Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
|
||||
|
||||
Ihre neuen Anmeldedaten:
|
||||
- Benutzername: {{username}}
|
||||
- Vorlaufiges Passwort: {{new_password}}
|
||||
|
||||
SICHERHEITSHINWEIS:
|
||||
- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.
|
||||
- Teilen Sie Ihr Passwort niemals mit anderen.
|
||||
- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.
|
||||
|
||||
Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}}
|
||||
|
||||
Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
|
||||
|
||||
Mit freundlichen Grussen,
|
||||
Ihr PicPeak-Team`,
|
||||
variables: JSON.stringify(['username', 'new_password', 'admin_login_url'])
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove the admin email templates
|
||||
await knex('email_templates')
|
||||
.whereIn('template_key', ['admin_invitation', 'admin_password_reset'])
|
||||
.delete();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Migration: Add created_by column to events table
|
||||
* This allows filtering events by owner for role-based access control
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add created_by column to events table
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
// Set existing events to be owned by the first admin (super_admin)
|
||||
const superAdmin = await knex('admin_users').where('role_id', 1).first();
|
||||
if (superAdmin) {
|
||||
await knex('events').update({ created_by: superAdmin.id });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('created_by');
|
||||
});
|
||||
};
|
||||
@@ -1,23 +1,56 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add user upload settings to events table
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
// Add user upload settings to events table (check if columns exist first)
|
||||
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
|
||||
if (!hasAllowUserUploads) {
|
||||
console.log('Adding allow_user_uploads column to events table...');
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
});
|
||||
} else {
|
||||
console.log('Column allow_user_uploads already exists in events table, skipping...');
|
||||
}
|
||||
|
||||
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
|
||||
if (!hasUploadCategoryId) {
|
||||
console.log('Adding upload_category_id column to events table...');
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
|
||||
});
|
||||
} else {
|
||||
console.log('Column upload_category_id already exists in events table, skipping...');
|
||||
}
|
||||
|
||||
// Add uploaded_by field to photos table to track who uploaded
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
|
||||
});
|
||||
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
|
||||
if (!hasUploadedBy) {
|
||||
console.log('Adding uploaded_by column to photos table...');
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
|
||||
});
|
||||
} else {
|
||||
console.log('Column uploaded_by already exists in photos table, skipping...');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('allow_user_uploads');
|
||||
table.dropColumn('upload_category_id');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.dropColumn('uploaded_by');
|
||||
});
|
||||
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
|
||||
if (hasAllowUserUploads) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('allow_user_uploads');
|
||||
});
|
||||
}
|
||||
|
||||
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
|
||||
if (hasUploadCategoryId) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('upload_category_id');
|
||||
});
|
||||
}
|
||||
|
||||
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
|
||||
if (hasUploadedBy) {
|
||||
await knex.schema.alterTable('photos', function(table) {
|
||||
table.dropColumn('uploaded_by');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -120,7 +120,9 @@ async function runMigrations() {
|
||||
|
||||
// Check if this is a new deployment
|
||||
// It's new if no essential tables exist OR no migrations have been applied
|
||||
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
|
||||
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
|
||||
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
|
||||
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
|
||||
|
||||
// Only detect existing schema for truly existing deployments
|
||||
if (!isNewDeployment) {
|
||||
@@ -227,4 +229,4 @@ if (require.main === module) {
|
||||
waitAndRun();
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
module.exports = { runMigrations };
|
||||
|
||||
Generated
+2363
-1382
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.109",
|
||||
"version": "2.3.2",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -15,32 +15,37 @@
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"joi": "^17.9.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -50,7 +55,16 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
async function pathExists(location) {
|
||||
try {
|
||||
await fsp.access(location);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFile(source, destination) {
|
||||
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await fsp.rename(source, destination);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
await fsp.copyFile(source, destination);
|
||||
await fsp.unlink(source);
|
||||
} else if (error.code === 'EEXIST') {
|
||||
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
const backendRoot = path.resolve(__dirname, '..');
|
||||
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||
|
||||
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = ['logos', 'favicons'];
|
||||
let migratedCounter = 0;
|
||||
|
||||
for (const category of categories) {
|
||||
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||
if (!fs.existsSync(legacyDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = path.join(targetUploadsRoot, category);
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(legacyDir, entry.name);
|
||||
const destinationPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (await pathExists(destinationPath)) {
|
||||
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await moveFile(sourcePath, destinationPath);
|
||||
migratedCounter += 1;
|
||||
}
|
||||
|
||||
const remaining = await fsp.readdir(legacyDir);
|
||||
if (remaining.length === 0) {
|
||||
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCounter === 0) {
|
||||
console.log('No legacy logo or favicon files needed migration.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||
}
|
||||
|
||||
migrate().catch((error) => {
|
||||
console.error('Migration failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Executable → Regular
Executable → Regular
+54
-22
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag) => args.includes(flag);
|
||||
const getOption = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
if (index !== -1 && index + 1 < args.length) {
|
||||
return args[index + 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||
const credentialsFileArg = getOption('credentials-file');
|
||||
const resolvedCredentialsFile = credentialsFileArg
|
||||
? path.resolve(process.cwd(), credentialsFileArg)
|
||||
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
|
||||
const rl = force ? null : readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function question(prompt) {
|
||||
async function ask(prompt) {
|
||||
if (force) {
|
||||
return 'yes';
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
||||
|
||||
console.log('Found admin user:', admin.username);
|
||||
console.log('Email:', admin.email);
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
|
||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
if (!force) {
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (!force) {
|
||||
const normalized = confirm.trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
||||
});
|
||||
|
||||
// Save to file
|
||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||
await fs.mkdir(credentialsDir, { recursive: true });
|
||||
|
||||
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||
const resetInfo = `
|
||||
========================================
|
||||
PicPeak Admin Password Reset
|
||||
PicPeak Admin Credentials
|
||||
========================================
|
||||
|
||||
Password has been reset for admin account:
|
||||
Your admin account has been reset with these credentials:
|
||||
|
||||
Username: admin
|
||||
New Password: ${newPassword}
|
||||
Username: ${admin.username}
|
||||
Email: ${admin.email}
|
||||
Password: ${newPassword}
|
||||
|
||||
IMPORTANT:
|
||||
1. You MUST change this password on next login
|
||||
IMPORTANT SECURITY NOTES:
|
||||
1. You MUST change this password after first login
|
||||
2. This file contains sensitive information
|
||||
3. Delete this file after noting the password
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
Login URL: ${adminUrl}
|
||||
|
||||
Reset performed on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
|
||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
||||
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||
|
||||
console.log('\n✅ Password reset successful!\n');
|
||||
console.log('========================================');
|
||||
console.log('New Credentials:');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Username: ${admin.username}`);
|
||||
console.log(`Email: ${admin.email}`);
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. You will be required to change this password on next login');
|
||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
||||
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||
console.log('3. Delete the file after noting the password');
|
||||
console.log('========================================\n');
|
||||
|
||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
||||
console.error('❌ Error resetting password:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
if (rl) {
|
||||
rl.close();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the reset
|
||||
resetAdminPassword();
|
||||
resetAdminPassword();
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to set/reset admin password
|
||||
*
|
||||
* Usage:
|
||||
* node set-admin-password.js <new-password>
|
||||
* node set-admin-password.js --env (uses ADMIN_PASSWORD environment variable)
|
||||
*
|
||||
* Security: Password must be at least 8 characters with mixed case, numbers, and special characters
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
@@ -16,24 +26,110 @@ const db = knex({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
function validatePassword(password) {
|
||||
if (!password || password.length < 8) {
|
||||
return { valid: false, error: 'Password must be at least 8 characters long' };
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { valid: false, error: 'Password must contain at least one lowercase letter' };
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { valid: false, error: 'Password must contain at least one uppercase letter' };
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
return { valid: false, error: 'Password must contain at least one number' };
|
||||
}
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
return { valid: false, error: 'Password must contain at least one special character' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node set-admin-password.js <new-password>
|
||||
node set-admin-password.js --env
|
||||
|
||||
Options:
|
||||
<new-password> The new password to set (must meet security requirements)
|
||||
--env Use ADMIN_PASSWORD environment variable
|
||||
|
||||
Security Requirements:
|
||||
- At least 8 characters
|
||||
- At least one lowercase letter
|
||||
- At least one uppercase letter
|
||||
- At least one number
|
||||
- At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)
|
||||
|
||||
Examples:
|
||||
node set-admin-password.js "MySecure@Pass123"
|
||||
ADMIN_PASSWORD="MySecure@Pass123" node set-admin-password.js --env
|
||||
`);
|
||||
}
|
||||
|
||||
async function setAdminPassword() {
|
||||
try {
|
||||
const password = 'admin123';
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await db('admin_users')
|
||||
// Get password from argument or environment
|
||||
const args = process.argv.slice(2);
|
||||
let password;
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error('❌ Error: No password provided\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args[0] === '--env') {
|
||||
password = process.env.ADMIN_PASSWORD;
|
||||
if (!password) {
|
||||
console.error('❌ Error: ADMIN_PASSWORD environment variable not set');
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (args[0] === '--help' || args[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
} else {
|
||||
password = args[0];
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const validation = validatePassword(password);
|
||||
if (!validation.valid) {
|
||||
console.error(`❌ Error: ${validation.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update database
|
||||
const updated = await db('admin_users')
|
||||
.where('username', 'admin')
|
||||
.update({
|
||||
password_hash: hashedPassword,
|
||||
password_changed_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
console.log('✅ Admin password set to: admin123');
|
||||
|
||||
if (updated === 0) {
|
||||
console.error('❌ Error: Admin user not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Admin password updated successfully');
|
||||
console.log(' Note: All existing sessions have been invalidated');
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting password:', error);
|
||||
console.error('❌ Error setting password:', error.message);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
setAdminPassword();
|
||||
setAdminPassword();
|
||||
|
||||
@@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) {
|
||||
.where('id', admin.id)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Password logging removed for security - check logs or database if needed
|
||||
console.log('Password: [NEWLY RESET - stored in database]');
|
||||
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
|
||||
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. Save this password securely - it will not be shown again');
|
||||
console.log('2. You will be required to change it on next login');
|
||||
} else {
|
||||
console.log('Password: [hidden - use --reset flag to generate new password]');
|
||||
}
|
||||
|
||||
+286
-38
@@ -12,6 +12,7 @@ logger.info('Server starting up', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
@@ -24,10 +25,17 @@ const { startBackupService } = require('./src/services/backupService');
|
||||
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
|
||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const {
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('./src/utils/tokenUtils');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth-enhanced');
|
||||
const authRoutes = require('./src/routes/auth');
|
||||
const eventRoutes = require('./src/routes/events');
|
||||
const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
@@ -42,25 +50,79 @@ const PORT = process.env.PORT || 3000;
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
|
||||
// Security middleware with custom CSP
|
||||
// In native HTTP installs, do NOT force HTTPS for subresources.
|
||||
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
||||
const cspDirectives = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
'https://www.google.com',
|
||||
'https://www.gstatic.com'
|
||||
],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
|
||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||
objectSrc: ["'none'"], // Disable plugins
|
||||
mediaSrc: ["'self'"], // Audio/video
|
||||
frameSrc: ["'self'", 'https://www.google.com'],
|
||||
};
|
||||
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
||||
if (enableHsts) {
|
||||
// In helmet, an empty array enables the directive
|
||||
cspDirectives.upgradeInsecureRequests = [];
|
||||
}
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const path = req.path || '';
|
||||
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||
const adminToken = getAdminTokenFromRequest(req);
|
||||
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
|
||||
const isGalleryRequest = Boolean(slugMatch)
|
||||
|| path.startsWith('/api/gallery')
|
||||
|| path.startsWith('/gallery')
|
||||
|| path.startsWith('/api/secure-images');
|
||||
|
||||
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
|
||||
if (isAdminRequest) {
|
||||
if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
}
|
||||
} else if (isGalleryRequest) {
|
||||
if (galleryToken) {
|
||||
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||
} else if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
}
|
||||
} else if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
} else if (galleryToken) {
|
||||
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||
connectSrc: ["'self'"], // API connections
|
||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||
objectSrc: ["'none'"], // Disable plugins
|
||||
mediaSrc: ["'self'"], // Audio/video
|
||||
frameSrc: ["'none'"], // Disable iframes
|
||||
},
|
||||
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
||||
useDefaults: false,
|
||||
directives: cspDirectives,
|
||||
},
|
||||
hsts: {
|
||||
hsts: enableHsts ? {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
} : false,
|
||||
permittedCrossDomainPolicies: false,
|
||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
|
||||
}));
|
||||
@@ -72,14 +134,14 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS configuration
|
||||
// CORS configuration (apply only to API routes)
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
|
||||
|
||||
// In development, also allow localhost origins
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push(
|
||||
@@ -89,23 +151,166 @@ const corsOptions = {
|
||||
'http://localhost:3000' // Direct backend access
|
||||
);
|
||||
}
|
||||
|
||||
// Allow requests with no origin (like mobile apps or curl)
|
||||
|
||||
// Allow requests with no origin (like curl) and allow-listed origins
|
||||
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
// Do not error globally; just omit CORS headers on disallowed origins
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
// Only attach CORS to API endpoints, not static assets
|
||||
app.use('/api', cors(corsOptions));
|
||||
// Handle preflight explicitly for API paths
|
||||
app.options('/api/*', cors(corsOptions));
|
||||
|
||||
// Initialize rate limiters (they will be created dynamically)
|
||||
let generalRateLimiter;
|
||||
let authRateLimiter;
|
||||
|
||||
function composeInlineStyles(payload) {
|
||||
const { branding } = payload;
|
||||
const cssSegments = [];
|
||||
|
||||
cssSegments.push(`:root {
|
||||
--brand-primary: ${branding.colors.primary};
|
||||
--brand-accent: ${branding.colors.accent};
|
||||
--brand-background: ${branding.colors.background};
|
||||
--brand-text: ${branding.colors.text};
|
||||
}`);
|
||||
|
||||
if (payload.baseCss) {
|
||||
cssSegments.push(payload.baseCss);
|
||||
}
|
||||
|
||||
if (payload.css) {
|
||||
cssSegments.push(`/* Custom styles */\n${payload.css}`);
|
||||
}
|
||||
|
||||
return cssSegments.join('\n\n');
|
||||
}
|
||||
|
||||
function renderBrandHeader(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||
|
||||
const tagline = branding.companyTagline
|
||||
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||
: '';
|
||||
|
||||
return `<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#features">${'Features'}</a>
|
||||
<a href="#workflow">${'Workflow'}</a>
|
||||
<a href="#collections">${'Collections'}</a>
|
||||
<a href="#stories">${'Stories'}</a>
|
||||
<a href="#contact">${'Contact'}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderBrandFooter(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const footerNote = branding.footerText
|
||||
? `<p>${branding.footerText}</p>`
|
||||
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||
|
||||
const supportLink = branding.supportEmail
|
||||
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||
: '';
|
||||
|
||||
const legalLinks = `
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
${supportLink}
|
||||
`;
|
||||
|
||||
return `<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
${legalLinks}
|
||||
</div>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function buildPublicSiteDocument(payload) {
|
||||
const inlineStyles = composeInlineStyles(payload);
|
||||
const header = renderBrandHeader(payload.branding);
|
||||
const footer = renderBrandFooter(payload.branding);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
${header}
|
||||
<main class="site-main">
|
||||
${payload.html}
|
||||
</main>
|
||||
${footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function handlePublicSiteRequest(req, res, next) {
|
||||
try {
|
||||
const payload = await getPublicSitePayload();
|
||||
|
||||
if (!payload.enabled) {
|
||||
res.redirect(302, '/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildPublicSiteDocument(payload);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
|
||||
res.setHeader('ETag', payload.etag);
|
||||
res.setHeader('Vary', 'Accept-Encoding');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
|
||||
|
||||
res.status(200).send(document);
|
||||
} catch (error) {
|
||||
logger.error('Failed to render public site', { error: error.message });
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to initialize rate limiters
|
||||
async function initializeRateLimiters() {
|
||||
generalRateLimiter = await createRateLimiter();
|
||||
@@ -119,10 +324,24 @@ async function initializeRateLimiters() {
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
|
||||
// Body parsing middleware with increased limits for large uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
try {
|
||||
const started = Date.now();
|
||||
const ts = new Date().toISOString();
|
||||
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - started;
|
||||
const tsDone = new Date().toISOString();
|
||||
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||
});
|
||||
} catch (_) {}
|
||||
next();
|
||||
};
|
||||
app.use('/api', apiRequestLogger);
|
||||
|
||||
// Maintenance mode middleware - add after body parsing but before routes
|
||||
app.use(maintenanceMiddleware);
|
||||
@@ -143,6 +362,7 @@ const secureStatic = require('./src/middleware/secureStatic');
|
||||
|
||||
// Get storage path from environment or use default
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||
|
||||
// Static file serving for photos (protected)
|
||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||
@@ -199,7 +419,8 @@ app.get('/health', async (req, res) => {
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
@@ -212,25 +433,52 @@ app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||
app.use('/api/secure-images', secureImagesRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('EXPRESS ERROR HANDLER:', err);
|
||||
console.error('Error stack:', err.stack);
|
||||
console.error('Request URL:', req.url);
|
||||
console.error('Request method:', req.method);
|
||||
logger.error('Express error handler:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method
|
||||
});
|
||||
res.status(500).json({ error: 'Something went wrong!', details: err.message });
|
||||
});
|
||||
// Optional: Serve built frontend (native installs)
|
||||
try {
|
||||
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
|
||||
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
|
||||
const indexPath = path.join(frontendDir, 'index.html');
|
||||
// Auto-serve when dist exists unless explicitly disabled
|
||||
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||
if (shouldServe) {
|
||||
logger.info(`Serving frontend from ${frontendDir}`);
|
||||
// Serve pre-built assets
|
||||
app.use(express.static(frontendDir));
|
||||
|
||||
// Landing page handler or SPA fallback
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
|
||||
// SPA fallback for admin + gallery routes
|
||||
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
} else {
|
||||
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||
}
|
||||
|
||||
// 404 handler for undefined API routes
|
||||
app.use('/api', notFoundHandler);
|
||||
|
||||
// Global error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// Initialize services
|
||||
async function startServer() {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return {
|
||||
db: mockDb,
|
||||
logActivity: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
|
||||
const buildPublicSiteRows = (overrides = {}) => ([
|
||||
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
|
||||
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
|
||||
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
|
||||
]);
|
||||
|
||||
const buildBrandingRows = (overrides = {}) => ([
|
||||
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
|
||||
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
|
||||
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
|
||||
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
|
||||
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
|
||||
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#f8fafc',
|
||||
textColor: '#0f172a'
|
||||
}) }
|
||||
]);
|
||||
|
||||
describe('publicSiteService', () => {
|
||||
beforeEach(() => {
|
||||
clearPublicSiteCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sanitizes stored HTML by stripping script tags', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||
expect(payload.html).not.toContain('<script');
|
||||
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||
});
|
||||
|
||||
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({
|
||||
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
|
||||
});
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.css).toContain('body { color: blue; }');
|
||||
expect(payload.css).not.toContain('@import');
|
||||
expect(payload.css).not.toContain('javascript:');
|
||||
// Client-side util should match server sanitization expectations
|
||||
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
|
||||
expect(clientSanitized).not.toContain('@import');
|
||||
expect(clientSanitized).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('injects branding tokens into the rendered payload', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
|
||||
const brandingRows = buildBrandingRows({
|
||||
companyName: 'Aurora Collective',
|
||||
companyTagline: 'Modern photography for timeless celebrations.',
|
||||
supportEmail: 'studio@aurora.co',
|
||||
logoUrl: '/uploads/logos/aurora.png',
|
||||
themeConfig: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#fafafa',
|
||||
textColor: '#171717'
|
||||
}
|
||||
});
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.html).toContain('Aurora Collective');
|
||||
expect(payload.html).toContain('Modern photography for timeless celebrations.');
|
||||
expect(payload.html).toContain('studio@aurora.co');
|
||||
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||
expect(payload.branding.colors.primary).toBe('#5C8762');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Centralized Multer Configuration Factory
|
||||
* Provides pre-configured multer instances for different upload scenarios
|
||||
*
|
||||
* @module config/multerConfig
|
||||
*/
|
||||
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
/**
|
||||
* Get the storage path from environment or default
|
||||
* @returns {string}
|
||||
*/
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Default allowed MIME types for different upload types
|
||||
*/
|
||||
const ALLOWED_TYPES = {
|
||||
photos: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
||||
videos: ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
|
||||
media: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
|
||||
logos: ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
|
||||
favicons: ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'],
|
||||
documents: ['application/pdf', 'text/plain']
|
||||
};
|
||||
|
||||
/**
|
||||
* Default file size limits (in bytes)
|
||||
*/
|
||||
const SIZE_LIMITS = {
|
||||
small: 1 * 1024 * 1024, // 1MB
|
||||
medium: 5 * 1024 * 1024, // 5MB
|
||||
large: 50 * 1024 * 1024, // 50MB
|
||||
xlarge: 500 * 1024 * 1024, // 500MB
|
||||
huge: 10 * 1024 * 1024 * 1024 // 10GB (for large videos)
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a disk storage configuration
|
||||
*
|
||||
* @param {Object} options - Storage options
|
||||
* @param {string} options.subdir - Subdirectory within storage path
|
||||
* @param {Function} [options.filename] - Custom filename generator
|
||||
* @param {boolean} [options.useTemp] - Use temp directory instead
|
||||
* @returns {multer.StorageEngine}
|
||||
*/
|
||||
const createDiskStorage = (options = {}) => {
|
||||
const { subdir, filename, useTemp = false } = options;
|
||||
|
||||
return multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
let uploadDir;
|
||||
if (useTemp) {
|
||||
uploadDir = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||
} else {
|
||||
uploadDir = path.join(getStoragePath(), subdir || 'uploads');
|
||||
}
|
||||
// Create directory synchronously to prevent race conditions
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
} catch (error) {
|
||||
cb(error);
|
||||
}
|
||||
},
|
||||
filename: filename || ((req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const ext = path.extname(file.originalname);
|
||||
const baseName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
cb(null, `${baseName}-${uniqueSuffix}${ext}`);
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a file filter function
|
||||
*
|
||||
* @param {string[]} allowedTypes - Array of allowed MIME types
|
||||
* @param {Object} [options] - Filter options
|
||||
* @param {boolean} [options.validateMagicNumbers] - Whether to validate file magic numbers
|
||||
* @param {string[]} [options.skipMagicValidation] - MIME types to skip magic number validation for
|
||||
* @returns {Function} Multer file filter function
|
||||
*/
|
||||
const createFileFilter = (allowedTypes, options = {}) => {
|
||||
const { validateMagicNumbers = true, skipMagicValidation = [] } = options;
|
||||
|
||||
return (req, file, cb) => {
|
||||
// Basic MIME type check
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
return cb(new Error(`File type ${file.mimetype} not allowed. Allowed types: ${allowedTypes.join(', ')}`));
|
||||
}
|
||||
|
||||
// Validate file type with magic numbers (if enabled and not skipped)
|
||||
if (validateMagicNumbers && !skipMagicValidation.includes(file.mimetype)) {
|
||||
if (validateFileType && !validateFileType(file.originalname, file.mimetype, allowedTypes)) {
|
||||
return cb(new Error('File content does not match file type'));
|
||||
}
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a multer instance for photo uploads
|
||||
*
|
||||
* @param {Object} [options] - Override options
|
||||
* @returns {multer.Multer}
|
||||
*/
|
||||
const createPhotoUploader = (options = {}) => {
|
||||
const defaults = {
|
||||
storage: createDiskStorage({ useTemp: true }),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.huge,
|
||||
files: options.maxFiles || 2000,
|
||||
fieldSize: 10 * 1024 * 1024,
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
|
||||
validateMagicNumbers: true
|
||||
})
|
||||
};
|
||||
|
||||
return multer({ ...defaults, ...options });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a multer instance for logo uploads
|
||||
*
|
||||
* @param {Object} [options] - Override options
|
||||
* @returns {multer.Multer}
|
||||
*/
|
||||
const createLogoUploader = (options = {}) => {
|
||||
const defaults = {
|
||||
storage: createDiskStorage({
|
||||
subdir: 'uploads/logos',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `logo-${Date.now()}${ext}`);
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.medium
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
|
||||
skipMagicValidation: ['image/svg+xml']
|
||||
})
|
||||
};
|
||||
|
||||
return multer({ ...defaults, ...options });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a multer instance for favicon uploads
|
||||
*
|
||||
* @param {Object} [options] - Override options
|
||||
* @returns {multer.Multer}
|
||||
*/
|
||||
const createFaviconUploader = (options = {}) => {
|
||||
const defaults = {
|
||||
storage: createDiskStorage({
|
||||
subdir: 'uploads/favicons',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `favicon-${Date.now()}${ext}`);
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.small
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
|
||||
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
|
||||
})
|
||||
};
|
||||
|
||||
return multer({ ...defaults, ...options });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a multer instance for gallery user uploads
|
||||
*
|
||||
* @param {string} destDir - Destination directory
|
||||
* @param {Object} [options] - Override options
|
||||
* @returns {multer.Multer}
|
||||
*/
|
||||
const createGalleryUploader = (destDir, options = {}) => {
|
||||
const defaults = {
|
||||
dest: destDir,
|
||||
limits: {
|
||||
fileSize: options.maxSize || SIZE_LIMITS.large,
|
||||
files: options.maxFiles || 10
|
||||
},
|
||||
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
|
||||
};
|
||||
|
||||
return multer({ ...defaults, ...options });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a custom multer instance
|
||||
*
|
||||
* @param {Object} config - Full multer configuration
|
||||
* @returns {multer.Multer}
|
||||
*/
|
||||
const createCustomUploader = (config) => {
|
||||
return multer(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload timeout middleware
|
||||
*
|
||||
* @param {number} [timeout=300000] - Timeout in milliseconds (default 5 minutes)
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
const uploadTimeoutMiddleware = (timeout = 300000) => {
|
||||
return (req, res, next) => {
|
||||
req.setTimeout(timeout, () => {
|
||||
console.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
});
|
||||
|
||||
res.setTimeout(timeout, () => {
|
||||
console.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// Pre-configured uploaders
|
||||
createPhotoUploader,
|
||||
createLogoUploader,
|
||||
createFaviconUploader,
|
||||
createGalleryUploader,
|
||||
createCustomUploader,
|
||||
|
||||
// Building blocks for custom configurations
|
||||
createDiskStorage,
|
||||
createFileFilter,
|
||||
|
||||
// Middleware
|
||||
uploadTimeoutMiddleware,
|
||||
|
||||
// Constants
|
||||
ALLOWED_TYPES,
|
||||
SIZE_LIMITS
|
||||
};
|
||||
@@ -0,0 +1,690 @@
|
||||
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_HTML = `
|
||||
<section class="hero" id="welcome">
|
||||
<div class="hero__inner">
|
||||
<span class="hero__badge">PicPeak Showcase</span>
|
||||
<h1>Share the story of {{company_name}}</h1>
|
||||
<p class="hero__lead">{{company_tagline}}</p>
|
||||
<div class="hero__cta">
|
||||
<a href="#features" class="button button--primary">Explore Features</a>
|
||||
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
|
||||
</div>
|
||||
<dl class="hero__stats">
|
||||
<div>
|
||||
<dt>Private invites</dt>
|
||||
<dd>Secure links for every guest</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Curated delivery</dt>
|
||||
<dd>Highlight every favourite instantly</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fully branded</dt>
|
||||
<dd>Colours, typography, and logo that match you</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="hero__visual">
|
||||
<article class="deck deck--primary">
|
||||
<header class="deck__header">
|
||||
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
|
||||
<span class="deck__title">PicPeak Gallery</span>
|
||||
</header>
|
||||
<ul class="deck__list">
|
||||
<li>Guided cover stories</li>
|
||||
<li>Guest uploads with approvals</li>
|
||||
<li>Protected high-res downloads</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="deck deck--secondary">
|
||||
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
|
||||
<p class="deck__author">— Studio Miraval</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Why teams pick PicPeak</span>
|
||||
<h2>Design-first galleries with the workflow you already love</h2>
|
||||
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article>
|
||||
<h3>Beautiful by default</h3>
|
||||
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Guided storytelling</h3>
|
||||
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Secure sharing</h3>
|
||||
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflow" id="workflow">
|
||||
<div class="workflow__content">
|
||||
<h2>Launch in minutes</h2>
|
||||
<ol class="workflow__steps">
|
||||
<li>
|
||||
<h4>Brand it once</h4>
|
||||
<p>PicPeak automatically applies your logo, colours, and support details.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Curate sections</h4>
|
||||
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Share confidently</h4>
|
||||
<p>Send a single link that greets guests before they enter their private gallery.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="workflow__media">
|
||||
<figure class="workflow__browser">
|
||||
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
|
||||
<figcaption>PicPeak dashboard — trusted by studios worldwide.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collections" id="collections">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Showcase highlights</span>
|
||||
<h2>Curated sample galleries that mirror your client experience</h2>
|
||||
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
|
||||
</div>
|
||||
<div class="collection-showcase">
|
||||
<article>
|
||||
<h3>Signature Galleries</h3>
|
||||
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Welcome timelines</h3>
|
||||
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stories" id="stories">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-badge">Client notes</span>
|
||||
<h2>Experiences that keep guests coming back</h2>
|
||||
</div>
|
||||
<div class="story-grid">
|
||||
<figure>
|
||||
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
|
||||
<figcaption>— Harper & Elias</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
|
||||
<figcaption>— Jordan Rivera, Photographer</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta" id="contact">
|
||||
<div class="cta__inner">
|
||||
<div>
|
||||
<h2>Ready to welcome your guests?</h2>
|
||||
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
|
||||
</div>
|
||||
<div class="cta__actions">
|
||||
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
|
||||
<a href="#features" class="button button--ghost">Review features</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer" id="legal">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>{{company_name}}</h2>
|
||||
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="mailto:{{support_email}}">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_CSS = `
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
|
||||
color: var(--brand-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.brand-label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
position: relative;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.site-nav a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover::after {
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.site-main {
|
||||
padding: 3rem 0 5rem;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.features,
|
||||
.workflow,
|
||||
.collections,
|
||||
.stories,
|
||||
.cta {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero__lead {
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.hero__stats {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hero__stats dt {
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.hero__stats dd {
|
||||
margin: 0.35rem 0 0;
|
||||
color: rgba(15, 23, 42, 0.6);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.deck {
|
||||
border-radius: 20px;
|
||||
padding: 1.75rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.deck--primary {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.deck--secondary {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
|
||||
}
|
||||
|
||||
.deck__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.deck__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.deck__title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.deck__list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
color: rgba(15, 23, 42, 0.68);
|
||||
}
|
||||
|
||||
.deck__quote {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
.deck__author {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.section-head--center {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.6rem);
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.workflow__steps {
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.workflow__steps h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.workflow__steps p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.workflow__browser {
|
||||
margin: 0;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.collection-showcase {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.collection-showcase article {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.story-grid {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.story-grid figure {
|
||||
margin: 0;
|
||||
padding: 1.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.story-grid blockquote {
|
||||
margin: 0 0 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.story-grid figcaption {
|
||||
font-weight: 600;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
|
||||
color: #fff;
|
||||
border-radius: 28px;
|
||||
padding: clamp(2.5rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.cta__inner h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.cta__inner p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.cta__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 3rem 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.footer-inner h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.footer-inner p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_PUBLIC_SITE_TITLE,
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
};
|
||||
+217
-3
@@ -1,6 +1,28 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
const { extractShareToken } = require('../utils/shareLinkUtils');
|
||||
|
||||
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||
try {
|
||||
const isPostgres = knexConfig && knexConfig.client === 'pg';
|
||||
if (!isPostgres && knexConfig && knexConfig.connection) {
|
||||
const filename = typeof knexConfig.connection === 'object'
|
||||
? knexConfig.connection.filename
|
||||
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
|
||||
if (filename && typeof filename === 'string') {
|
||||
const dir = path.dirname(filename);
|
||||
if (dir && dir !== '.') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: log and continue; SQLite will fail later if still missing
|
||||
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
|
||||
}
|
||||
|
||||
// Create database connection with built-in retry logic
|
||||
const db = knex(knexConfig);
|
||||
@@ -42,18 +64,30 @@ async function initializeDatabase() {
|
||||
table.string('event_type').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.date('event_date').notNullable();
|
||||
table.string('customer_name');
|
||||
table.string('customer_email');
|
||||
table.string('host_email').notNullable();
|
||||
table.string('host_name');
|
||||
table.string('admin_email').notNullable();
|
||||
table.string('password_hash').notNullable();
|
||||
table.text('welcome_message');
|
||||
table.text('color_theme');
|
||||
table.string('share_link').unique().notNullable();
|
||||
table.string('share_token').unique();
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('expires_at').notNullable();
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.boolean('is_archived').defaultTo(false);
|
||||
table.string('archive_path');
|
||||
table.datetime('archived_at');
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
table.integer('upload_category_id');
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
table.boolean('require_password').defaultTo(true);
|
||||
});
|
||||
} else {
|
||||
// Check if color_theme needs to be updated to TEXT type
|
||||
@@ -70,12 +104,16 @@ async function initializeDatabase() {
|
||||
event_type TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
customer_name TEXT,
|
||||
customer_email TEXT,
|
||||
host_name TEXT,
|
||||
host_email TEXT NOT NULL,
|
||||
admin_email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
welcome_message TEXT,
|
||||
color_theme TEXT,
|
||||
share_link TEXT UNIQUE NOT NULL,
|
||||
share_token TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
@@ -83,11 +121,42 @@ async function initializeDatabase() {
|
||||
archive_path TEXT,
|
||||
archived_at DATETIME,
|
||||
allow_user_uploads BOOLEAN DEFAULT 0,
|
||||
upload_category_id INTEGER
|
||||
upload_category_id INTEGER,
|
||||
allow_downloads BOOLEAN DEFAULT 1,
|
||||
disable_right_click BOOLEAN DEFAULT 0,
|
||||
watermark_downloads BOOLEAN DEFAULT 0,
|
||||
watermark_text TEXT,
|
||||
hero_photo_id INTEGER,
|
||||
require_password BOOLEAN DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||
const pragmaRows = await db.raw("PRAGMA table_info('events')");
|
||||
const existingColumns = pragmaRows.map(row => row.name);
|
||||
const selectColumns = existingColumns.map((col) => {
|
||||
switch (col) {
|
||||
case 'allow_user_uploads':
|
||||
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
|
||||
case 'upload_category_id':
|
||||
return "upload_category_id";
|
||||
case 'allow_downloads':
|
||||
return "COALESCE(allow_downloads, 1) as allow_downloads";
|
||||
case 'disable_right_click':
|
||||
return "COALESCE(disable_right_click, 0) as disable_right_click";
|
||||
case 'watermark_downloads':
|
||||
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
|
||||
case 'watermark_text':
|
||||
return 'watermark_text';
|
||||
case 'hero_photo_id':
|
||||
return 'hero_photo_id';
|
||||
case 'require_password':
|
||||
return 'COALESCE(require_password, 1) as require_password';
|
||||
default:
|
||||
return col;
|
||||
}
|
||||
});
|
||||
|
||||
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
|
||||
await db.raw('DROP TABLE events');
|
||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||
} catch (error) {
|
||||
@@ -97,6 +166,37 @@ async function initializeDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
|
||||
if (!hasShareTokenColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('share_token').unique();
|
||||
});
|
||||
}
|
||||
|
||||
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
|
||||
if (!hasHostNameColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const eventsWithoutToken = await db('events')
|
||||
.whereNull('share_token')
|
||||
.select('id', 'share_link');
|
||||
|
||||
for (const event of eventsWithoutToken) {
|
||||
const token = extractShareToken(event.share_link);
|
||||
if (token) {
|
||||
await db('events')
|
||||
.where({ id: event.id })
|
||||
.update({ share_token: token });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Share token backfill skipped', { error: error.message });
|
||||
}
|
||||
|
||||
// Photo metadata table
|
||||
const hasPhotosTable = await db.schema.hasTable('photos');
|
||||
if (!hasPhotosTable) {
|
||||
@@ -108,6 +208,7 @@ async function initializeDatabase() {
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable(); // 'collage' or 'individual'
|
||||
table.integer('size_bytes');
|
||||
table.string('uploaded_by').defaultTo('admin');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.integer('view_count').defaultTo(0);
|
||||
table.integer('download_count').defaultTo(0);
|
||||
@@ -138,11 +239,24 @@ async function initializeDatabase() {
|
||||
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
|
||||
table.json('email_data');
|
||||
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('scheduled_at').defaultTo(db.fn.now());
|
||||
table.datetime('sent_at');
|
||||
table.text('error_message');
|
||||
table.integer('retry_count').defaultTo(0);
|
||||
});
|
||||
} else {
|
||||
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
|
||||
if (!hasCreatedAt) {
|
||||
await db.schema.alterTable('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
});
|
||||
try {
|
||||
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
|
||||
} catch (updateError) {
|
||||
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin users table
|
||||
@@ -160,6 +274,7 @@ async function initializeDatabase() {
|
||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||
table.datetime('last_login');
|
||||
table.string('last_login_ip');
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
} else {
|
||||
// Check if updated_at column exists
|
||||
@@ -195,6 +310,13 @@ async function initializeDatabase() {
|
||||
table.string('last_login_ip');
|
||||
});
|
||||
}
|
||||
|
||||
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
|
||||
if (!hasLanguage) {
|
||||
await db.schema.table('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Token revocation tables
|
||||
@@ -270,6 +392,18 @@ async function initializeDatabase() {
|
||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const defaultLanguageSetting = await db('app_settings')
|
||||
.where('setting_key', 'default_language')
|
||||
.first();
|
||||
if (!defaultLanguageSetting) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: JSON.stringify('en'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Activity logs table
|
||||
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
|
||||
@@ -294,6 +428,86 @@ async function initializeDatabase() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ensureGlobalCategories();
|
||||
}
|
||||
|
||||
// Ensure photo categories exist for new deployments
|
||||
async function ensureGlobalCategories() {
|
||||
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
|
||||
if (!hasCategoryIdColumn) {
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
|
||||
if (!hasCmsPagesTable) {
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
|
||||
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
|
||||
if (categoryCount === 0) {
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true },
|
||||
];
|
||||
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
}
|
||||
|
||||
const cmsPages = await db('cms_pages').select('slug');
|
||||
const existingSlugs = cmsPages.map((page) => page.slug);
|
||||
const defaultPages = [
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const page of defaultPages) {
|
||||
if (!existingSlugs.includes(page.slug)) {
|
||||
await db('cms_pages').insert(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to log activities
|
||||
@@ -312,4 +526,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('Revoked token used', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token used for admin endpoint', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// IP validation (optional - can be strict or just log)
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.warn('Token used from different IP', {
|
||||
userId: decoded.id,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp: currentIp
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if password was changed after token was issued
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email
|
||||
};
|
||||
req.token = token; // Store token for potential revocation
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced gallery authentication middleware with revocation checking
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid access token' });
|
||||
}
|
||||
|
||||
// Check if event still exists and is active
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Add event info to request
|
||||
req.event = event;
|
||||
req.galleryToken = decoded;
|
||||
req.token = token;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gallery auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Export other middleware functions from original file...
|
||||
module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
// ... other exports
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware
|
||||
* Adds additional security checks beyond basic JWT validation
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload; // Extract payload when using complete: true
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token used for admin endpoint', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// IP validation (optional - can be strict or just log)
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.warn('Token used from different IP', {
|
||||
userId: decoded.id,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp: currentIp
|
||||
});
|
||||
// Optional: Reject if IP doesn't match
|
||||
// return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if password was changed after token was issued
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced gallery authentication middleware
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid access token' });
|
||||
}
|
||||
|
||||
// Check if event still exists and is active
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Add event info to request
|
||||
req.event = event;
|
||||
req.galleryToken = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gallery auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo access authentication
|
||||
* Validates both admin and gallery tokens for photo access
|
||||
*/
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Allow both admin and gallery tokens
|
||||
if (decoded.type === 'admin') {
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.auth = { type: 'admin', user: admin };
|
||||
} else if (decoded.type === 'gallery') {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// For gallery tokens, ensure they can only access their event's photos
|
||||
req.auth = { type: 'gallery', event: event };
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Photo auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify gallery access for specific operations
|
||||
*/
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
if (!req.auth) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
|
||||
// Admins can access any gallery
|
||||
if (req.auth.type === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Gallery tokens can only access their own event
|
||||
if (req.auth.type === 'gallery') {
|
||||
if (req.auth.event.id !== parseInt(eventId)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Access verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
photoAuth,
|
||||
verifyGalleryAccess
|
||||
};
|
||||
+260
-70
@@ -1,97 +1,287 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
logger.warn('Admin auth attempt without token', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
} catch (jwtError) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
error: jwtError.name,
|
||||
message: jwtError.message,
|
||||
timestamp: new Date().toISOString()
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
|
||||
if (jwtError.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
|
||||
|
||||
if (!admin) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('Admin auth failed - user not found or inactive', {
|
||||
ip: clientIp,
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
logger.warn('Revoked token used', {
|
||||
userId: decoded.id,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
req.admin = admin;
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token used for admin endpoint', {
|
||||
userId: decoded.id,
|
||||
tokenType: decoded.type
|
||||
});
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// IP validation (optional - can be strict or just log)
|
||||
const currentIp = req.ip || req.connection.remoteAddress;
|
||||
if (decoded.ip && decoded.ip !== currentIp) {
|
||||
logger.warn('Token used from different IP', {
|
||||
userId: decoded.id,
|
||||
tokenIp: decoded.ip,
|
||||
currentIp: currentIp
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin still exists and is active, including role info
|
||||
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
|
||||
let admin;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id',
|
||||
'admin_users.username',
|
||||
'admin_users.email',
|
||||
'admin_users.password_changed_at',
|
||||
'roles.id as role_id',
|
||||
'roles.name as role_name'
|
||||
)
|
||||
.first();
|
||||
} catch (joinError) {
|
||||
// Fallback: roles table may not exist yet during upgrade
|
||||
// Query without role join - user will have no role info but can still authenticate
|
||||
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.first();
|
||||
if (admin) {
|
||||
admin.role_id = null;
|
||||
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if password was changed after token was issued
|
||||
if (admin.password_changed_at) {
|
||||
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
logger.warn('Token used after password change', { userId: decoded.id });
|
||||
return res.status(401).json({
|
||||
error: 'Token invalid due to password change',
|
||||
code: 'PASSWORD_CHANGED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request (enhanced with role)
|
||||
req.admin = {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
roleId: admin.role_id,
|
||||
roleName: admin.role_name
|
||||
};
|
||||
req.token = token; // Store token for potential revocation
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.error('Admin auth middleware error', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
logger.error('Auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { adminAuth };
|
||||
/**
|
||||
* Enhanced gallery authentication middleware with revocation checking
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
decoded = decoded.payload;
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid access token' });
|
||||
}
|
||||
|
||||
// Check if event still exists and is active
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Add event info to request
|
||||
req.event = event;
|
||||
req.galleryToken = decoded;
|
||||
req.token = token;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gallery auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo access authentication
|
||||
* Validates both admin and gallery tokens for photo access
|
||||
*/
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Allow both admin and gallery tokens
|
||||
if (decoded.type === 'admin') {
|
||||
const admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.auth = { type: 'admin', user: admin };
|
||||
} else if (decoded.type === 'gallery') {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: true,
|
||||
is_archived: false
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// For gallery tokens, ensure they can only access their event's photos
|
||||
req.auth = { type: 'gallery', event: event };
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Invalid token type' });
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Photo auth middleware error:', error);
|
||||
res.status(401).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify gallery access for specific operations
|
||||
*/
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
if (!req.auth) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
|
||||
// Admins can access any gallery
|
||||
if (req.auth.type === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Gallery tokens can only access their own event
|
||||
if (req.auth.type === 'gallery') {
|
||||
if (req.auth.event.id !== parseInt(eventId)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Access verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
photoAuth,
|
||||
verifyGalleryAccess
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Catches all errors and returns standardized responses.
|
||||
* Distinguishes between operational errors (expected) and programming errors (bugs).
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* Determines if an error is operational (expected) or a programming error (bug).
|
||||
* Operational errors are expected failures like validation errors, not found, etc.
|
||||
* Programming errors are bugs that should be logged and investigated.
|
||||
*
|
||||
* @param {Error} err - The error to check
|
||||
* @returns {boolean} True if operational error
|
||||
*/
|
||||
const isOperationalError = (err) => {
|
||||
return err instanceof AppError && err.isOperational;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats error for development environment (includes stack trace).
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @returns {Object} Formatted error response
|
||||
*/
|
||||
const formatDevError = (err) => {
|
||||
return {
|
||||
error: err.message,
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
stack: err.stack,
|
||||
...(err.details && { details: err.details }),
|
||||
...(err.field && { field: err.field })
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats error for production environment (hides sensitive details).
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @param {boolean} isOperational - Whether this is an operational error
|
||||
* @returns {Object} Formatted error response
|
||||
*/
|
||||
const formatProdError = (err, isOperational) => {
|
||||
// For operational errors, show the message
|
||||
if (isOperational) {
|
||||
return {
|
||||
error: err.message,
|
||||
code: err.code || 'ERROR',
|
||||
...(err.details && { details: err.details }),
|
||||
...(err.field && { field: err.field })
|
||||
};
|
||||
}
|
||||
|
||||
// For programming errors, hide details
|
||||
return {
|
||||
error: 'An unexpected error occurred',
|
||||
code: 'INTERNAL_ERROR'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles specific error types and converts them to AppError format.
|
||||
*
|
||||
* @param {Error} err - The error to handle
|
||||
* @returns {Error} Converted error or original error
|
||||
*/
|
||||
const handleKnownErrors = (err) => {
|
||||
// Handle Knex/Database errors
|
||||
if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') {
|
||||
const { AppError } = require('../utils/errors');
|
||||
const error = new AppError('A record with this value already exists', 409, 'DUPLICATE_ENTRY');
|
||||
error.isOperational = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle JSON parsing errors
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Invalid JSON in request body');
|
||||
}
|
||||
|
||||
// Handle multer file upload errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('File size exceeds the maximum allowed limit');
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Unexpected file field');
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Must be registered last, after all routes.
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
// If headers already sent, delegate to Express default handler
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Convert known error types
|
||||
const error = handleKnownErrors(err);
|
||||
|
||||
// Determine error status code
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
const operational = isOperationalError(error);
|
||||
|
||||
// Log the error
|
||||
const logContext = {
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
statusCode,
|
||||
errorCode: error.code,
|
||||
operational,
|
||||
...(req.admin && { adminId: req.admin.id }),
|
||||
...(req.gallerySlug && { gallerySlug: req.gallerySlug })
|
||||
};
|
||||
|
||||
if (operational) {
|
||||
// Operational errors are expected, log at warn level
|
||||
logger.warn('Operational error', {
|
||||
...logContext,
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
// Programming errors are bugs, log at error level with stack
|
||||
logger.error('Unhandled error', {
|
||||
...logContext,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Format and send response
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const response = isDev ? formatDevError(error) : formatProdError(error, operational);
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* 404 handler for undefined routes.
|
||||
* Should be registered after all routes but before errorHandler.
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
next(new NotFoundError('Route', req.originalUrl));
|
||||
};
|
||||
|
||||
/**
|
||||
* Async handler that catches unhandled promise rejections.
|
||||
* Use this to wrap async route handlers.
|
||||
*
|
||||
* @param {Function} fn - Async function to wrap
|
||||
* @returns {Function} Wrapped function
|
||||
*/
|
||||
const asyncHandler = (fn) => (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
isOperationalError
|
||||
};
|
||||
@@ -1,17 +1,52 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||
let event;
|
||||
|
||||
if (!token) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
@@ -26,12 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
|
||||
let event;
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => {
|
||||
@@ -64,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
@@ -80,14 +112,14 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
||||
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
res.status(401).json({ error: 'Invalid token', details: error.message });
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyGalleryAccess
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Permission Checking Middleware for RBAC
|
||||
* Provides role-based access control with caching for performance
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { ForbiddenError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache for role permissions (refreshed periodically)
|
||||
let permissionCache = new Map();
|
||||
let cacheLastUpdated = 0;
|
||||
const CACHE_TTL = 60000; // 1 minute
|
||||
|
||||
/**
|
||||
* Refresh permission cache from database
|
||||
* Handles upgrade scenario where RBAC tables may not exist yet
|
||||
*/
|
||||
async function refreshPermissionCache() {
|
||||
const now = Date.now();
|
||||
if (now - cacheLastUpdated < CACHE_TTL && permissionCache.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rolePermissions = await db('role_permissions')
|
||||
.join('roles', 'roles.id', 'role_permissions.role_id')
|
||||
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
|
||||
.select('roles.name as role_name', 'permissions.name as permission_name');
|
||||
|
||||
const newCache = new Map();
|
||||
for (const rp of rolePermissions) {
|
||||
if (!newCache.has(rp.role_name)) {
|
||||
newCache.set(rp.role_name, new Set());
|
||||
}
|
||||
newCache.get(rp.role_name).add(rp.permission_name);
|
||||
}
|
||||
|
||||
permissionCache = newCache;
|
||||
cacheLastUpdated = now;
|
||||
} catch (error) {
|
||||
// Handle case where RBAC tables don't exist yet (upgrade scenario)
|
||||
// Grant super_admin all permissions by default during upgrade window
|
||||
if (error.message.includes('no such table') || error.message.includes('does not exist') || error.message.includes('relation')) {
|
||||
logger.warn('RBAC tables not available yet - granting full access to authenticated users during upgrade');
|
||||
const allPermissions = new Set([
|
||||
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
|
||||
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
|
||||
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
|
||||
'analytics.view', 'email.view', 'email.edit', 'email.send',
|
||||
'branding.view', 'branding.edit', 'cms.view', 'cms.edit',
|
||||
'settings.view', 'settings.edit', 'backup.view', 'backup.create', 'backup.restore', 'backup.delete',
|
||||
'users.view', 'users.create', 'users.edit', 'users.delete',
|
||||
'activity.view', 'activity.export'
|
||||
]);
|
||||
permissionCache.set('super_admin', allPermissions);
|
||||
cacheLastUpdated = now;
|
||||
} else {
|
||||
logger.error('Failed to refresh permission cache', { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has a specific permission
|
||||
* @param {string} roleName - Role name to check
|
||||
* @param {string} permissionName - Permission name to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function roleHasPermission(roleName, permissionName) {
|
||||
await refreshPermissionCache();
|
||||
const rolePerms = permissionCache.get(roleName);
|
||||
return rolePerms ? rolePerms.has(permissionName) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified permissions
|
||||
* @param {number} userId - User ID to check
|
||||
* @param {string[]} permissions - Array of permission names
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function userHasAnyPermission(userId, permissions) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (await roleHasPermission(user.role_name, perm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has all specified permissions
|
||||
* @param {number} userId - User ID to check
|
||||
* @param {string[]} permissions - Array of permission names
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function userHasAllPermissions(userId, permissions) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (!(await roleHasPermission(user.role_name, perm))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory: require specific permission(s)
|
||||
* @param {string|string[]} permissions - Permission name(s) required
|
||||
* @param {object} options - { requireAll: boolean }
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
function requirePermission(permissions, options = { requireAll: false }) {
|
||||
const permArray = Array.isArray(permissions) ? permissions : [permissions];
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.admin || !req.admin.id) {
|
||||
throw new ForbiddenError('Authentication required');
|
||||
}
|
||||
|
||||
const hasPermission = options.requireAll
|
||||
? await userHasAllPermissions(req.admin.id, permArray)
|
||||
: await userHasAnyPermission(req.admin.id, permArray);
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn('Permission denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
requiredPermissions: permArray,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Insufficient permissions');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: require super_admin role
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
function requireSuperAdmin() {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.admin || !req.admin.id) {
|
||||
throw new ForbiddenError('Authentication required');
|
||||
}
|
||||
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', req.admin.id)
|
||||
.select('roles.name as role_name')
|
||||
.first();
|
||||
|
||||
if (!user || user.role_name !== 'super_admin') {
|
||||
logger.warn('Super admin access denied', {
|
||||
userId: req.admin.id,
|
||||
username: req.admin.username,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
throw new ForbiddenError('Super Admin access required');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions for client
|
||||
* @param {number} userId - User ID
|
||||
* @returns {Promise<{role: object|null, permissions: string[]}>}
|
||||
*/
|
||||
async function getUserPermissions(userId) {
|
||||
const user = await db('admin_users')
|
||||
.join('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', userId)
|
||||
.select('roles.name as role_name', 'roles.display_name as role_display_name')
|
||||
.first();
|
||||
|
||||
if (!user) return { role: null, permissions: [] };
|
||||
|
||||
await refreshPermissionCache();
|
||||
const permissions = permissionCache.get(user.role_name) || new Set();
|
||||
|
||||
return {
|
||||
role: {
|
||||
name: user.role_name,
|
||||
displayName: user.role_display_name
|
||||
},
|
||||
permissions: Array.from(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache (useful for testing or when permissions change)
|
||||
*/
|
||||
function clearPermissionCache() {
|
||||
permissionCache.clear();
|
||||
cacheLastUpdated = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requirePermission,
|
||||
requireSuperAdmin,
|
||||
getUserPermissions,
|
||||
userHasAnyPermission,
|
||||
userHasAllPermissions,
|
||||
roleHasPermission,
|
||||
refreshPermissionCache,
|
||||
clearPermissionCache
|
||||
};
|
||||
@@ -2,14 +2,14 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
console.log('PhotoAuth middleware - path:', req.path);
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
@@ -20,9 +20,9 @@ async function photoAuth(req, res, next) {
|
||||
}
|
||||
|
||||
// First check for JWT token (from gallery access)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
|
||||
if (tokenFromRequest) {
|
||||
const token = tokenFromRequest;
|
||||
try {
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
@@ -79,29 +79,36 @@ async function photoAuth(req, res, next) {
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
console.error('JWT verification failed:', err.message);
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for password header (legacy support)
|
||||
const password = req.headers['x-gallery-password'];
|
||||
|
||||
if (!password && !authHeader) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password) {
|
||||
if (!eventSlug && !password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
@@ -121,7 +128,7 @@ async function photoAuth(req, res, next) {
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Photo auth error:', error);
|
||||
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
// In-memory session tracking (in production, use Redis)
|
||||
const sessions = new Map();
|
||||
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
|
||||
|
||||
async function sessionTimeoutMiddleware(req, res, next) {
|
||||
// Skip for non-authenticated routes
|
||||
if (!req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
@@ -150,4 +147,4 @@ module.exports = {
|
||||
sessionTimeoutMiddleware,
|
||||
endSession,
|
||||
getActiveSessions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const buildChain = ({ firstResult, updateResult } = {}) => {
|
||||
const chain = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
whereNot: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockResolvedValue(updateResult ?? 1),
|
||||
first: jest.fn().mockResolvedValue(firstResult),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
jest.mock('../../database/db', () => {
|
||||
const dbMock = jest.fn();
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__setImplementations = (...chains) => {
|
||||
dbMock.mockReset();
|
||||
chains.forEach((chain) => {
|
||||
dbMock.mockImplementationOnce(() => chain);
|
||||
});
|
||||
};
|
||||
return {
|
||||
db: dbMock,
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../middleware/auth', () => ({
|
||||
adminAuth: (_req, _res, next) => {
|
||||
_req.admin = { id: 1, username: 'admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const adminAuthRouter = require('../adminAuth');
|
||||
|
||||
describe('adminAuth profile updates', () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth/admin', adminAuthRouter);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('updates the admin profile', async () => {
|
||||
const updatedUser = {
|
||||
id: 1,
|
||||
username: 'newadmin',
|
||||
email: 'newadmin@example.com',
|
||||
must_change_password: false,
|
||||
};
|
||||
|
||||
db.__setImplementations(
|
||||
buildChain({ firstResult: null }), // email check
|
||||
buildChain({ firstResult: null }), // username check
|
||||
buildChain({ updateResult: 1 }), // update
|
||||
buildChain({ firstResult: updatedUser }), // fetch updated user
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/auth/admin/profile')
|
||||
.send({ username: updatedUser.username, email: updatedUser.email })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ user: updatedUser });
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
'admin_profile_updated',
|
||||
{ admin_id: 1, updated_fields: ['username', 'email'] },
|
||||
null,
|
||||
{ type: 'admin', id: 1, name: updatedUser.username }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects email conflicts', async () => {
|
||||
db.__setImplementations(
|
||||
buildChain({ firstResult: { id: 2 } })
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/auth/admin/profile')
|
||||
.send({ username: 'newadmin', email: 'taken@example.com' })
|
||||
.expect(409);
|
||||
|
||||
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
|
||||
});
|
||||
|
||||
it('validates input', async () => {
|
||||
const response = await request(app)
|
||||
.put('/auth/admin/profile')
|
||||
.send({ username: '', email: 'not-an-email' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
jest.mock('../../database/db', () => {
|
||||
const deleteMock = jest.fn().mockResolvedValue(5);
|
||||
const chain = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
whereNull: jest.fn().mockReturnThis(),
|
||||
whereNotNull: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
delete: deleteMock,
|
||||
count: jest.fn().mockReturnThis(),
|
||||
first: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
};
|
||||
|
||||
const dbMock = jest.fn(() => chain);
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__chain = chain;
|
||||
dbMock.__deleteMock = deleteMock;
|
||||
return { db: dbMock };
|
||||
});
|
||||
|
||||
jest.mock('../../middleware/auth', () => ({
|
||||
adminAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
const { db } = require('../../database/db');
|
||||
const notificationsRouter = require('../adminNotifications');
|
||||
|
||||
describe('adminNotifications routes', () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/admin/notifications', notificationsRouter);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('clears all notifications', async () => {
|
||||
db.__deleteMock.mockResolvedValueOnce(8);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/admin/notifications/clear-all')
|
||||
.expect(200);
|
||||
|
||||
expect(db).toHaveBeenCalledWith('activity_logs');
|
||||
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.body).toEqual({
|
||||
message: 'All notifications cleared',
|
||||
deletedCount: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles database errors when clearing notifications', async () => {
|
||||
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/admin/notifications/clear-all')
|
||||
.expect(500);
|
||||
|
||||
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Accept Invitation Routes (Public)
|
||||
* Handles invitation token validation and account creation
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const userManagementService = require('../services/userManagementService');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /:token
|
||||
* Validate invitation token
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
router.get('/:token', [
|
||||
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const invitation = await userManagementService.validateInvitationToken(req.params.token);
|
||||
|
||||
if (!invitation) {
|
||||
return res.status(404).json({ error: 'Invalid or expired invitation' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
email: invitation.email,
|
||||
role: invitation.role_name,
|
||||
expiresAt: invitation.expires_at
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:token
|
||||
* Accept invitation and create account
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
router.post('/:token', [
|
||||
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token'),
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 50 })
|
||||
.withMessage('Username must be 3-50 characters')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/)
|
||||
.withMessage('Username can only contain letters, numbers, underscores, and hyphens'),
|
||||
body('password')
|
||||
.isLength({ min: 12 })
|
||||
.withMessage('Password must be at least 12 characters')
|
||||
.custom((value) => {
|
||||
const validation = validatePasswordStrength(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.messages.join(', '));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const result = await userManagementService.acceptInvitation({
|
||||
token: req.params.token,
|
||||
username: req.body.username,
|
||||
password: req.body.password
|
||||
});
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Account created successfully. You can now log in.',
|
||||
email: result.email
|
||||
}, 201);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,13 +3,14 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single archive details
|
||||
router.get('/:id', adminAuth, async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Restore archive
|
||||
router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download archive
|
||||
router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete archive permanently
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
|
||||
+144
-80
@@ -1,99 +1,163 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { body } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
router.get('/profile', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users')
|
||||
.where('id', req.admin.id)
|
||||
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundError('Admin user');
|
||||
}
|
||||
|
||||
res.json(admin);
|
||||
}));
|
||||
|
||||
// Update admin profile
|
||||
router.put('/profile', [
|
||||
adminAuth,
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 50 })
|
||||
.withMessage('Username must be between 3 and 50 characters'),
|
||||
body('email')
|
||||
.trim()
|
||||
.isEmail()
|
||||
.withMessage('A valid email address is required')
|
||||
.normalizeEmail()
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const username = req.body.username.trim();
|
||||
const email = req.body.email.trim().toLowerCase();
|
||||
const adminId = req.admin.id;
|
||||
|
||||
// Check for username conflict
|
||||
const existingUsername = await db('admin_users')
|
||||
.where('username', username)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username is already in use', 'username');
|
||||
}
|
||||
|
||||
// Check for email conflict
|
||||
const existingEmail = await db('admin_users')
|
||||
.where('email', email)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
|
||||
if (existingEmail) {
|
||||
throw new ConflictError('Email address is already in use', 'email');
|
||||
}
|
||||
|
||||
await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.update({
|
||||
username,
|
||||
email,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_profile_updated',
|
||||
{ username, email },
|
||||
null,
|
||||
{ type: 'admin', id: adminId, name: req.admin.username }
|
||||
);
|
||||
|
||||
const updatedAdmin = await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Admin profile updated successfully',
|
||||
user: updatedAdmin
|
||||
});
|
||||
}));
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', [
|
||||
adminAuth,
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.admin.id;
|
||||
|
||||
// Validate new password strength
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.messages
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db('admin_users')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: user.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
// Validate new password strength
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new ValidationError('Password does not meet security requirements', passwordValidation.messages);
|
||||
}
|
||||
});
|
||||
|
||||
// Get user from database
|
||||
const user = await db('admin_users')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('User');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
throw new ValidationError('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: user.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Password changed successfully' });
|
||||
}));
|
||||
|
||||
// Logout
|
||||
router.post('/logout', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity('admin_logout',
|
||||
{ admin_id: req.admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Failed to logout' });
|
||||
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
// Get token from header
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Log activity
|
||||
await logActivity('admin_logout',
|
||||
{ admin_id: req.admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const fs = require('fs').promises;
|
||||
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
|
||||
const router = express.Router();
|
||||
|
||||
// Get backup configuration
|
||||
router.get('/config', adminAuth, async (req, res) => {
|
||||
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update backup configuration
|
||||
router.put('/config', adminAuth, async (req, res) => {
|
||||
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup status and history
|
||||
router.get('/status', adminAuth, async (req, res) => {
|
||||
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const status = await getBackupStatus(limit);
|
||||
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Trigger manual backup
|
||||
router.post('/run', adminAuth, async (req, res) => {
|
||||
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
// Check if backup is already running
|
||||
const status = await getBackupStatus();
|
||||
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup run details
|
||||
router.get('/runs/:id', adminAuth, async (req, res) => {
|
||||
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get file states (for debugging/monitoring)
|
||||
router.get('/files', adminAuth, async (req, res) => {
|
||||
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 50, search = '' } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Clean up old backup runs
|
||||
router.delete('/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { days = 30 } = req.body;
|
||||
|
||||
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Test backup destination connectivity
|
||||
router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const { destination_type, ...config } = req.body;
|
||||
|
||||
@@ -221,29 +222,101 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rsync':
|
||||
// Test rsync connection
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const sshCommand = config.ssh_key
|
||||
? `ssh -i ${config.ssh_key} -o StrictHostKeyChecking=no -o ConnectTimeout=10`
|
||||
: 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
|
||||
|
||||
const testCommand = config.user
|
||||
? `${sshCommand} ${config.user}@${config.host} "echo 'Connection successful'"`
|
||||
: `${sshCommand} ${config.host} "echo 'Connection successful'"`;
|
||||
|
||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Validate and sanitize inputs to prevent command injection
|
||||
const sanitizeInput = (input) => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
// Remove any shell metacharacters and limit length
|
||||
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||
};
|
||||
|
||||
const host = sanitizeInput(config.host);
|
||||
const user = sanitizeInput(config.user);
|
||||
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||
|
||||
if (!host) {
|
||||
res.json({ success: false, message: 'Invalid host specified' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
res.json({ success: false, message: 'Invalid host format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build SSH arguments as array (safe from injection)
|
||||
const sshArgs = [];
|
||||
if (sshKeyPath) {
|
||||
// Validate SSH key path exists and is a file
|
||||
const fsSync = require('fs');
|
||||
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||
res.json({ success: false, message: 'SSH key file not found' });
|
||||
break;
|
||||
}
|
||||
sshArgs.push('-i', sshKeyPath);
|
||||
}
|
||||
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||
sshArgs.push('-o', 'BatchMode=yes');
|
||||
|
||||
// Add target (user@host or just host)
|
||||
const target = user ? `${user}@${host}` : host;
|
||||
sshArgs.push(target);
|
||||
sshArgs.push('echo', 'Connection successful');
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(testCommand);
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const sshProcess = spawn('ssh', sshArgs, {
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
sshProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true, stdout });
|
||||
} else {
|
||||
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
sshProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: host,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -262,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get backup manifest for a specific backup run
|
||||
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupRunId } = req.params;
|
||||
const result = await getBackupManifest(backupRunId);
|
||||
@@ -274,12 +347,12 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate a backup manifest
|
||||
router.post('/manifest/validate', adminAuth, async (req, res) => {
|
||||
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath } = req.body;
|
||||
|
||||
@@ -301,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download backup manifest
|
||||
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupRunId } = req.params;
|
||||
const { format = 'json' } = req.query;
|
||||
@@ -327,12 +400,12 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get manifest for specific backup
|
||||
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
const result = await getBackupManifest(backupId);
|
||||
@@ -344,12 +417,12 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download manifest file
|
||||
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
const { format = 'json' } = req.query;
|
||||
@@ -375,12 +448,12 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate a manifest
|
||||
router.post('/manifests/validate', adminAuth, async (req, res) => {
|
||||
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath, manifestData } = req.body;
|
||||
|
||||
@@ -409,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// List S3 buckets
|
||||
router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const config = await getBackupConfig();
|
||||
|
||||
@@ -436,12 +509,12 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 buckets:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets' });
|
||||
}
|
||||
});
|
||||
|
||||
// List files in S3 backup location
|
||||
router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
|
||||
const config = await getBackupConfig();
|
||||
@@ -473,12 +546,12 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 files:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to list S3 files' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up old S3 backups
|
||||
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { retentionDays = 30, dryRun = false } = req.body;
|
||||
const config = await getBackupConfig();
|
||||
@@ -535,12 +608,12 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup S3 backups:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Test S3 upload functionality
|
||||
router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const config = await getBackupConfig();
|
||||
|
||||
@@ -587,12 +660,12 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('S3 upload test failed:', error);
|
||||
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
|
||||
res.status(500).json({ error: 'S3 upload test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download entire backup
|
||||
router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { backupId } = req.params;
|
||||
|
||||
@@ -675,12 +748,12 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup:', error);
|
||||
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to download backup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current file checksums
|
||||
router.get('/checksums', adminAuth, async (req, res) => {
|
||||
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { path: targetPath = '', recursive = true } = req.query;
|
||||
const checksums = {};
|
||||
@@ -744,12 +817,12 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get file checksums:', error);
|
||||
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to get file checksums' });
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate backup size before running
|
||||
router.post('/estimate', adminAuth, async (req, res) => {
|
||||
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { includeArchived = true } = req.body;
|
||||
|
||||
@@ -847,7 +920,7 @@ router.post('/estimate', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to estimate backup size:', error);
|
||||
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to estimate backup size' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -945,12 +1018,13 @@ async function validateManifestData(manifestData) {
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Manifest validation error', { error: error.message });
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation error: ${error.message}`,
|
||||
details: { error: error.message }
|
||||
error: 'Validation error encountered while processing manifest',
|
||||
details: { hint: 'See server logs for diagnostic details.' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all CMS pages
|
||||
router.get('/pages', adminAuth, async (req, res) => {
|
||||
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||
try {
|
||||
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
||||
res.json(pages);
|
||||
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get a single CMS page
|
||||
router.get('/pages/:slug', adminAuth, async (req, res) => {
|
||||
router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const page = await db('cms_pages').where('slug', slug).first();
|
||||
@@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a CMS page
|
||||
router.put('/pages/:slug', adminAuth, [
|
||||
router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
body('title_en').optional().isString(),
|
||||
body('title_de').optional().isString(),
|
||||
body('content_en').optional().isString(),
|
||||
|
||||
@@ -2,11 +2,12 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all global categories
|
||||
router.get('/global', adminAuth, async (req, res) => {
|
||||
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
@@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
router.get('/event/:eventId', adminAuth, async (req, res) => {
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Create a new category
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required'),
|
||||
body('slug').optional(),
|
||||
body('is_global').optional().isBoolean(),
|
||||
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
// Update a category
|
||||
router.put('/:id', adminAuth, [
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Delete a category
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Admin CSS Templates Routes
|
||||
* Handles CRUD operations for custom CSS gallery templates
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
||||
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
||||
|
||||
/**
|
||||
* GET /admin/css-templates
|
||||
* Get all CSS templates
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await withRetry(() =>
|
||||
db('css_templates').orderBy('slot_number')
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
console.error('Get CSS templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/css-templates/enabled
|
||||
* Get only enabled templates (for event form dropdown)
|
||||
*/
|
||||
router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ is_enabled: true })
|
||||
.select('id', 'name', 'slot_number')
|
||||
.orderBy('slot_number')
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
console.error('Get enabled templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/css-templates/:slotNumber
|
||||
* Get a specific template by slot number
|
||||
*/
|
||||
router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 3 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slotNumber } = req.params;
|
||||
const template = await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ slot_number: parseInt(slotNumber) })
|
||||
.first()
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
console.error('Get template error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch template' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/css-templates/:slotNumber
|
||||
* Update a template
|
||||
*/
|
||||
router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 3 }),
|
||||
body('name').optional().isString().isLength({ max: 50 }),
|
||||
body('css_content').optional().isString(),
|
||||
body('is_enabled').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slotNumber } = req.params;
|
||||
const { name, css_content, is_enabled } = req.body;
|
||||
|
||||
// Validate CSS size
|
||||
if (css_content && css_content.length > MAX_CSS_SIZE) {
|
||||
return res.status(400).json({
|
||||
error: `CSS content exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate CSS syntax
|
||||
if (css_content) {
|
||||
const validation = validateCSS(css_content);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid CSS syntax',
|
||||
details: validation.error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize CSS
|
||||
const { sanitized, warnings } = sanitizeCSS(css_content || '');
|
||||
|
||||
const updates = {
|
||||
updated_at: db.fn.now()
|
||||
};
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.name = name.substring(0, 50) || 'Untitled';
|
||||
}
|
||||
if (css_content !== undefined) {
|
||||
updates.css_content = sanitized;
|
||||
}
|
||||
if (is_enabled !== undefined) {
|
||||
updates.is_enabled = Boolean(is_enabled);
|
||||
}
|
||||
|
||||
await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ slot_number: parseInt(slotNumber) })
|
||||
.update(updates)
|
||||
);
|
||||
|
||||
const template = await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ slot_number: parseInt(slotNumber) })
|
||||
.first()
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template,
|
||||
sanitization_warnings: warnings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update template error:', error);
|
||||
res.status(500).json({ error: 'Failed to update template' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/css-templates/:slotNumber/reset
|
||||
* Reset template to default (only for slot 1)
|
||||
*/
|
||||
router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [
|
||||
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ slot_number: 1 })
|
||||
.update({
|
||||
name: 'Elegant Dark',
|
||||
css_content: DEFAULT_CSS_TEMPLATE,
|
||||
is_enabled: true,
|
||||
updated_at: db.fn.now()
|
||||
})
|
||||
);
|
||||
|
||||
const template = await withRetry(() =>
|
||||
db('css_templates')
|
||||
.where({ slot_number: 1 })
|
||||
.first()
|
||||
);
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
console.error('Reset template error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +1,13 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', adminAuth, async (req, res) => {
|
||||
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
// Get active events count
|
||||
const activeEvents = await db('events')
|
||||
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get recent activity
|
||||
router.get('/activity', adminAuth, async (req, res) => {
|
||||
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
|
||||
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get system health status
|
||||
router.get('/health', adminAuth, async (req, res) => {
|
||||
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const os = require('os');
|
||||
|
||||
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get analytics data for charts
|
||||
router.get('/analytics', adminAuth, async (req, res) => {
|
||||
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const days = sanitizeDays(req.query.days || 7);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -11,7 +12,7 @@ router.use(adminAuth);
|
||||
/**
|
||||
* Get database backup status and configuration
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
// Get configuration
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
|
||||
/**
|
||||
* Update database backup configuration
|
||||
*/
|
||||
router.put('/config', async (req, res) => {
|
||||
router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const allowedSettings = [
|
||||
'database_backup_enabled',
|
||||
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
|
||||
/**
|
||||
* Trigger manual database backup
|
||||
*/
|
||||
router.post('/backup', async (req, res) => {
|
||||
router.post('/backup', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
if (databaseBackupService.isRunning) {
|
||||
return res.status(409).json({ error: 'Backup already in progress' });
|
||||
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
|
||||
/**
|
||||
* Get current backup progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const progress = databaseBackupService.getProgress();
|
||||
|
||||
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
|
||||
/**
|
||||
* Get backup history with pagination
|
||||
*/
|
||||
router.get('/history', async (req, res) => {
|
||||
router.get('/history', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
|
||||
/**
|
||||
* Delete old backup files
|
||||
*/
|
||||
router.delete('/cleanup', async (req, res) => {
|
||||
router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
|
||||
try {
|
||||
const { retentionDays = 30 } = req.body;
|
||||
|
||||
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
|
||||
/**
|
||||
* Test database backup configuration
|
||||
*/
|
||||
router.post('/test', async (req, res) => {
|
||||
router.post('/test', requirePermission('backup.create'), async (req, res) => {
|
||||
try {
|
||||
const config = await databaseBackupService.getBackupConfig();
|
||||
|
||||
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
|
||||
/**
|
||||
* Get table checksums
|
||||
*/
|
||||
router.get('/checksums', async (req, res) => {
|
||||
router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const checksums = await databaseBackupService.getTableChecksums();
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ const express = require('express');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
router.get('/config', adminAuth, async (req, res) => {
|
||||
router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
|
||||
@@ -18,7 +19,8 @@ router.get('/config', adminAuth, async (req, res) => {
|
||||
smtp_user: '',
|
||||
smtp_pass: '', // Don't send actual password
|
||||
from_email: '',
|
||||
from_name: ''
|
||||
from_name: '',
|
||||
tls_reject_unauthorized: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
||||
// Update email configuration
|
||||
router.post('/config', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
|
||||
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
|
||||
body('from_email').isEmail().withMessage('Invalid from email address')
|
||||
@@ -53,7 +56,8 @@ router.post('/config', [
|
||||
smtp_user,
|
||||
smtp_pass,
|
||||
from_email,
|
||||
from_name
|
||||
from_name,
|
||||
tls_reject_unauthorized
|
||||
} = req.body;
|
||||
|
||||
// Check if config exists
|
||||
@@ -66,6 +70,7 @@ router.post('/config', [
|
||||
smtp_user: smtp_user || '',
|
||||
from_email,
|
||||
from_name: from_name || 'Photo Sharing',
|
||||
tls_reject_unauthorized: tls_reject_unauthorized !== false, // Default to true
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
@@ -97,7 +102,7 @@ router.post('/config', [
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
router.post('/test', adminAuth, async (req, res) => {
|
||||
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const { test_email } = req.body;
|
||||
|
||||
@@ -137,6 +142,10 @@ router.post('/test', adminAuth, async (req, res) => {
|
||||
user: config.smtp_user,
|
||||
pass: config.smtp_pass
|
||||
} : undefined,
|
||||
tls: {
|
||||
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
|
||||
rejectUnauthorized: config.tls_reject_unauthorized !== false
|
||||
},
|
||||
logger: process.env.NODE_ENV === 'development',
|
||||
debug: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
@@ -173,32 +182,63 @@ router.post('/test', adminAuth, async (req, res) => {
|
||||
} catch (error) {
|
||||
console.error('Test email error:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = 'Failed to send test email';
|
||||
|
||||
// Provide more specific error messages with translation keys
|
||||
let errorMessage = 'Error sending email';
|
||||
let errorKey = 'email.errors.sendFailed';
|
||||
let details = error.message;
|
||||
|
||||
let detailsKey = 'email.errors.unknownError';
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
errorMessage = 'Failed to connect to SMTP server';
|
||||
errorKey = 'email.errors.connectionRefused';
|
||||
details = 'Please check your SMTP host and port settings';
|
||||
detailsKey = 'email.errors.checkHostPort';
|
||||
} else if (error.code === 'EAUTH') {
|
||||
errorMessage = 'SMTP authentication failed';
|
||||
errorKey = 'email.errors.authFailed';
|
||||
details = 'Please check your SMTP username and password';
|
||||
detailsKey = 'email.errors.checkCredentials';
|
||||
} else if (error.code === 'ESOCKET') {
|
||||
errorMessage = 'Network error';
|
||||
errorMessage = 'Network error connecting to SMTP server';
|
||||
errorKey = 'email.errors.networkError';
|
||||
details = 'Could not establish connection to SMTP server';
|
||||
detailsKey = 'email.errors.connectionFailed';
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Connection to SMTP server timed out';
|
||||
errorKey = 'email.errors.timeout';
|
||||
details = 'The server took too long to respond. Please check your network and SMTP settings.';
|
||||
detailsKey = 'email.errors.timeoutDetails';
|
||||
} else if (error.code === 'ENOTFOUND') {
|
||||
errorMessage = 'SMTP server not found';
|
||||
errorKey = 'email.errors.serverNotFound';
|
||||
details = 'The SMTP host could not be resolved. Please verify the hostname.';
|
||||
detailsKey = 'email.errors.checkHostname';
|
||||
} else if (error.responseCode >= 500) {
|
||||
errorMessage = 'SMTP server error';
|
||||
errorKey = 'email.errors.serverError';
|
||||
details = `Server returned error code ${error.responseCode}`;
|
||||
detailsKey = 'email.errors.serverErrorDetails';
|
||||
} else if (error.responseCode >= 400) {
|
||||
errorMessage = 'Email rejected by server';
|
||||
errorKey = 'email.errors.rejected';
|
||||
details = error.response || 'The email was rejected. Check recipient address and settings.';
|
||||
detailsKey = 'email.errors.rejectedDetails';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
|
||||
res.status(500).json({
|
||||
error: errorMessage,
|
||||
errorKey: errorKey,
|
||||
details: details,
|
||||
code: error.code
|
||||
detailsKey: detailsKey,
|
||||
code: error.code,
|
||||
responseCode: error.responseCode
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, async (req, res) => {
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const templates = await db('email_templates')
|
||||
.select('*')
|
||||
@@ -252,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single template
|
||||
router.get('/templates/:key', adminAuth, async (req, res) => {
|
||||
router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
@@ -308,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
|
||||
// Update email template
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
@@ -386,7 +427,7 @@ router.put('/templates/:key', [
|
||||
});
|
||||
|
||||
// Preview email template
|
||||
router.post('/templates/:key/preview', adminAuth, async (req, res) => {
|
||||
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Admin Event Rename Routes
|
||||
* Handles event renaming operations
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/admin/events/:eventId/rename
|
||||
* Rename an event
|
||||
*/
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Event name must be between 3 and 100 characters'),
|
||||
body('resendEmail')
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage('resendEmail must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
const { newEventName, resendEmail = false } = req.body;
|
||||
|
||||
const result = await eventRenameService.renameEvent(
|
||||
parseInt(eventId, 10),
|
||||
newEventName,
|
||||
resendEmail,
|
||||
req.admin
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Event renamed successfully',
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error renaming event:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to rename event' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/events/:eventId/validate-rename
|
||||
* Validate a potential rename without executing it
|
||||
*/
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Event name must be between 3 and 100 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ valid: false, errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventId } = req.params;
|
||||
const { newEventName } = req.body;
|
||||
|
||||
const validation = await eventRenameService.validateRename(
|
||||
parseInt(eventId, 10),
|
||||
newEventName
|
||||
);
|
||||
|
||||
res.json(validation);
|
||||
} catch (error) {
|
||||
console.error('Error validating rename:', error);
|
||||
res.status(500).json({ valid: false, error: 'Validation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,13 +2,15 @@
|
||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').notEmpty(), // Remove the weak isLength validation
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
@@ -16,7 +18,7 @@ router.post('/', adminAuth, [
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim()
|
||||
body('customer_name').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
@@ -30,8 +32,8 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -65,9 +67,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link based on configured style
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
@@ -88,13 +90,16 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
@@ -121,4 +126,4 @@ router.post('/', adminAuth, [
|
||||
console.error('Error creating event:', error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ const express = require('express');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
@@ -13,40 +14,152 @@ const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
value = value === 'true';
|
||||
}
|
||||
}
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get event field requirements', { error: error.message });
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
const normalizeBoolean = (val, defaultValue = true) => {
|
||||
if (val === undefined || val === null) return defaultValue;
|
||||
if (typeof val === 'boolean') return val;
|
||||
if (typeof val === 'number') return val !== 0;
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const requirePassword = normalizeBoolean(input, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim(),
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
body('watermark_text').optional().trim(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
|
||||
// Get field requirements from settings
|
||||
const fieldRequirements = await getEventFieldRequirements();
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -58,6 +171,7 @@ router.post('/', adminAuth, [
|
||||
disable_right_click = false,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
require_password: requirePasswordInput = true,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
allow_ratings = true,
|
||||
@@ -66,15 +180,41 @@ router.post('/', adminAuth, [
|
||||
allow_favorites = true,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
css_template_id = null
|
||||
} = req.body;
|
||||
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Conditional validation based on settings
|
||||
const validationErrors = [];
|
||||
if (fieldRequirements.require_customer_name && !customerName) {
|
||||
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
|
||||
}
|
||||
if (fieldRequirements.require_customer_email && !customerEmail) {
|
||||
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_admin_email && !admin_email) {
|
||||
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ errors: validationErrors });
|
||||
}
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
// Debug logging
|
||||
console.log('Download control values:', {
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
require_password: requirePassword,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
@@ -82,18 +222,21 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
let passwordValidation = null;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -111,12 +254,14 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
@@ -141,21 +286,26 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
upload_category_id,
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -180,7 +330,7 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at },
|
||||
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -190,14 +340,16 @@ router.post('/', adminAuth, [
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: host_email,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
host_name: host_name,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
@@ -211,7 +363,10 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
share_link: shareLink,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
@@ -222,7 +377,7 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
// Get all events with pagination and filters
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -234,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
|
||||
// Build query
|
||||
let query = db('events');
|
||||
|
||||
|
||||
// Editor role can only see their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
query = query.where('created_by', req.admin.id);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
@@ -294,7 +454,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
|
||||
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
|
||||
}));
|
||||
})).map(mapEventForApi);
|
||||
|
||||
res.json({
|
||||
events: eventsWithCounts,
|
||||
@@ -312,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get single event details
|
||||
router.get('/:id', adminAuth, async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
let query = db('events').where('id', id);
|
||||
|
||||
// Editor role can only see their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
query = query.where('created_by', req.admin.id);
|
||||
}
|
||||
|
||||
const event = await query.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
@@ -356,7 +521,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
.where('event_id', id)
|
||||
.countDistinct('ip_address as uniqueVisitors');
|
||||
|
||||
res.json({
|
||||
res.json(mapEventForApi({
|
||||
...event,
|
||||
photo_count: parseInt(photoCount) || 0,
|
||||
total_size: parseInt(totalSize) || 0,
|
||||
@@ -364,7 +529,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
total_downloads: parseInt(totalDownloads) || 0,
|
||||
unique_visitors: parseInt(uniqueVisitors) || 0,
|
||||
recent_photos: recentPhotos
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch event details' });
|
||||
@@ -372,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, [
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
body('admin_email').optional().isEmail(),
|
||||
body('is_active').optional().isBoolean(),
|
||||
@@ -380,7 +545,8 @@ router.put('/:id', adminAuth, [
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
body('host_name').optional().trim().notEmpty(),
|
||||
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('upload_category_id').optional().custom((value) => {
|
||||
// Accept null, undefined, or integer values
|
||||
if (value === null || value === undefined) return true;
|
||||
@@ -396,21 +562,107 @@ router.put('/:id', adminAuth, [
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
body('watermark_text').optional().trim(),
|
||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
// Download protection settings
|
||||
body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']),
|
||||
body('enable_devtools_protection').optional().isBoolean(),
|
||||
body('use_canvas_rendering').optional().isBoolean(),
|
||||
body('overlay_protection').optional().isBoolean(),
|
||||
body('image_quality').optional().isInt({ min: 1, max: 100 }),
|
||||
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
||||
console.log('Request body:', req.body);
|
||||
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
|
||||
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
|
||||
updates.external_path = trimmedPath || null;
|
||||
}
|
||||
|
||||
if (updates.source_mode === 'managed') {
|
||||
updates.external_path = null;
|
||||
}
|
||||
|
||||
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Log the update request for debugging
|
||||
console.log('Update event request:', {
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
updates,
|
||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||
@@ -420,11 +672,28 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Check if event exists
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -445,7 +714,7 @@ router.put('/:id', adminAuth, [
|
||||
});
|
||||
|
||||
// Delete event
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -515,24 +784,27 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
// Provide more specific error messages
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||
details: error.message
|
||||
error: 'Cannot delete event due to existing references. Please contact support.'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
error: 'Failed to delete event'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle event status
|
||||
router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -563,12 +835,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -598,10 +875,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
// For password reset, we'll need to create a template or use a different approach
|
||||
// For now, let's use the gallery_created template with updated password
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -622,15 +902,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
// Get event details
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
@@ -656,8 +939,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -672,7 +960,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: event.host_email,
|
||||
recipient: recipientEmail,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
@@ -697,7 +985,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -728,7 +1016,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk archive events
|
||||
router.post('/bulk-archive', adminAuth, [
|
||||
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
@@ -780,7 +1068,7 @@ router.post('/bulk-archive', adminAuth, [
|
||||
results.failed.push({
|
||||
id: event.id,
|
||||
name: event.event_name,
|
||||
error: error.message
|
||||
error: 'Failed to archive event. Check server logs for details.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -806,4 +1094,4 @@ router.post('/bulk-archive', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/admin/external-media/list?path=relative/dir
|
||||
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
||||
const result = await list(relPath);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.warn('Invalid external media path requested', {
|
||||
path: req.query.path,
|
||||
error: error.message
|
||||
});
|
||||
res.status(400).json({ error: 'Invalid external media path' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to recursively collect files under a directory, filtered by image extensions
|
||||
async function walkDir(dir, baseDir) {
|
||||
const results = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
results.push(...await walkDir(full, baseDir));
|
||||
} else if (e.isFile()) {
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
|
||||
const rel = path.relative(baseDir, full);
|
||||
results.push({ full, rel, name: e.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// POST /api/admin/events/:id/import-external
|
||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.id);
|
||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||
if (!external_path) return res.status(400).json({ error: 'external_path is required' });
|
||||
|
||||
// Load event
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const baseAbs = resolveExternalPath({ external_path }, '');
|
||||
// Collect files
|
||||
const files = recursive ? await walkDir(baseAbs, baseAbs) : (await fs.readdir(baseAbs, { withFileTypes: true }))
|
||||
.filter(e => e.isFile())
|
||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||
|
||||
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||
let skipped = 0;
|
||||
const preparedFiles = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const stats = await fs.stat(f.full);
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
if (segs[0] === map.collages) type = 'collage';
|
||||
if (segs[0] === map.individual) type = 'individual';
|
||||
preparedFiles.push({ ...f, type, size: stats.size });
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupeMap = new Map();
|
||||
for (const file of preparedFiles) {
|
||||
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||
const existing = dedupeMap.get(dedupeKey);
|
||||
if (!existing || file.size > existing.size) {
|
||||
if (existing) skipped++;
|
||||
dedupeMap.set(dedupeKey, file);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
|
||||
// Insert photos
|
||||
for (const f of dedupeMap.values()) {
|
||||
// Infer type by subfolder names
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
if (segs[0] === map.collages) type = 'collage';
|
||||
if (segs[0] === map.individual) type = 'individual';
|
||||
|
||||
try {
|
||||
// Check if already exists (by external_relpath)
|
||||
const exists = await db('photos')
|
||||
.where({ event_id: eventId, external_relpath: f.rel })
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
const stats = await fs.stat(f.full);
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
filename: f.name,
|
||||
// Keep path as a hint for legacy code but not used for resolution in external mode
|
||||
path: path.join(event.slug, f.name),
|
||||
thumbnail_path: null,
|
||||
type,
|
||||
size_bytes: stats.size,
|
||||
source_origin: 'external',
|
||||
external_relpath: f.rel
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
imported += (inserted?.length ? 1 : 0);
|
||||
} catch (e) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update event fields
|
||||
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
|
||||
|
||||
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed
|
||||
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
|
||||
|
||||
res.json({ imported, skipped, thumbnailsQueued: 0 });
|
||||
} catch (error) {
|
||||
logger.error('External media import failed', {
|
||||
eventId: req.params.id,
|
||||
externalPath: req.body?.external_path,
|
||||
error: error.message
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to import external media' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackModeration = require('../services/feedbackModeration');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -13,8 +14,9 @@ const {
|
||||
} = require('../utils/feedbackValidation');
|
||||
|
||||
// Get event feedback settings
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
|
||||
// Update event feedback settings
|
||||
router.put('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
validateEventId,
|
||||
validateFeedbackSettings,
|
||||
checkValidation,
|
||||
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
|
||||
// Get feedback for an event (with filters)
|
||||
router.get('/events/:eventId/feedback',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
|
||||
// Moderate feedback (approve/hide/reject)
|
||||
router.put('/feedback/:feedbackId/:action',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId, action } = req.params;
|
||||
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
|
||||
// Delete feedback
|
||||
router.delete('/feedback/:feedbackId',
|
||||
adminAuth,
|
||||
requirePermission('events.delete'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { feedbackId } = req.params;
|
||||
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
|
||||
// Get feedback analytics for an event
|
||||
router.get('/events/:eventId/feedback-analytics',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
// Export feedback data
|
||||
router.get('/events/:eventId/feedback/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
|
||||
// Get pending moderation items (across all events)
|
||||
router.get('/feedback/pending-moderation',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const pending = await feedbackService.getPendingModeration();
|
||||
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
|
||||
// Word filter management
|
||||
router.get('/word-filters',
|
||||
adminAuth,
|
||||
requirePermission('settings.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const filters = await feedbackModeration.getAllWordFilters();
|
||||
@@ -351,6 +361,7 @@ router.get('/word-filters',
|
||||
|
||||
router.post('/word-filters',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
validateWordFilter,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -368,7 +379,7 @@ router.post('/word-filters',
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.message === 'Word filter already exists') {
|
||||
return res.status(409).json({ error: error.message });
|
||||
return res.status(409).json({ error: 'Word filter already exists' });
|
||||
}
|
||||
logger.error('Error adding word filter:', error);
|
||||
res.status(500).json({ error: 'Failed to add word filter' });
|
||||
@@ -378,6 +389,7 @@ router.post('/word-filters',
|
||||
|
||||
router.put('/word-filters/:id',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
|
||||
|
||||
router.delete('/word-filters/:id',
|
||||
adminAuth,
|
||||
requirePermission('settings.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -430,4 +443,4 @@ function convertToCSV(data) {
|
||||
return [csvHeaders, ...csvRows].join('\n');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -9,7 +10,7 @@ const router = express.Router();
|
||||
/**
|
||||
* Get image security settings
|
||||
*/
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
@@ -31,20 +32,22 @@ router.get('/settings', adminAuth, async (req, res) => {
|
||||
|
||||
const config = {};
|
||||
settings.forEach(setting => {
|
||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
// PostgreSQL JSON columns are already parsed by the driver
|
||||
// Just use the value directly - no need to JSON.parse
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
});
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
logger.error('Error getting image security settings', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to get security settings' });
|
||||
logger.error('Error getting image security settings', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Failed to get security settings', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update image security settings
|
||||
*/
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
@@ -95,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get security monitoring dashboard data
|
||||
*/
|
||||
router.get('/dashboard', adminAuth, async (req, res) => {
|
||||
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { timeframe = '24h' } = req.query;
|
||||
|
||||
@@ -200,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get detailed security logs
|
||||
*/
|
||||
router.get('/logs', adminAuth, async (req, res) => {
|
||||
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
@@ -269,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Get image access logs for a specific event
|
||||
*/
|
||||
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
||||
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { page = 1, limit = 50 } = req.query;
|
||||
@@ -319,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Block/unblock suspicious IPs
|
||||
*/
|
||||
router.post('/block-ip', adminAuth, async (req, res) => {
|
||||
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { ip, action = 'block' } = req.body;
|
||||
|
||||
@@ -365,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Clear security logs older than specified time
|
||||
*/
|
||||
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
||||
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { olderThan = '30d' } = req.body;
|
||||
|
||||
@@ -422,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
||||
/**
|
||||
* Export security data for analysis
|
||||
*/
|
||||
router.get('/export', adminAuth, async (req, res) => {
|
||||
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { format = 'json', timeframe = '7d' } = req.query;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const router = express.Router();
|
||||
|
||||
// Get notifications (unread activity logs)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { limit = 20, includeRead = false } = req.query;
|
||||
|
||||
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
router.put('/:id/read', adminAuth, async (req, res) => {
|
||||
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Mark all notifications as read
|
||||
router.put('/read-all', adminAuth, async (req, res) => {
|
||||
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
await db('activity_logs')
|
||||
.whereNull('read_at')
|
||||
@@ -98,19 +99,56 @@ router.put('/read-all', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete old notifications (older than 30 days and read)
|
||||
router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
// Use database-agnostic date calculation
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const deletedCount = await db('activity_logs')
|
||||
.whereNotNull('read_at')
|
||||
.where('created_at', '<', thirtyDaysAgo)
|
||||
.delete();
|
||||
|
||||
let deletedCount = 0;
|
||||
const client = db?.client?.config?.client;
|
||||
|
||||
if (client === 'pg') {
|
||||
const primaryResult = await db.raw(
|
||||
`
|
||||
WITH deleted AS (
|
||||
DELETE FROM activity_logs
|
||||
WHERE read_at IS NOT NULL OR created_at < ?
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*)::int AS count FROM deleted
|
||||
`,
|
||||
[thirtyDaysAgo.toISOString()]
|
||||
);
|
||||
deletedCount = primaryResult.rows?.[0]?.count || 0;
|
||||
|
||||
if (deletedCount === 0) {
|
||||
const fallbackResult = await db.raw(
|
||||
`
|
||||
WITH deleted AS (
|
||||
DELETE FROM activity_logs
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*)::int AS count FROM deleted
|
||||
`
|
||||
);
|
||||
deletedCount = fallbackResult.rows?.[0]?.count || 0;
|
||||
}
|
||||
} else {
|
||||
deletedCount = await db('activity_logs')
|
||||
.where(function () {
|
||||
this.whereNotNull('read_at')
|
||||
.orWhere('created_at', '<', thirtyDaysAgo);
|
||||
})
|
||||
.delete();
|
||||
|
||||
if (deletedCount === 0) {
|
||||
deletedCount = await db('activity_logs').delete();
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Old notifications cleared',
|
||||
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -119,4 +157,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Admin Photo Export Routes
|
||||
* Handles filtering and exporting photos based on guest feedback
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
|
||||
const exportService = new PhotoExportService();
|
||||
|
||||
/**
|
||||
* GET /admin/photos/:eventId/filtered
|
||||
* Get filtered photos with pagination
|
||||
*/
|
||||
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||
query('has_likes').optional().isBoolean(),
|
||||
query('min_likes').optional().isInt({ min: 0 }),
|
||||
query('has_favorites').optional().isBoolean(),
|
||||
query('min_favorites').optional().isInt({ min: 0 }),
|
||||
query('has_comments').optional().isBoolean(),
|
||||
query('category_id').optional().isInt(),
|
||||
query('logic').optional().isIn(['AND', 'OR']),
|
||||
query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']),
|
||||
query('order').optional().isIn(['asc', 'desc']),
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
// Verify event exists
|
||||
const event = await withRetry(() =>
|
||||
db('events').where('id', eventId).first()
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Parse filter params
|
||||
const filters = {
|
||||
min_rating: req.query.min_rating ? parseFloat(req.query.min_rating) : undefined,
|
||||
max_rating: req.query.max_rating ? parseFloat(req.query.max_rating) : undefined,
|
||||
has_likes: req.query.has_likes,
|
||||
min_likes: req.query.min_likes ? parseInt(req.query.min_likes) : undefined,
|
||||
has_favorites: req.query.has_favorites,
|
||||
min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined,
|
||||
has_comments: req.query.has_comments,
|
||||
category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined,
|
||||
logic: req.query.logic || 'AND'
|
||||
};
|
||||
|
||||
const sort = req.query.sort || 'date';
|
||||
const order = req.query.order || 'desc';
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
'photos.favorite_count',
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
),
|
||||
eventId
|
||||
);
|
||||
|
||||
filterBuilder
|
||||
.applyFilters(filters)
|
||||
.applySorting(sort, order)
|
||||
.applyPagination(page, limit);
|
||||
|
||||
const photos = await withRetry(() => filterBuilder.getQuery());
|
||||
|
||||
// Get count of filtered photos
|
||||
const countResult = await withRetry(() =>
|
||||
PhotoFilterBuilder.buildCountQuery(db, eventId, filters)
|
||||
);
|
||||
const filteredCount = parseInt(countResult[0]?.count) || 0;
|
||||
|
||||
// Get summary counts
|
||||
const summary = await withRetry(() =>
|
||||
PhotoFilterBuilder.getSummary(db, eventId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
photos,
|
||||
pagination: {
|
||||
total: summary.total,
|
||||
filtered: filteredCount,
|
||||
page,
|
||||
limit,
|
||||
pages: Math.ceil(filteredCount / limit)
|
||||
},
|
||||
summary
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Filter photos error:', error);
|
||||
res.status(500).json({ error: 'Failed to filter photos' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/photos/:eventId/filter-summary
|
||||
* Get just the summary counts for filter UI
|
||||
*/
|
||||
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
const summary = await withRetry(() =>
|
||||
PhotoFilterBuilder.getSummary(db, eventId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Filter summary error:', error);
|
||||
res.status(500).json({ error: 'Failed to get filter summary' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/photos/:eventId/export
|
||||
* Export selected or filtered photos
|
||||
*/
|
||||
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
|
||||
body('photo_ids').optional().isArray(),
|
||||
body('photo_ids.*').optional().isInt(),
|
||||
body('filter').optional().isObject(),
|
||||
body('format').isIn(['txt', 'csv', 'xmp', 'json']),
|
||||
body('options').optional().isObject()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
const { photo_ids, filter, format, options = {} } = req.body;
|
||||
|
||||
// Verify event exists
|
||||
const event = await withRetry(() =>
|
||||
db('events').where('id', eventId).first()
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// If filter provided instead of photo_ids, get matching photo IDs
|
||||
let photoIds = photo_ids;
|
||||
|
||||
if (!photoIds && filter) {
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos').select('id'),
|
||||
eventId
|
||||
);
|
||||
filterBuilder.applyFilters(filter);
|
||||
const filteredPhotos = await withRetry(() => filterBuilder.getQuery());
|
||||
photoIds = filteredPhotos.map(p => p.id);
|
||||
}
|
||||
|
||||
// Export photos
|
||||
const result = await exportService.exportPhotos(eventId, photoIds, format, options);
|
||||
|
||||
if (result.type === 'stream') {
|
||||
res.setHeader('Content-Type', result.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
result.stream.pipe(res);
|
||||
} else {
|
||||
res.setHeader('Content-Type', result.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
res.send(result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export photos error:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to export photos' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/photos/export-formats
|
||||
* Get available export format options
|
||||
*/
|
||||
router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: PhotoExportService.getFormatOptions()
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,10 +4,14 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -47,8 +51,8 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
files: 500, // Maximum 500 files
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
@@ -56,13 +60,16 @@ const upload = multer({
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only with proper validation
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
|
||||
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
@@ -73,8 +80,11 @@ const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
maxFileSize: 50 * 1024 * 1024,
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
|
||||
@@ -99,17 +109,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
};
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve max files per upload:', error);
|
||||
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||
}
|
||||
|
||||
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||
}
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
@@ -159,21 +177,23 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
|
||||
// Determine photo type and category name
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
|
||||
// For backwards compatibility, accept string values
|
||||
if (category_id === 'collage') {
|
||||
|
||||
// Look up the actual category from database if provided
|
||||
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
|
||||
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
||||
if (category) {
|
||||
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
|
||||
// Use category slug for type determination
|
||||
if (category.slug === 'collage' || category.slug === 'collages') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
}
|
||||
} else if (category_id === 'collage') {
|
||||
// For backwards compatibility, accept string values
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
}
|
||||
@@ -239,6 +259,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId, // Save the selected category
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
@@ -403,7 +424,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
});
|
||||
|
||||
// Delete a photo
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -460,26 +481,55 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a photo (e.g., change category)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
updateData.type = category_id;
|
||||
updateData.category_id = null; // Clear legacy category_id
|
||||
} else if (category_id === null || category_id === undefined) {
|
||||
// Explicitly clear category
|
||||
updateData.category_id = null;
|
||||
} else {
|
||||
// Handle numeric category IDs from photo_categories table
|
||||
const numericCategoryId = parseInt(category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
updateData.category_id = numericCategoryId;
|
||||
} else {
|
||||
updateData.category_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update photo
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ category_id: category_id || null });
|
||||
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.update(updateData);
|
||||
|
||||
// Fetch and return the updated photo
|
||||
const updatedPhoto = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
message: 'Photo updated successfully',
|
||||
photo: updatedPhoto
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
@@ -487,7 +537,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk delete photos
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds } = req.body;
|
||||
@@ -555,7 +605,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Bulk update photos
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
@@ -571,21 +621,40 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount.count !== photoIds.length) {
|
||||
if (parseInt(photoCount.count) !== photoIds.length) {
|
||||
return res.status(400).json({ error: 'Some photos do not belong to this event' });
|
||||
}
|
||||
|
||||
// Update photos
|
||||
const updateData = {};
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
updateData.category_id = updates.category_id || null;
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
|
||||
updateData.type = updates.category_id;
|
||||
updateData.category_id = null; // Clear legacy category_id
|
||||
} else if (updates.category_id === null) {
|
||||
// Explicitly clear category
|
||||
updateData.category_id = null;
|
||||
} else {
|
||||
// Handle numeric category IDs from photo_categories table
|
||||
const numericCategoryId = parseInt(updates.category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
updateData.category_id = numericCategoryId;
|
||||
} else {
|
||||
updateData.category_id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.update(updateData);
|
||||
|
||||
|
||||
res.json({ message: `${photoIds.length} photos updated successfully` });
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating photos:', error);
|
||||
@@ -594,7 +663,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Download a photo
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -606,8 +675,9 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const filePath = resolvePhotoFilePath(event, photo);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
@@ -625,14 +695,15 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
||||
});
|
||||
|
||||
// Get all photos for an event
|
||||
router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select('photos.*');
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
@@ -684,12 +755,14 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.pc_slug || photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
@@ -707,7 +780,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve photo with admin authentication
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -719,8 +792,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const filePath = resolvePhotoFilePath(event, photo);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
@@ -743,7 +817,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Serve thumbnail with admin authentication
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -783,7 +857,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -798,8 +872,147 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
storagePath: getStoragePath()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('Error fetching admin photo debug data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photo debug data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// ============================================
|
||||
// CHUNKED UPLOAD ENDPOINTS
|
||||
// For large file uploads (videos up to 10GB)
|
||||
// ============================================
|
||||
|
||||
// Initialize a chunked upload
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
|
||||
// Validate event exists
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!filename || !fileSize || !mimeType) {
|
||||
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
|
||||
}
|
||||
|
||||
// Validate file size (max 10GB)
|
||||
const maxSize = 10 * 1024 * 1024 * 1024;
|
||||
if (fileSize > maxSize) {
|
||||
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
|
||||
}
|
||||
|
||||
const result = await chunkedUpload.initializeUpload({
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId: parseInt(eventId),
|
||||
totalChunks
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error initializing chunked upload:', error);
|
||||
res.status(500).json({ error: 'Failed to initialize upload' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload a chunk
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
// Get chunk data from request body
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const chunkData = Buffer.concat(chunks);
|
||||
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error uploading chunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||
}
|
||||
});
|
||||
|
||||
// Complete chunked upload and process the file
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
try {
|
||||
const { eventId, uploadId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
|
||||
// Complete the chunked upload (merge chunks)
|
||||
const mergedFile = await chunkedUpload.completeUpload(uploadId);
|
||||
|
||||
// Process the merged file as a regular upload
|
||||
const fileObj = {
|
||||
originalname: mergedFile.filename,
|
||||
mimetype: mergedFile.mimeType,
|
||||
size: mergedFile.size,
|
||||
path: mergedFile.path
|
||||
};
|
||||
|
||||
const uploadedPhotos = await processUploadedPhotos(
|
||||
[fileObj],
|
||||
parseInt(eventId),
|
||||
'admin',
|
||||
category_id || null
|
||||
);
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
uploaded: uploadedPhotos.length,
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get upload status
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
const status = chunkedUpload.getUploadStatus(uploadId);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({ error: 'Upload not found or expired' });
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to get upload status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Abort chunked upload
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
await chunkedUpload.abortUpload(uploadId);
|
||||
|
||||
res.json({ success: true, message: 'Upload aborted' });
|
||||
} catch (error) {
|
||||
console.error('Error aborting upload:', error);
|
||||
res.status(500).json({ error: 'Failed to abort upload' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { restoreService } = require('../services/restoreService');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const logger = require('../utils/logger');
|
||||
const { db } = require('../database/db');
|
||||
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
|
||||
// Apply admin authentication to all routes
|
||||
router.use(adminAuth);
|
||||
|
||||
/**
|
||||
* Transform frontend S3 config to backend format
|
||||
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
|
||||
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
|
||||
*/
|
||||
function transformS3Config(body) {
|
||||
if (body.s3Config) {
|
||||
// Already in correct format
|
||||
return body.s3Config;
|
||||
}
|
||||
|
||||
// Check if frontend sent flat S3 config fields
|
||||
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
|
||||
return {
|
||||
endpoint: body.s3Endpoint,
|
||||
bucket: body.s3Bucket,
|
||||
accessKeyId: body.s3AccessKey,
|
||||
secretAccessKey: body.s3SecretKey,
|
||||
region: body.s3Region || 'us-east-1',
|
||||
forcePathStyle: body.s3ForcePathStyle !== false
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get restore service status and history
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const history = await restoreService.getRestoreHistory(limit);
|
||||
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
|
||||
/**
|
||||
* Validate restore request
|
||||
*/
|
||||
router.post('/validate', [
|
||||
router.post('/validate', requirePermission('backup.restore'), [
|
||||
body('source').notEmpty().withMessage('Backup source is required'),
|
||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||
@@ -63,18 +90,38 @@ router.post('/validate', [
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform S3 config from frontend format
|
||||
const s3Config = transformS3Config(req.body);
|
||||
|
||||
// Perform dry run validation
|
||||
const result = await restoreService.restore({
|
||||
...req.body,
|
||||
source: req.body.source,
|
||||
manifestPath: req.body.manifestPath,
|
||||
restoreType: req.body.restoreType,
|
||||
selectedItems: req.body.selectedItems,
|
||||
s3Config,
|
||||
dryRun: true,
|
||||
force: false
|
||||
});
|
||||
|
||||
|
||||
// Transform spaceCheck to match frontend expected format
|
||||
const spaceCheck = result.spaceCheck ? {
|
||||
sufficient: result.spaceCheck.hasEnoughSpace,
|
||||
required: result.spaceCheck.requiredBytes,
|
||||
available: result.spaceCheck.availableBytes,
|
||||
requiredFormatted: result.spaceCheck.requiredFormatted,
|
||||
availableFormatted: result.spaceCheck.availableFormatted,
|
||||
// Keep original fields for backwards compatibility
|
||||
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
|
||||
requiredBytes: result.spaceCheck.requiredBytes,
|
||||
availableBytes: result.spaceCheck.availableBytes
|
||||
} : null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
validation: result.validation,
|
||||
spaceCheck: result.spaceCheck,
|
||||
spaceCheck,
|
||||
logs: result.logs
|
||||
}
|
||||
});
|
||||
@@ -82,7 +129,7 @@ router.post('/validate', [
|
||||
logger.error('Restore validation failed:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
error: error.message || 'Restore validation failed',
|
||||
logs: restoreService.restoreLog
|
||||
});
|
||||
}
|
||||
@@ -91,7 +138,7 @@ router.post('/validate', [
|
||||
/**
|
||||
* Start restore operation
|
||||
*/
|
||||
router.post('/start', [
|
||||
router.post('/start', requirePermission('backup.restore'), [
|
||||
body('source').notEmpty().withMessage('Backup source is required'),
|
||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||
@@ -135,19 +182,28 @@ router.post('/start', [
|
||||
|
||||
// Log restore attempt
|
||||
logger.warn('Restore operation started', {
|
||||
user: req.user.email,
|
||||
user: req.admin.email,
|
||||
ip: req.ip,
|
||||
restoreType: req.body.restoreType,
|
||||
source: req.body.source
|
||||
});
|
||||
|
||||
// Transform S3 config from frontend format
|
||||
const s3Config = transformS3Config(req.body);
|
||||
|
||||
// Start restore in background
|
||||
restoreService.restore({
|
||||
...req.body,
|
||||
source: req.body.source,
|
||||
manifestPath: req.body.manifestPath,
|
||||
restoreType: req.body.restoreType,
|
||||
selectedItems: req.body.selectedItems,
|
||||
skipPreBackup: req.body.skipPreBackup,
|
||||
force: req.body.force,
|
||||
s3Config,
|
||||
dryRun: false,
|
||||
operator: {
|
||||
type: 'manual',
|
||||
userId: req.user.id,
|
||||
userId: req.admin.id,
|
||||
ip: req.ip
|
||||
}
|
||||
}).catch(error => {
|
||||
@@ -160,9 +216,10 @@ router.post('/start', [
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start restore:', error);
|
||||
logger.error('Error stack:', error.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: error.message || 'Failed to start restore operation'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -170,7 +227,7 @@ router.post('/start', [
|
||||
/**
|
||||
* Get current restore progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const progress = restoreService.getProgress();
|
||||
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
|
||||
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
|
||||
/**
|
||||
* Get restore run details
|
||||
*/
|
||||
router.get('/run/:id', async (req, res) => {
|
||||
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const run = await db('restore_runs')
|
||||
.where('id', req.params.id)
|
||||
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
|
||||
/**
|
||||
* Get restore run report
|
||||
*/
|
||||
router.get('/run/:id/report', async (req, res) => {
|
||||
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const run = await db('restore_runs')
|
||||
.where('id', req.params.id)
|
||||
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
|
||||
/**
|
||||
* List available backups for restore
|
||||
*/
|
||||
router.get('/available-backups', async (req, res) => {
|
||||
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const backups = [];
|
||||
|
||||
@@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List backups for restore (POST version for frontend compatibility)
|
||||
* Accepts source type in request body
|
||||
*/
|
||||
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { source } = req.body; // 'local', 's3', or undefined for all
|
||||
const backups = [];
|
||||
|
||||
// Get backup configuration
|
||||
const backupConfig = await getBackupConfig();
|
||||
|
||||
// Get database backups from backup_runs table
|
||||
const backupRuns = await db('backup_runs')
|
||||
.where('status', 'completed')
|
||||
.whereNotNull('manifest_path')
|
||||
.orderBy('completed_at', 'desc')
|
||||
.limit(20);
|
||||
|
||||
for (const run of backupRuns) {
|
||||
const isS3 = run.manifest_path.startsWith('s3://');
|
||||
const backupType = isS3 ? 's3' : 'local';
|
||||
|
||||
// Filter by source if specified
|
||||
if (source && source !== backupType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
backups.push({
|
||||
id: run.id,
|
||||
type: backupType,
|
||||
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
|
||||
path: run.manifest_path,
|
||||
manifest_path: run.manifest_path,
|
||||
manifestId: run.manifest_id,
|
||||
manifestPath: run.manifest_path,
|
||||
size: parseInt(run.total_size_bytes) || 0,
|
||||
total_size: parseInt(run.total_size_bytes) || 0,
|
||||
total_size_bytes: parseInt(run.total_size_bytes) || 0,
|
||||
filesCount: run.files_backed_up || 0,
|
||||
files_backed_up: run.files_backed_up || 0,
|
||||
duration: run.duration_seconds,
|
||||
duration_seconds: run.duration_seconds,
|
||||
// Frontend expects snake_case date fields
|
||||
created_at: run.completed_at,
|
||||
completed_at: run.completed_at,
|
||||
started_at: run.started_at,
|
||||
// camelCase aliases
|
||||
completedAt: run.completed_at,
|
||||
startedAt: run.started_at,
|
||||
// Backup metadata
|
||||
status: run.status,
|
||||
backup_type: run.backup_type,
|
||||
backupType: run.backup_type,
|
||||
backup_mode: run.backup_mode,
|
||||
backupMode: run.backup_mode,
|
||||
app_version: run.app_version,
|
||||
appVersion: run.app_version
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: backups,
|
||||
source: source || 'all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list backups for restore:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to list backups for restore'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get restore settings
|
||||
*/
|
||||
router.get('/settings', async (req, res) => {
|
||||
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await getRestoreSettings();
|
||||
res.json({
|
||||
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
|
||||
/**
|
||||
* Update restore settings
|
||||
*/
|
||||
router.put('/settings', [
|
||||
router.put('/settings', requirePermission('backup.restore'), [
|
||||
body('restore_allow_force').optional().isBoolean(),
|
||||
body('restore_require_pre_backup').optional().isBoolean(),
|
||||
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
|
||||
@@ -458,4 +590,4 @@ async function getBackupConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,14 +6,30 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
} = require('../constants/publicSiteDefaults');
|
||||
const {
|
||||
clearPublicSiteCache,
|
||||
getDefaultPublicSitePayload,
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for logo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -43,7 +59,7 @@ const upload = multer({
|
||||
// Configure multer for favicon uploads
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -77,7 +93,7 @@ const faviconUpload = multer({
|
||||
});
|
||||
|
||||
// Get all settings
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings').select('*');
|
||||
|
||||
@@ -105,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get settings by type
|
||||
router.get('/:type', adminAuth, async (req, res) => {
|
||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const settings = await db('app_settings')
|
||||
@@ -136,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get password complexity settings for frontend
|
||||
router.get('/password/complexity', adminAuth, async (req, res) => {
|
||||
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
||||
|
||||
@@ -157,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update branding settings
|
||||
router.put('/branding', adminAuth, async (req, res) => {
|
||||
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
company_name,
|
||||
@@ -176,7 +192,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
logo_position,
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode
|
||||
logo_display_mode,
|
||||
hide_powered_by
|
||||
} = req.body;
|
||||
|
||||
const brandingSettings = {
|
||||
@@ -196,7 +213,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
logo_position,
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode
|
||||
logo_display_mode,
|
||||
hide_powered_by
|
||||
};
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
@@ -218,7 +236,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||
// Delete the file from filesystem
|
||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
console.log('Deleted favicon file:', faviconPath);
|
||||
@@ -248,7 +267,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||
// Delete the file from filesystem
|
||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
console.log('Deleted logo file:', logoPath);
|
||||
@@ -284,6 +304,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ company_name })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
@@ -292,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload logo
|
||||
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||
@@ -304,8 +326,12 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
.first();
|
||||
|
||||
if (oldLogoSetting && oldLogoSetting.setting_value) {
|
||||
const oldPath = JSON.parse(oldLogoSetting.setting_value);
|
||||
try {
|
||||
// Handle both JSON-serialized and legacy raw path values
|
||||
let oldPath = oldLogoSetting.setting_value;
|
||||
if (oldPath.startsWith('"')) {
|
||||
oldPath = JSON.parse(oldPath);
|
||||
}
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old logo:', error);
|
||||
@@ -333,13 +359,13 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_logo_url',
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
@@ -354,7 +380,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload watermark logo
|
||||
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
|
||||
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
@@ -366,11 +392,21 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
|
||||
.first();
|
||||
|
||||
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
|
||||
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
|
||||
let oldPath;
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
// Try to parse as JSON first (for JSON-stringified paths)
|
||||
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
oldPath = oldWatermarkLogoSetting.setting_value;
|
||||
}
|
||||
|
||||
if (oldPath && typeof oldPath === 'string') {
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,13 +431,13 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_watermark_logo_url',
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
@@ -416,7 +452,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
|
||||
});
|
||||
|
||||
// Update theme settings
|
||||
router.put('/theme', adminAuth, async (req, res) => {
|
||||
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const themeSettings = req.body;
|
||||
|
||||
@@ -443,6 +479,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Theme update error:', error);
|
||||
@@ -451,9 +489,56 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||||
uploadLimitTouched = true;
|
||||
const rawValue = Number(settings.general_max_files_per_upload);
|
||||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||||
|
||||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return res.status(400).json({
|
||||
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||||
});
|
||||
}
|
||||
|
||||
settings.general_max_files_per_upload = normalizedValue;
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
|
||||
settings.general_public_site_html = settings.general_public_site_html.trim();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
|
||||
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
|
||||
}
|
||||
|
||||
const enableToggle = settings.general_public_site_enabled;
|
||||
if (enableToggle === true) {
|
||||
let htmlValue = settings.general_public_site_html;
|
||||
|
||||
if (htmlValue === undefined) {
|
||||
const currentSettings = await getRawPublicSiteSettings();
|
||||
htmlValue = currentSettings.general_public_site_html;
|
||||
}
|
||||
|
||||
if (!htmlValue || !String(htmlValue).trim()) {
|
||||
return res.status(400).json({
|
||||
error: 'Public site HTML must be provided before enabling the public landing page.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -476,6 +561,16 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
if (uploadLimitTouched) {
|
||||
clearMaxFilesPerUploadCache();
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
clearShareLinkSettingsCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'general_settings_updated',
|
||||
@@ -493,7 +588,7 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update security settings
|
||||
router.put('/security', adminAuth, async (req, res) => {
|
||||
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
@@ -513,6 +608,8 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
resetSecurityConfigCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'security_settings_updated',
|
||||
@@ -530,7 +627,7 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, async (req, res) => {
|
||||
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
@@ -567,7 +664,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Get total storage used
|
||||
const totalStorage = await db('photos')
|
||||
@@ -593,7 +690,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const storagePath = getStoragePath();
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
@@ -603,11 +700,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
let diskStats = null;
|
||||
let rawDiskTotal = null;
|
||||
let rawDiskFree = null;
|
||||
let rawDiskAvailable = null;
|
||||
try {
|
||||
diskStats = await fs.statfs(storagePath);
|
||||
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
|
||||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||
} catch (diskError) {
|
||||
console.error('Disk stats error:', diskError.message);
|
||||
}
|
||||
|
||||
const clampDiskValue = (value) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Treat unusually large virtualised values as unreliable (>50TB)
|
||||
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
|
||||
if (value > MAX_REASONABLE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
let diskTotal = null;
|
||||
let diskFree = null;
|
||||
let diskAvailable = null;
|
||||
|
||||
if (diskStats) {
|
||||
diskTotal = clampDiskValue(rawDiskTotal);
|
||||
diskFree = clampDiskValue(rawDiskFree);
|
||||
diskAvailable = clampDiskValue(rawDiskAvailable);
|
||||
|
||||
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
|
||||
diskAvailable = null;
|
||||
}
|
||||
if (diskTotal && diskFree && diskFree > diskTotal) {
|
||||
diskFree = null;
|
||||
}
|
||||
}
|
||||
|
||||
const totalUsed = totalStorage?.total || 0;
|
||||
|
||||
const parseBytesValue = (value) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
};
|
||||
|
||||
const parseEnvOverride = (bytesVar, gbVar) => {
|
||||
if (process.env[bytesVar]) {
|
||||
return parseBytesValue(process.env[bytesVar]);
|
||||
}
|
||||
if (process.env[gbVar]) {
|
||||
const value = parseBytesValue(process.env[gbVar]);
|
||||
return value ? value * 1024 * 1024 * 1024 : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let configuredSoftLimit = null;
|
||||
let capacityOverrideDb = null;
|
||||
let availableOverrideDb = null;
|
||||
|
||||
try {
|
||||
const storageSettings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_storage_soft_limit_bytes',
|
||||
'general_storage_capacity_override_bytes',
|
||||
'general_storage_available_override_bytes'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
storageSettings.forEach((setting) => {
|
||||
let parsedValue = null;
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
parsedValue = JSON.parse(setting.setting_value);
|
||||
} catch (error) {
|
||||
parsedValue = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (setting.setting_key) {
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage settings read error:', error.message);
|
||||
}
|
||||
|
||||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
|
||||
|
||||
let capacityOverrideBytes = null;
|
||||
let availableOverrideBytes = null;
|
||||
let overrideSource = null;
|
||||
|
||||
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
|
||||
capacityOverrideBytes = capacityOverrideEnv;
|
||||
availableOverrideBytes = availableOverrideEnv;
|
||||
overrideSource = 'env';
|
||||
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
|
||||
capacityOverrideBytes = capacityOverrideDb;
|
||||
availableOverrideBytes = availableOverrideDb;
|
||||
overrideSource = 'settings';
|
||||
}
|
||||
|
||||
if (capacityOverrideBytes != null) {
|
||||
diskTotal = capacityOverrideBytes;
|
||||
if (availableOverrideBytes == null) {
|
||||
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
|
||||
} else {
|
||||
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
|
||||
}
|
||||
diskFree = diskAvailable;
|
||||
} else if (availableOverrideBytes != null) {
|
||||
diskAvailable = Math.max(availableOverrideBytes, 0);
|
||||
diskFree = diskAvailable;
|
||||
}
|
||||
|
||||
let recommendedSoftLimit = null;
|
||||
if (diskTotal && diskAvailable) {
|
||||
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
|
||||
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
|
||||
} else if (diskTotal) {
|
||||
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
|
||||
} else if (diskAvailable) {
|
||||
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
|
||||
}
|
||||
|
||||
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
|
||||
recommendedSoftLimit = totalUsed;
|
||||
}
|
||||
|
||||
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
if (!recommendedSoftLimit && fallbackSoftLimit) {
|
||||
recommendedSoftLimit = fallbackSoftLimit;
|
||||
}
|
||||
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
|
||||
const diskMetricsReliable = Boolean(diskTotal);
|
||||
|
||||
res.json({
|
||||
total_used: totalStorage.total || 0,
|
||||
total_used: totalUsed,
|
||||
archive_storage: archiveStorage,
|
||||
storage_by_event: storageByEvent,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default
|
||||
storage_limit: effectiveSoftLimit,
|
||||
storage_soft_limit: effectiveSoftLimit,
|
||||
configured_soft_limit: configuredSoftLimit,
|
||||
recommended_soft_limit: recommendedSoftLimit,
|
||||
soft_limit_configured: Boolean(configuredSoftLimit),
|
||||
disk_total: diskTotal,
|
||||
disk_free: diskFree,
|
||||
disk_available: diskAvailable,
|
||||
disk_total_raw: rawDiskTotal,
|
||||
disk_free_raw: rawDiskFree,
|
||||
disk_available_raw: rawDiskAvailable,
|
||||
disk_metrics_reliable: diskMetricsReliable,
|
||||
disk_override_source: overrideSource
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage info error:', error);
|
||||
@@ -616,7 +892,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload favicon endpoint
|
||||
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
|
||||
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No favicon file provided' });
|
||||
@@ -629,13 +905,13 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_favicon_url',
|
||||
setting_value: faviconUrl,
|
||||
setting_value: JSON.stringify(faviconUrl),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: faviconUrl,
|
||||
setting_value: JSON.stringify(faviconUrl),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
@@ -654,7 +930,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
||||
});
|
||||
|
||||
// Update rate limit settings
|
||||
router.put('/security/rate-limit', adminAuth, [
|
||||
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
|
||||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||
@@ -717,4 +993,79 @@ router.put('/security/rate-limit', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Get default public site template
|
||||
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
res.json({
|
||||
enabled: false,
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding,
|
||||
meta: {
|
||||
title: defaults.title,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load public site defaults:', error);
|
||||
res.status(500).json({ error: 'Failed to load defaults' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset public site template to defaults
|
||||
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const entries = [
|
||||
{
|
||||
key: 'general_public_site_html',
|
||||
value: DEFAULT_PUBLIC_SITE_HTML.trim()
|
||||
},
|
||||
{
|
||||
key: 'general_public_site_custom_css',
|
||||
value: ''
|
||||
}
|
||||
];
|
||||
|
||||
for (const { key, value } of entries) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
await logActivity('public_site_reset_to_default',
|
||||
{
|
||||
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Public site template reset to defaults',
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to reset public site template:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
const express = require('express');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
router.get('/version', adminAuth, async (req, res) => {
|
||||
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Read backend version from package.json
|
||||
let backendVersion = '1.0.0';
|
||||
@@ -20,12 +23,15 @@ router.get('/version', adminAuth, async (req, res) => {
|
||||
} catch (err) {
|
||||
console.error('Could not read package.json:', err);
|
||||
}
|
||||
|
||||
|
||||
const channel = getCurrentChannel(backendVersion);
|
||||
|
||||
res.json({
|
||||
backend: backendVersion,
|
||||
frontend: '1.0.0', // This will be set by frontend
|
||||
node: process.version,
|
||||
environment: process.env.NODE_ENV || 'production'
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
channel: channel
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
@@ -33,8 +39,34 @@ router.get('/version', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Check for updates
|
||||
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Check if update checking is enabled
|
||||
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
||||
|
||||
if (!updateCheckEnabled) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
message: 'Update checking is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const updateInfo = await checkForUpdates(forceRefresh);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
...updateInfo
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error checking for updates:', error);
|
||||
res.status(500).json({ error: 'Failed to check for updates' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, async (req, res) => {
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Database size - check if PostgreSQL or SQLite
|
||||
let dbSize = 0;
|
||||
@@ -169,7 +201,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get database statistics
|
||||
router.get('/database', adminAuth, async (req, res) => {
|
||||
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Get table info
|
||||
const tables = [
|
||||
@@ -208,10 +240,14 @@ router.get('/database', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
// Table might not exist
|
||||
logger.warn('Failed to retrieve table info', {
|
||||
table,
|
||||
error: error.message
|
||||
});
|
||||
tableInfo.push({
|
||||
name: table,
|
||||
rows: 0,
|
||||
error: error.message
|
||||
error: 'Unable to retrieve table details'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -226,4 +262,4 @@ router.get('/database', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user