Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 775e417e55 |
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
|
||||
@@ -1,132 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering only
|
||||
|
||||
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,269 +0,0 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
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.1.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.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 and ensure tag uniqueness
|
||||
git fetch --tags --quiet || true
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
|
||||
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
|
||||
NEW_PATCH=$((NEW_PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
done
|
||||
|
||||
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*.*.*' ]
|
||||
tags: [ 'v*.*.*' ] # Triggered by Release Please tags
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
@@ -31,15 +37,32 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
|
||||
# For main/develop/tags, build multi-arch
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $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'
|
||||
@@ -67,7 +90,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
@@ -79,7 +102,7 @@ jobs:
|
||||
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: |
|
||||
@@ -111,15 +134,32 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
# For PRs, build only amd64 to avoid QEMU emulation issues
|
||||
# For main/develop/tags, build multi-arch
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
||||
echo "skip_qemu=false" >> $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'
|
||||
@@ -147,7 +187,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
@@ -159,7 +199,7 @@ jobs:
|
||||
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: |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
|
||||
# Sync version to package.json files after release
|
||||
sync-versions:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update package.json versions
|
||||
run: |
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
echo "Updating package.json files to version $VERSION"
|
||||
|
||||
# Update backend package.json
|
||||
cd backend
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
cd ..
|
||||
|
||||
# Update frontend package.json
|
||||
cd frontend
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
cd ..
|
||||
|
||||
- name: Commit version updates
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add backend/package.json frontend/package.json
|
||||
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
|
||||
git push
|
||||
+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 @@
|
||||
{
|
||||
".": "2.2.0"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+241
@@ -0,0 +1,241 @@
|
||||
# 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.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
|
||||
+51
-39
@@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/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:
|
||||
@@ -219,14 +219,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:
|
||||
|
||||
@@ -424,10 +427,10 @@ If you lose your admin credentials after the first login, you'll need to manuall
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./setup.sh --native --force-admin-password-reset
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./setup.sh --docker --force-admin-password-reset
|
||||
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.
|
||||
@@ -447,6 +450,20 @@ ADMIN_EMAIL=your-email@yourdomain.com
|
||||
|
||||
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`:
|
||||
@@ -465,39 +482,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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -527,10 +537,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
|
||||
@@ -539,21 +555,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
|
||||
}
|
||||
@@ -565,6 +572,11 @@ your-domain.com {
|
||||
handle /uploads/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||
handle {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID)
|
||||
|
||||
- 📘 [**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
|
||||
@@ -133,6 +135,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.
|
||||
@@ -208,7 +235,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
|
||||
@@ -217,12 +243,13 @@ These features are currently in beta testing and may have limited functionality
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **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 |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
| **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 | 📋 Planned |
|
||||
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
||||
| **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
|
||||
|
||||
|
||||
+14
-14
@@ -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/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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
@@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -385,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
|
||||
@@ -508,13 +508,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
|
||||
@@ -523,7 +523,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
|
||||
```
|
||||
|
||||
+15
-4
@@ -11,13 +11,17 @@ 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 . .
|
||||
@@ -27,6 +31,13 @@ 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,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();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,16 @@ describe('resolvePhotoFilePath', () => {
|
||||
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' };
|
||||
|
||||
Binary file not shown.
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -0,0 +1,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,239 @@
|
||||
/**
|
||||
* 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) {
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+2165
-1386
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.8",
|
||||
"version": "2.1.1",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@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.12.2",
|
||||
@@ -26,20 +27,22 @@
|
||||
"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",
|
||||
@@ -55,5 +58,13 @@
|
||||
"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
Executable → Regular
Executable → Regular
Executable → Regular
@@ -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,22 +26,108 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-19
@@ -25,6 +25,7 @@ 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');
|
||||
@@ -34,7 +35,7 @@ const {
|
||||
} = 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');
|
||||
@@ -323,10 +324,8 @@ async function initializeRateLimiters() {
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
|
||||
// Body parsing middleware with increased limits for large uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
@@ -434,6 +433,11 @@ 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'));
|
||||
@@ -470,20 +474,11 @@ try {
|
||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
// 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,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
|
||||
};
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -63,12 +64,16 @@ 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);
|
||||
@@ -99,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,
|
||||
@@ -157,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) {
|
||||
|
||||
@@ -1,169 +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');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
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 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' });
|
||||
}
|
||||
}
|
||||
|
||||
// Export other middleware functions from original file...
|
||||
module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
// ... other exports
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware
|
||||
* Adds additional security checks beyond basic JWT validation
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
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 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' });
|
||||
}
|
||||
|
||||
// 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 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' });
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
+259
-70
@@ -1,98 +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 } = require('../utils/tokenUtils');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -230,25 +231,89 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
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) {
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: config.host || config.destination,
|
||||
destination: host,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
@@ -270,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);
|
||||
@@ -287,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -309,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;
|
||||
@@ -340,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -357,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -388,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -417,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();
|
||||
|
||||
@@ -449,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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();
|
||||
@@ -486,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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();
|
||||
@@ -548,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -600,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -688,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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 = {};
|
||||
@@ -757,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -14,36 +15,101 @@ 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');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
// 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
|
||||
};
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
|
||||
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;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||
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;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// 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('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;
|
||||
@@ -73,11 +139,11 @@ 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('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 {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -86,13 +152,14 @@ router.post('/', adminAuth, [
|
||||
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 = '',
|
||||
@@ -113,9 +180,32 @@ 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
|
||||
@@ -133,7 +223,6 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
let passwordValidation = null;
|
||||
let galleryPassword = password;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
@@ -148,8 +237,6 @@ router.post('/', adminAuth, [
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
} else {
|
||||
galleryPassword = '';
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -167,11 +254,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -201,22 +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,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
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)
|
||||
@@ -251,13 +340,15 @@ 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_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 || ''
|
||||
@@ -272,8 +363,10 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareLink,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
@@ -284,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;
|
||||
@@ -296,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);
|
||||
@@ -356,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,
|
||||
@@ -374,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' });
|
||||
@@ -418,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,
|
||||
@@ -426,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' });
|
||||
@@ -434,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(),
|
||||
@@ -442,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;
|
||||
@@ -462,6 +566,13 @@ router.put('/:id', adminAuth, [
|
||||
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;
|
||||
@@ -470,7 +581,8 @@ router.put('/:id', adminAuth, [
|
||||
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);
|
||||
@@ -481,6 +593,39 @@ router.put('/:id', adminAuth, [
|
||||
|
||||
const { id } = req.params;
|
||||
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;
|
||||
@@ -527,7 +672,12 @@ 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' });
|
||||
}
|
||||
@@ -564,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;
|
||||
|
||||
@@ -645,11 +795,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
@@ -680,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' });
|
||||
}
|
||||
@@ -715,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,
|
||||
@@ -739,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' });
|
||||
}
|
||||
@@ -773,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,
|
||||
@@ -789,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, {
|
||||
@@ -814,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;
|
||||
|
||||
@@ -845,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) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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');
|
||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/admin/external-media/list?path=relative/dir
|
||||
router.get('/list', adminAuth, async (req, res) => {
|
||||
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
||||
const result = await list(relPath);
|
||||
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
|
||||
|
||||
// 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, async (req, res) => {
|
||||
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 || {};
|
||||
|
||||
@@ -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) => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -626,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) {
|
||||
@@ -690,9 +760,9 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
// 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
|
||||
@@ -710,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;
|
||||
|
||||
@@ -747,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;
|
||||
|
||||
@@ -787,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;
|
||||
|
||||
@@ -807,4 +877,142 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 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: 'Restore validation failed',
|
||||
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: 'Failed to start restore operation'
|
||||
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 }),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
@@ -18,7 +19,10 @@ const {
|
||||
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');
|
||||
|
||||
@@ -89,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('*');
|
||||
|
||||
@@ -117,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')
|
||||
@@ -148,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');
|
||||
|
||||
@@ -169,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,
|
||||
@@ -188,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 = {
|
||||
@@ -208,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
|
||||
@@ -308,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' });
|
||||
@@ -370,7 +376,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' });
|
||||
@@ -432,7 +438,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;
|
||||
|
||||
@@ -469,12 +475,27 @@ 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 };
|
||||
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 || '');
|
||||
@@ -529,6 +550,12 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
if (uploadLimitTouched) {
|
||||
clearMaxFilesPerUploadCache();
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
clearShareLinkSettingsCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
@@ -547,7 +574,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;
|
||||
|
||||
@@ -567,6 +594,8 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
resetSecurityConfigCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'security_settings_updated',
|
||||
@@ -584,7 +613,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;
|
||||
|
||||
@@ -621,7 +650,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')
|
||||
@@ -849,7 +878,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' });
|
||||
@@ -887,7 +916,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'),
|
||||
@@ -951,7 +980,7 @@ router.put('/security/rate-limit', adminAuth, [
|
||||
});
|
||||
|
||||
// Get default public site template
|
||||
router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
@@ -972,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Reset public site template to defaults
|
||||
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
||||
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const entries = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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');
|
||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
||||
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';
|
||||
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -170,7 +171,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 = [
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, async (req, res) => {
|
||||
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update thumbnail settings
|
||||
router.put('/settings', adminAuth, async (req, res) => {
|
||||
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { width, height, fit, quality, format } = req.body;
|
||||
|
||||
@@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Regenerate all thumbnails with new settings
|
||||
router.post('/regenerate', adminAuth, async (req, res) => {
|
||||
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||
|
||||
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get regeneration status
|
||||
router.get('/regenerate/status', adminAuth, async (req, res) => {
|
||||
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
// Count photos with and without thumbnails
|
||||
const totalPhotos = await db('photos').count('id as count').first();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Admin Users Routes
|
||||
* Handles user management, roles, and invitations
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission, requireSuperAdmin, getUserPermissions } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const userManagementService = require('../services/userManagementService');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Transform user object from snake_case (DB) to camelCase (API)
|
||||
*/
|
||||
function transformUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isActive: user.is_active,
|
||||
lastLogin: user.last_login,
|
||||
lastLoginIp: user.last_login_ip,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
roleId: user.role_id,
|
||||
roleName: user.role_name,
|
||||
roleDisplayName: user.role_display_name,
|
||||
createdByUsername: user.created_by_username
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform role object from snake_case (DB) to camelCase (API)
|
||||
*/
|
||||
function transformRole(role) {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
displayName: role.display_name,
|
||||
description: role.description,
|
||||
isSystem: role.is_system,
|
||||
priority: role.priority
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me/permissions
|
||||
* Get current user's permissions
|
||||
*/
|
||||
router.get('/me/permissions', adminAuth, handleAsync(async (req, res) => {
|
||||
const permissions = await getUserPermissions(req.admin.id);
|
||||
res.json(permissions);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List all admin users
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const users = await userManagementService.getAllAdminUsers();
|
||||
res.json({ users: users.map(transformUser) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /roles
|
||||
* List all roles
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/roles', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const roles = await userManagementService.getAllRoles();
|
||||
res.json({ roles: roles.map(transformRole) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /invitations
|
||||
* List pending invitations
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/invitations', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => {
|
||||
const invitations = await userManagementService.getPendingInvitations();
|
||||
res.json({ invitations });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /invite
|
||||
* Create invitation
|
||||
* Requires: users.create permission
|
||||
*/
|
||||
router.post('/invite', [
|
||||
adminAuth,
|
||||
requirePermission('users.create'),
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('role_id').isInt({ min: 1 }).withMessage('Role ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const invitation = await userManagementService.createInvitation({
|
||||
email: req.body.email,
|
||||
roleId: req.body.role_id,
|
||||
invitedById: req.admin.id
|
||||
});
|
||||
|
||||
successResponse(res, { invitation }, 201);
|
||||
}));
|
||||
|
||||
/**
|
||||
* DELETE /invitations/:id
|
||||
* Cancel invitation
|
||||
* Requires: users.create permission
|
||||
*/
|
||||
router.delete('/invitations/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.create'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid invitation ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
await userManagementService.cancelInvitation(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'Invitation cancelled' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /:id
|
||||
* Get single user
|
||||
* Requires: users.view permission
|
||||
*/
|
||||
router.get('/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.view'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
|
||||
res.json({ user: transformUser(user) });
|
||||
}));
|
||||
|
||||
/**
|
||||
* PUT /:id
|
||||
* Update user
|
||||
* Requires: users.edit permission
|
||||
*/
|
||||
router.put('/:id', [
|
||||
adminAuth,
|
||||
requirePermission('users.edit'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required'),
|
||||
body('username').optional().trim().isLength({ min: 3, max: 50 }).withMessage('Username must be 3-50 characters'),
|
||||
body('email').optional().isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('role_id').optional().isInt({ min: 1 }).withMessage('Valid role ID is required'),
|
||||
body('is_active').optional().isBoolean().withMessage('is_active must be boolean')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const user = await userManagementService.updateAdminUser(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.admin.id
|
||||
);
|
||||
|
||||
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:id/deactivate
|
||||
* Deactivate user
|
||||
* Requires: users.delete permission
|
||||
*/
|
||||
router.post('/:id/deactivate', [
|
||||
adminAuth,
|
||||
requirePermission('users.delete'),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
await userManagementService.deactivateAdminUser(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'User deactivated successfully' });
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /:id/reset-password
|
||||
* Reset user password
|
||||
* Requires: super_admin role
|
||||
*/
|
||||
router.post('/:id/reset-password', [
|
||||
adminAuth,
|
||||
requireSuperAdmin(),
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await userManagementService.resetAdminPassword(parseInt(req.params.id), req.admin.id);
|
||||
successResponse(res, { message: 'Password reset email sent', ...result });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,392 +0,0 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
checkAccountLockout,
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const {
|
||||
validatePasswordInContext,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure
|
||||
} = require('../utils/passwordValidation');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
const lockoutStatus = await checkAccountLockout(username);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Login attempt on locked account', { username, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
// Check for suspicious activity
|
||||
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
|
||||
if (isSuspicious) {
|
||||
// Still allow login but log it
|
||||
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.where({ username })
|
||||
.orWhere({ email: username })
|
||||
.first();
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
if (!admin.is_active) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin password change with validation
|
||||
router.post('/admin/change-password', [
|
||||
body('currentPassword').notEmpty(),
|
||||
body('newPassword').notEmpty(),
|
||||
body('confirmPassword').notEmpty()
|
||||
.custom((value, { req }) => value === req.body.newPassword)
|
||||
.withMessage('Passwords do not match')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const adminId = req.admin.id; // From auth middleware
|
||||
|
||||
// Get admin user
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, admin.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
const passwordValidation = validatePasswordInContext(newPassword, 'admin', {
|
||||
username: admin.username,
|
||||
email: admin.email
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
logPasswordValidationFailure('admin_password_change', passwordValidation.errors, {
|
||||
userId: adminId,
|
||||
username: admin.username
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password with configurable rounds
|
||||
const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update password and track change time
|
||||
await db('admin_users').where('id', adminId).update({
|
||||
password_hash: hashedPassword,
|
||||
password_changed_at: new Date(),
|
||||
must_change_password: false
|
||||
});
|
||||
|
||||
// Log password change
|
||||
logger.info('Admin password changed', {
|
||||
userId: adminId,
|
||||
username: admin.username,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Password changed successfully',
|
||||
score: passwordValidation.score
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
|
||||
// Log the logout
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
logger.info('User logged out', {
|
||||
userId: decoded.id,
|
||||
username: decoded.username,
|
||||
type: decoded.type
|
||||
});
|
||||
} catch (err) {
|
||||
// Token might be invalid, but still process logout
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
if (requiresPassword) {
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate session token with additional security info
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Gallery verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current session info
|
||||
router.get('/session', async (req, res) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
valid: false,
|
||||
error: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Session check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Password strength check endpoint (for real-time validation)
|
||||
router.post('/password-strength', [
|
||||
body('password').notEmpty(),
|
||||
body('context').isIn(['admin', 'gallery']).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { password, context = 'gallery' } = req.body;
|
||||
|
||||
// Get user data if available (for context-aware validation)
|
||||
const userData = {};
|
||||
if (context === 'admin' && req.admin) {
|
||||
userData.username = req.admin.username;
|
||||
userData.email = req.admin.email;
|
||||
}
|
||||
|
||||
const validation = validatePasswordInContext(password, context, userData);
|
||||
|
||||
res.json({
|
||||
valid: validation.valid,
|
||||
score: validation.score,
|
||||
errors: validation.errors,
|
||||
feedback: validation.feedback
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check password strength' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,389 +0,0 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
checkAccountLockout,
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const logger = require('../utils/logger');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
setGalleryAuthCookies,
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('../utils/tokenUtils');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
const lockoutStatus = await checkAccountLockout(username);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Login attempt on locked account', { username, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
// Check for suspicious activity
|
||||
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
|
||||
if (isSuspicious) {
|
||||
// Still allow login but log it
|
||||
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.where({ username })
|
||||
.orWhere({ email: username })
|
||||
.first();
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
if (!admin.is_active) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const adminToken = getAdminTokenFromRequest(req);
|
||||
const galleryToken = getGalleryTokenFromRequest(req);
|
||||
const token = adminToken || galleryToken;
|
||||
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
logger.info('User logged out', {
|
||||
userId: decoded.id,
|
||||
username: decoded.username,
|
||||
type: decoded.type
|
||||
});
|
||||
|
||||
if (decoded.type === 'admin') {
|
||||
clearAdminAuthCookie(res);
|
||||
} else if (decoded.type === 'gallery') {
|
||||
clearGalleryAuthCookies(res, decoded.eventSlug);
|
||||
}
|
||||
} catch (err) {
|
||||
// Token might be invalid, but still process logout and clear cookies
|
||||
clearAdminAuthCookie(res);
|
||||
clearGalleryAuthCookies(res);
|
||||
}
|
||||
} else {
|
||||
// No token found, but ensure cookies are cleared
|
||||
clearAdminAuthCookie(res);
|
||||
clearGalleryAuthCookies(res);
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
}
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Gallery verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Share link authentication (token-based)
|
||||
router.post('/gallery/share-login', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('token').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug, token } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
let expectedToken = event.share_link;
|
||||
if (expectedToken && expectedToken.includes('/')) {
|
||||
expectedToken = expectedToken.split('/').pop();
|
||||
}
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Share link authentication error:', error);
|
||||
res.status(500).json({ error: 'Share link login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery logout to clear cookies
|
||||
router.post('/gallery/logout', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.body || {};
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Gallery logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current session info
|
||||
router.get('/session', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug,
|
||||
eventSlug: decoded.eventSlug,
|
||||
adminUsername: decoded.username
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
valid: false,
|
||||
error: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Session check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+450
-63
@@ -5,11 +5,35 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
checkAccountLockout,
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const logger = require('../utils/logger');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
setGalleryAuthCookies,
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('../utils/tokenUtils');
|
||||
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const {
|
||||
validatePasswordInContext,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure
|
||||
} = require('../utils/passwordValidation');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty(),
|
||||
body('username').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -19,49 +43,146 @@ router.post('/admin/login', [
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
const lockoutStatus = await checkAccountLockout(username);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Login attempt on locked account', { username, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
// Check for suspicious activity
|
||||
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
|
||||
if (isSuspicious) {
|
||||
// Still allow login but log it
|
||||
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
||||
}
|
||||
|
||||
// Fetch admin with role information
|
||||
const admin = await db('admin_users')
|
||||
.where({ username })
|
||||
.orWhere({ email: username })
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.username', username)
|
||||
.orWhere('admin_users.email', username)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
|
||||
if (!admin.is_active) {
|
||||
return res.status(401).json({ error: 'Account disabled' });
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await db('admin_users').where('id', admin.id).update({ last_login: new Date() });
|
||||
|
||||
const token = jwt.sign({ id: admin.id, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '24h' });
|
||||
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Include role in response
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery password verification
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const adminToken = getAdminTokenFromRequest(req);
|
||||
const galleryToken = getGalleryTokenFromRequest(req);
|
||||
const token = adminToken || galleryToken;
|
||||
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
logger.info('User logged out', {
|
||||
userId: decoded.id,
|
||||
username: decoded.username,
|
||||
type: decoded.type
|
||||
});
|
||||
|
||||
if (decoded.type === 'admin') {
|
||||
clearAdminAuthCookie(res);
|
||||
} else if (decoded.type === 'gallery') {
|
||||
clearGalleryAuthCookies(res, decoded.eventSlug);
|
||||
}
|
||||
} catch (err) {
|
||||
// Token might be invalid, but still process logout and clear cookies
|
||||
clearAdminAuthCookie(res);
|
||||
clearGalleryAuthCookies(res);
|
||||
}
|
||||
} else {
|
||||
// No token found, but ensure cookies are cleared
|
||||
clearAdminAuthCookie(res);
|
||||
clearGalleryAuthCookies(res);
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty(),
|
||||
body('password').notEmpty()
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -70,67 +191,333 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
|
||||
// Verify reCAPTCHA - temporarily disabled for testing
|
||||
// const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
// if (!recaptchaValid) {
|
||||
// return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
// }
|
||||
|
||||
const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first();
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'login_fail'
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
|
||||
// Log successful access
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'login_success'
|
||||
});
|
||||
|
||||
// Generate session token
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery'
|
||||
}, process.env.JWT_SECRET, { expiresIn: '24h' });
|
||||
|
||||
const responseEvent = {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
hero_photo_id: event.hero_photo_id,
|
||||
allow_downloads: event.allow_downloads
|
||||
};
|
||||
|
||||
console.log('Auth response event:', JSON.stringify(responseEvent, null, 2));
|
||||
type: 'gallery',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: responseEvent
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Gallery verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Share link authentication (token-based)
|
||||
router.post('/gallery/share-login', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('token').notEmpty()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug, token } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
const resolved = await resolveShareIdentifier(slug);
|
||||
if (resolved?.event) {
|
||||
event = resolved.event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Share link authentication error:', error);
|
||||
res.status(500).json({ error: 'Share link login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery logout to clear cookies
|
||||
router.post('/gallery/logout', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.body || {};
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Gallery logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current session info
|
||||
router.get('/session', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug,
|
||||
eventSlug: decoded.eventSlug,
|
||||
adminUsername: decoded.username
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
valid: false,
|
||||
error: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Session check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin password change with validation
|
||||
router.post('/admin/change-password', [
|
||||
body('currentPassword').notEmpty(),
|
||||
body('newPassword').notEmpty(),
|
||||
body('confirmPassword').notEmpty()
|
||||
.custom((value, { req }) => value === req.body.newPassword)
|
||||
.withMessage('Passwords do not match')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
|
||||
// Get admin from request (should be set by auth middleware)
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
const adminId = req.admin.id;
|
||||
|
||||
// Get admin user
|
||||
const admin = await db('admin_users').where({ id: adminId }).first();
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, admin.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
const passwordValidation = validatePasswordInContext(newPassword, 'admin', {
|
||||
username: admin.username,
|
||||
email: admin.email
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
logPasswordValidationFailure('admin_password_change', passwordValidation.errors, {
|
||||
userId: adminId,
|
||||
username: admin.username
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Hash new password with configurable rounds
|
||||
const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update password and track change time
|
||||
await db('admin_users').where('id', adminId).update({
|
||||
password_hash: hashedPassword,
|
||||
password_changed_at: new Date(),
|
||||
must_change_password: false
|
||||
});
|
||||
|
||||
// Log password change
|
||||
logger.info('Admin password changed', {
|
||||
userId: adminId,
|
||||
username: admin.username,
|
||||
ip: ipAddress
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Password changed successfully',
|
||||
score: passwordValidation.score
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Password strength check endpoint (for real-time validation)
|
||||
router.post('/password-strength', [
|
||||
body('password').notEmpty(),
|
||||
body('context').isIn(['admin', 'gallery']).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { password, context = 'gallery' } = req.body;
|
||||
|
||||
// Get user data if available (for context-aware validation)
|
||||
const userData = {};
|
||||
if (context === 'admin' && req.admin) {
|
||||
userData.username = req.admin.username;
|
||||
userData.email = req.admin.email;
|
||||
}
|
||||
|
||||
const validation = validatePasswordInContext(password, context, userData);
|
||||
|
||||
res.json({
|
||||
valid: validation.valid,
|
||||
score: validation.score,
|
||||
errors: validation.errors,
|
||||
feedback: validation.feedback
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check password strength' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+109
-33
@@ -5,31 +5,52 @@ const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
// 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;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
|
||||
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;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// Create new event
|
||||
@@ -37,7 +58,8 @@ router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
@@ -62,7 +84,6 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
@@ -71,6 +92,15 @@ router.post('/', adminAuth, [
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
@@ -98,12 +128,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
const shareLinkSlug = `${slug}/${shareToken}`;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -126,12 +153,15 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
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: shareLinkSlug,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
@@ -141,11 +171,13 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: fullShareLink,
|
||||
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 || ''
|
||||
@@ -154,9 +186,11 @@ router.post('/', adminAuth, [
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: fullShareLink,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -185,17 +219,27 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events);
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
@@ -203,6 +247,38 @@ router.put('/:id', adminAuth, async (req, res) => {
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
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) {
|
||||
|
||||
+247
-68
@@ -9,44 +9,92 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
try {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
const hasTable = await db.schema.hasTable('slug_redirects');
|
||||
if (!hasTable) return null;
|
||||
|
||||
const redirect = await db('slug_redirects')
|
||||
.where({ old_slug: slug })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Extract token from share link and verify
|
||||
const expectedToken = event.share_link.split('/').pop();
|
||||
if (token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
|
||||
return redirect ? redirect.new_slug : null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
res.status(500).json({ error: 'Failed to verify token' });
|
||||
logger.warn('Error checking slug redirect:', { slug, error: error.message });
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve gallery identifier (slug or token) to canonical data
|
||||
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
}));
|
||||
|
||||
// Get gallery info (with optional token verification)
|
||||
router.get('/:slug/info', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { token } = req.query;
|
||||
|
||||
const event = await db('events')
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'event_name',
|
||||
@@ -56,16 +104,28 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme'
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering'
|
||||
)
|
||||
.first();
|
||||
|
||||
|
||||
if (!event) {
|
||||
// Check for redirect
|
||||
const newSlug = await checkSlugRedirect(slug);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
@@ -76,12 +136,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
let expectedToken = event.share_link;
|
||||
// Handle both formats: full URL or just token
|
||||
if (event.share_link && event.share_link.includes('/')) {
|
||||
expectedToken = event.share_link.split('/').pop();
|
||||
}
|
||||
if (token !== expectedToken) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
@@ -100,7 +156,9 @@ router.get('/:slug/info', async (req, res) => {
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -261,6 +319,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
disable_right_click: req.event.disable_right_click === true,
|
||||
watermark_downloads: req.event.watermark_downloads === true,
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
@@ -343,19 +403,27 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
|
||||
if (shouldApplyWatermark) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Use event watermark text if available, otherwise fall back to global settings
|
||||
const effectiveSettings = {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
};
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
||||
'Content-Length': watermarkedBuffer.length
|
||||
});
|
||||
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
@@ -414,9 +482,16 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Get watermark settings
|
||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
let filePath;
|
||||
@@ -431,7 +506,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Determine the file name in the archive
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
@@ -442,10 +517,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
// No folders, just the filename
|
||||
archiveName = photo.filename;
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
|
||||
@@ -532,15 +607,23 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
// Check watermark settings similar to download-all
|
||||
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
||||
const effectiveSettings = shouldApplyWatermark ? {
|
||||
...watermarkSettings,
|
||||
enabled: true,
|
||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||
} : null;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
if (shouldApplyWatermark && effectiveSettings) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
|
||||
@@ -583,38 +666,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
|
||||
|
||||
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
// photoId,
|
||||
@@ -622,20 +708,61 @@ router.get('/:slug/photo/:photoId',
|
||||
// req.clientInfo,
|
||||
// 'view_basic'
|
||||
// );
|
||||
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const file = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
file.pipe(res);
|
||||
} else {
|
||||
// No range request, send entire file
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images (existing logic)
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file with basic protection headers
|
||||
@@ -773,22 +900,35 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId);
|
||||
|
||||
|
||||
// Verify the event matches the token
|
||||
if (req.event.id !== eventId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
|
||||
// Check if user uploads are allowed
|
||||
if (!req.event.allow_user_uploads) {
|
||||
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
|
||||
}
|
||||
|
||||
|
||||
// Ensure temp upload directory exists
|
||||
const fs = require('fs');
|
||||
const tempUploadDir = '/tmp/uploads/';
|
||||
if (!fs.existsSync(tempUploadDir)) {
|
||||
try {
|
||||
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
|
||||
logger.info('Created temp upload directory:', tempUploadDir);
|
||||
} catch (mkdirErr) {
|
||||
logger.error('Failed to create temp upload directory:', mkdirErr);
|
||||
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
|
||||
}
|
||||
}
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const upload = multer({
|
||||
dest: '/tmp/uploads/',
|
||||
limits: {
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB
|
||||
files: 10 // Max 10 files at once
|
||||
},
|
||||
@@ -836,4 +976,43 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:slug/css-template
|
||||
* Get custom CSS template for gallery (public endpoint)
|
||||
*/
|
||||
router.get('/:slug/css-template', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Find the event by slug
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.select('css_template_id')
|
||||
.first();
|
||||
|
||||
if (!event || !event.css_template_id) {
|
||||
// No custom CSS - return 204 No Content
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Get the template if it's enabled
|
||||
const template = await db('css_templates')
|
||||
.where({ id: event.css_template_id, is_enabled: true })
|
||||
.select('css_content')
|
||||
.first();
|
||||
|
||||
if (!template || !template.css_content) {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
// Return CSS with caching headers
|
||||
res.setHeader('Content-Type', 'text/css');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||
res.send(template.css_content);
|
||||
} catch (error) {
|
||||
console.error('Get CSS template error:', error);
|
||||
res.status(500).send('/* Error loading template */');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,13 +5,14 @@ const router = express.Router();
|
||||
// Get public settings (branding and theme)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Fetch branding, theme, general, and security settings
|
||||
// Fetch branding, theme, general, security, analytics, and event settings
|
||||
// Note: We include analytics in the query but it might not exist yet
|
||||
const settings = await withRetry(async () => {
|
||||
return await db('app_settings')
|
||||
.where(function() {
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%');
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%')
|
||||
.orWhere('setting_key', 'like', 'event_require_%');
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
@@ -19,10 +20,19 @@ router.get('/', async (req, res) => {
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
// Handle null/undefined values
|
||||
if (setting.setting_value === null || setting.setting_value === undefined) {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
return;
|
||||
}
|
||||
// If value is already a primitive (boolean, number), use it directly
|
||||
if (typeof setting.setting_value === 'boolean' || typeof setting.setting_value === 'number') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
return;
|
||||
}
|
||||
// Try to parse string values as JSON
|
||||
try {
|
||||
settingsObject[setting.setting_key] = setting.setting_value
|
||||
? JSON.parse(setting.setting_value)
|
||||
: null;
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
@@ -59,7 +69,11 @@ router.get('/', async (req, res) => {
|
||||
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
|
||||
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
|
||||
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
|
||||
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
|
||||
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null,
|
||||
// Event field requirements
|
||||
event_require_customer_name: settingsObject.event_require_customer_name !== false,
|
||||
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -710,7 +710,7 @@ async function saveManifestToS3(manifest, manifestFileName, config, result) {
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
async function runBackupInternal() {
|
||||
async function runBackupInternal(isManual = false) {
|
||||
if (isRunning) {
|
||||
logger.warn('Backup already running, skipping');
|
||||
return;
|
||||
@@ -722,21 +722,30 @@ async function runBackupInternal() {
|
||||
|
||||
try {
|
||||
const config = await resolveConfigWithFallback();
|
||||
if (!config || !normalizeBoolean(config.backup_enabled)) {
|
||||
logger.info('Backup is disabled, skipping');
|
||||
|
||||
// For scheduled backups, check if backup is enabled
|
||||
// Manual backups should always be allowed (just need valid destination config)
|
||||
if (!isManual && (!config || !normalizeBoolean(config.backup_enabled))) {
|
||||
logger.info('Scheduled backup is disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// For manual backups, just ensure we have a destination configured
|
||||
if (!config || !config.backup_destination_type) {
|
||||
logger.warn('Backup destination not configured');
|
||||
throw new Error('Backup destination not configured. Please configure backup settings first.');
|
||||
}
|
||||
|
||||
const schemaVersion = await getCurrentSchemaVersion();
|
||||
const [insertedId] = await db('backup_runs').insert({
|
||||
const insertResult = await db('backup_runs').insert({
|
||||
started_at: startTime,
|
||||
status: 'running',
|
||||
backup_type: 'scheduled',
|
||||
backup_type: isManual ? 'manual' : 'scheduled',
|
||||
app_version: packageJson.version,
|
||||
node_version: process.version,
|
||||
db_schema_version: schemaVersion
|
||||
});
|
||||
runId = insertedId;
|
||||
}).returning('id');
|
||||
runId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
const files = await service.getFilesToBackup(config.backup_include_archived);
|
||||
logger.info(`Found ${files.length} files to check for backup`);
|
||||
@@ -820,11 +829,17 @@ async function runBackupInternal() {
|
||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
|
||||
statistics: JSON.stringify({
|
||||
// Use snake_case for frontend compatibility
|
||||
files_processed: result.backedUpCount,
|
||||
total_size: result.backedUpSize,
|
||||
total_files_checked: files.length,
|
||||
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
destination: destinationType,
|
||||
// Keep camelCase for backward compatibility
|
||||
totalFilesChecked: files.length,
|
||||
filesBackedUp: result.backedUpCount,
|
||||
totalSize: result.backedUpSize,
|
||||
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||
destination: destinationType
|
||||
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -922,27 +937,59 @@ function stopBackupService() {
|
||||
|
||||
async function triggerManualBackup() {
|
||||
logger.info('Starting manual backup');
|
||||
await service.runBackup();
|
||||
await service.runBackup(true); // Pass flag to indicate manual backup
|
||||
}
|
||||
|
||||
async function getBackupStatus(limit = 10) {
|
||||
try {
|
||||
const runs = await db('backup_runs')
|
||||
const rawRuns = await db('backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
.limit(limit);
|
||||
|
||||
// Transform runs to add frontend-compatible field aliases
|
||||
const runs = rawRuns.map(run => {
|
||||
// Parse and transform statistics to snake_case for frontend compatibility
|
||||
let statistics = run.statistics;
|
||||
if (statistics) {
|
||||
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
|
||||
let stats = statistics;
|
||||
if (typeof statistics === 'string') {
|
||||
try {
|
||||
stats = JSON.parse(statistics);
|
||||
} catch (e) {
|
||||
stats = {};
|
||||
}
|
||||
}
|
||||
// Add snake_case aliases for frontend
|
||||
statistics = {
|
||||
...stats,
|
||||
files_processed: stats.filesBackedUp || stats.files_processed || 0,
|
||||
total_size: stats.totalSize || stats.total_size || 0,
|
||||
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
|
||||
average_file_size: stats.averageFileSize || stats.average_file_size || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
created_at: run.started_at, // Alias for frontend compatibility
|
||||
statistics
|
||||
};
|
||||
});
|
||||
|
||||
const lastRun = runs[0];
|
||||
let manifestValid = false;
|
||||
|
||||
if (lastRun && lastRun.manifest_path) {
|
||||
try {
|
||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
||||
if (backupManifest.validateManifest) {
|
||||
backupManifest.validateManifest(manifest);
|
||||
// Use validateBackupManifest which handles both local and S3 paths
|
||||
const result = await validateBackupManifest(lastRun.manifest_path);
|
||||
manifestValid = result.valid;
|
||||
if (!result.valid) {
|
||||
logger.warn('Manifest validation failed:', result.error);
|
||||
}
|
||||
manifestValid = true;
|
||||
} catch (error) {
|
||||
logger.warn('Manifest validation failed:', error);
|
||||
logger.warn('Manifest validation failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,6 +998,7 @@ async function getBackupStatus(limit = 10) {
|
||||
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
|
||||
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
|
||||
recentRuns: runs,
|
||||
recentBackups: runs, // Alias for frontend compatibility
|
||||
nextScheduledRun: getNextScheduledRun()
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getChunksPath = () => path.join(getStoragePath(), 'chunks');
|
||||
|
||||
// In-memory store for active uploads (in production, consider Redis)
|
||||
const activeUploads = new Map();
|
||||
|
||||
// Chunk size: 10MB
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// Upload expiration: 24 hours
|
||||
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Initialize a new chunked upload
|
||||
* @param {Object} options - Upload options
|
||||
* @returns {Promise<Object>} - Upload metadata
|
||||
*/
|
||||
async function initializeUpload(options) {
|
||||
const {
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
totalChunks
|
||||
} = options;
|
||||
|
||||
// Generate unique upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Create chunks directory for this upload
|
||||
const uploadDir = path.join(getChunksPath(), uploadId);
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// Calculate expected chunks
|
||||
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
|
||||
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
expectedChunks,
|
||||
receivedChunks: new Set(),
|
||||
uploadDir,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
|
||||
status: 'in_progress'
|
||||
};
|
||||
|
||||
activeUploads.set(uploadId, uploadMeta);
|
||||
|
||||
logger.info('Initialized chunked upload', {
|
||||
uploadId,
|
||||
filename,
|
||||
fileSize,
|
||||
expectedChunks,
|
||||
eventId
|
||||
});
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
expectedChunks,
|
||||
expiresAt: uploadMeta.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single chunk
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @param {number} chunkIndex - Chunk index (0-based)
|
||||
* @param {Buffer} chunkData - Chunk data
|
||||
* @returns {Promise<Object>} - Chunk upload result
|
||||
*/
|
||||
async function uploadChunk(uploadId, chunkIndex, chunkData) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
throw new Error('Upload not found or expired');
|
||||
}
|
||||
|
||||
if (uploadMeta.status !== 'in_progress') {
|
||||
throw new Error(`Upload is ${uploadMeta.status}`);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (Date.now() > uploadMeta.expiresAt) {
|
||||
await abortUpload(uploadId);
|
||||
throw new Error('Upload expired');
|
||||
}
|
||||
|
||||
// Write chunk to disk
|
||||
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
|
||||
await fs.writeFile(chunkPath, chunkData);
|
||||
|
||||
// Mark chunk as received
|
||||
uploadMeta.receivedChunks.add(chunkIndex);
|
||||
|
||||
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
|
||||
|
||||
logger.debug('Chunk uploaded', {
|
||||
uploadId,
|
||||
chunkIndex,
|
||||
receivedChunks: uploadMeta.receivedChunks.size,
|
||||
expectedChunks: uploadMeta.expectedChunks,
|
||||
progress: progress.toFixed(1)
|
||||
});
|
||||
|
||||
return {
|
||||
chunkIndex,
|
||||
received: uploadMeta.receivedChunks.size,
|
||||
expected: uploadMeta.expectedChunks,
|
||||
progress,
|
||||
complete: uploadMeta.receivedChunks.size === uploadMeta.expectedChunks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the upload by merging all chunks
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @returns {Promise<Object>} - Merged file info
|
||||
*/
|
||||
async function completeUpload(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
throw new Error('Upload not found or expired');
|
||||
}
|
||||
|
||||
// Verify all chunks received
|
||||
if (uploadMeta.receivedChunks.size !== uploadMeta.expectedChunks) {
|
||||
throw new Error(`Missing chunks: received ${uploadMeta.receivedChunks.size} of ${uploadMeta.expectedChunks}`);
|
||||
}
|
||||
|
||||
uploadMeta.status = 'merging';
|
||||
|
||||
// Create temp file for merged result
|
||||
const tempDir = path.join(getStoragePath(), 'temp', `merge_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
const mergedFilePath = path.join(tempDir, uploadMeta.filename);
|
||||
const writeStream = require('fs').createWriteStream(mergedFilePath);
|
||||
|
||||
try {
|
||||
// Merge chunks in order
|
||||
for (let i = 0; i < uploadMeta.expectedChunks; i++) {
|
||||
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(i).padStart(6, '0')}`);
|
||||
const chunkData = await fs.readFile(chunkPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writeStream.write(chunkData, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => writeStream.end(resolve));
|
||||
|
||||
// Verify file size
|
||||
const stats = await fs.stat(mergedFilePath);
|
||||
if (stats.size !== uploadMeta.fileSize) {
|
||||
logger.warn('Merged file size mismatch', {
|
||||
expected: uploadMeta.fileSize,
|
||||
actual: stats.size
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up chunks
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||
|
||||
uploadMeta.status = 'completed';
|
||||
activeUploads.delete(uploadId);
|
||||
|
||||
logger.info('Chunked upload completed', {
|
||||
uploadId,
|
||||
filename: uploadMeta.filename,
|
||||
fileSize: stats.size,
|
||||
eventId: uploadMeta.eventId
|
||||
});
|
||||
|
||||
return {
|
||||
path: mergedFilePath,
|
||||
filename: uploadMeta.filename,
|
||||
size: stats.size,
|
||||
mimeType: uploadMeta.mimeType,
|
||||
eventId: uploadMeta.eventId,
|
||||
tempDir
|
||||
};
|
||||
} catch (error) {
|
||||
writeStream.destroy();
|
||||
uploadMeta.status = 'failed';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort and clean up an upload
|
||||
* @param {string} uploadId - Upload ID
|
||||
*/
|
||||
async function abortUpload(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (uploadMeta) {
|
||||
try {
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean up upload directory', { uploadId, error: err.message });
|
||||
}
|
||||
|
||||
activeUploads.delete(uploadId);
|
||||
|
||||
logger.info('Chunked upload aborted', { uploadId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload status
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @returns {Object|null} - Upload status or null if not found
|
||||
*/
|
||||
function getUploadStatus(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
filename: uploadMeta.filename,
|
||||
fileSize: uploadMeta.fileSize,
|
||||
receivedChunks: uploadMeta.receivedChunks.size,
|
||||
expectedChunks: uploadMeta.expectedChunks,
|
||||
progress: (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100,
|
||||
status: uploadMeta.status,
|
||||
createdAt: uploadMeta.createdAt,
|
||||
expiresAt: uploadMeta.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired uploads
|
||||
*/
|
||||
async function cleanupExpiredUploads() {
|
||||
const now = Date.now();
|
||||
const expiredIds = [];
|
||||
|
||||
for (const [uploadId, meta] of activeUploads.entries()) {
|
||||
if (now > meta.expiresAt) {
|
||||
expiredIds.push(uploadId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const uploadId of expiredIds) {
|
||||
await abortUpload(uploadId);
|
||||
}
|
||||
|
||||
if (expiredIds.length > 0) {
|
||||
logger.info(`Cleaned up ${expiredIds.length} expired uploads`);
|
||||
}
|
||||
|
||||
return expiredIds.length;
|
||||
}
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
initializeUpload,
|
||||
uploadChunk,
|
||||
completeUpload,
|
||||
abortUpload,
|
||||
getUploadStatus,
|
||||
cleanupExpiredUploads,
|
||||
CHUNK_SIZE
|
||||
};
|
||||
@@ -9,7 +9,7 @@ let lastConfigHash = null;
|
||||
// Generate hash from config for change detection
|
||||
function generateConfigHash(config) {
|
||||
const crypto = require('crypto');
|
||||
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
|
||||
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}:${config.tls_reject_unauthorized}`;
|
||||
return crypto.createHash('md5').update(configString).digest('hex');
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ async function initializeTransporter(forceReinit = false) {
|
||||
auth: config.smtp_user ? {
|
||||
user: config.smtp_user,
|
||||
pass: config.smtp_pass
|
||||
} : undefined
|
||||
} : undefined,
|
||||
tls: {
|
||||
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
|
||||
rejectUnauthorized: config.tls_reject_unauthorized !== false
|
||||
}
|
||||
});
|
||||
|
||||
// Verify configuration
|
||||
@@ -149,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Event Rename Service
|
||||
* Handles renaming events including slug updates, file system changes, and database updates
|
||||
*/
|
||||
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const { queueEmail } = require('./emailProcessor');
|
||||
|
||||
class EventRenameService {
|
||||
constructor() {
|
||||
this.storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to YYYY-MM-DD string
|
||||
* @param {Date|string} date - Date object or string
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
formatDate(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return String(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from event details
|
||||
* @param {string} eventType - Type of event (wedding, birthday, etc.)
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {string|Date} eventDate - Date of the event
|
||||
* @returns {string} Generated slug
|
||||
*/
|
||||
generateSlug(eventType, eventName, eventDate) {
|
||||
const processedEventName = eventName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const formattedDate = this.formatDate(eventDate);
|
||||
return `${eventType}-${processedEventName}-${formattedDate}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a rename operation is possible
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} newEventName - New event name
|
||||
* @returns {Promise<{valid: boolean, error?: string, newSlug?: string}>}
|
||||
*/
|
||||
async validateRename(eventId, newEventName) {
|
||||
try {
|
||||
// Get current event
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
return { valid: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return { valid: false, error: 'Cannot rename archived events' };
|
||||
}
|
||||
|
||||
// Validate new name
|
||||
if (!newEventName || newEventName.trim().length < 3) {
|
||||
return { valid: false, error: 'Event name must be at least 3 characters' };
|
||||
}
|
||||
|
||||
if (newEventName.trim().length > 100) {
|
||||
return { valid: false, error: 'Event name must be less than 100 characters' };
|
||||
}
|
||||
|
||||
// Generate new slug
|
||||
const newSlug = this.generateSlug(event.event_type, newEventName.trim(), event.event_date);
|
||||
|
||||
// Check if slug already exists (for different event)
|
||||
const existingEvent = await db('events')
|
||||
.where({ slug: newSlug })
|
||||
.whereNot({ id: eventId })
|
||||
.first();
|
||||
|
||||
if (existingEvent) {
|
||||
return { valid: false, error: 'An event with this name already exists for the same date', conflicts: [existingEvent.event_name] };
|
||||
}
|
||||
|
||||
// Check if the slug is the same as current
|
||||
if (newSlug === event.slug) {
|
||||
return { valid: false, error: 'New name generates the same URL as the current name' };
|
||||
}
|
||||
|
||||
return { valid: true, newSlug, currentSlug: event.slug };
|
||||
} catch (error) {
|
||||
logger.error('Validation error:', { error: error.message });
|
||||
return { valid: false, error: 'Validation failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the event folder on the filesystem
|
||||
* @param {string} oldSlug - Current slug
|
||||
* @param {string} newSlug - New slug
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async renameEventFolder(oldSlug, newSlug) {
|
||||
const oldPath = path.join(this.storagePath, 'events/active', oldSlug);
|
||||
const newPath = path.join(this.storagePath, 'events/active', newSlug);
|
||||
|
||||
try {
|
||||
// Check if old folder exists
|
||||
await fs.access(oldPath);
|
||||
|
||||
// Check if new folder already exists
|
||||
try {
|
||||
await fs.access(newPath);
|
||||
throw new Error('Target folder already exists');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename folder
|
||||
await fs.rename(oldPath, newPath);
|
||||
logger.info('Event folder renamed', { oldSlug, newSlug });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.warn('Event folder not found, skipping rename', { oldSlug });
|
||||
return true; // Not a fatal error if folder doesn't exist
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename individual photo files to match new event name
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} oldEventName - Old event name
|
||||
* @param {string} newEventName - New event name
|
||||
* @param {string} newSlug - New slug for path updates
|
||||
* @returns {Promise<number>} Number of files renamed
|
||||
*/
|
||||
async renamePhotoFiles(eventId, oldEventName, newEventName, oldSlug, newSlug) {
|
||||
const photos = await db('photos').where({ event_id: eventId });
|
||||
let renamedCount = 0;
|
||||
|
||||
// Process event name for filenames
|
||||
const oldNamePrefix = oldEventName.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const newNamePrefix = newEventName.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const oldFilename = photo.filename;
|
||||
let newFilename = oldFilename;
|
||||
|
||||
// Replace event name prefix if present
|
||||
if (oldFilename.startsWith(oldNamePrefix)) {
|
||||
newFilename = oldFilename.replace(oldNamePrefix, newNamePrefix);
|
||||
}
|
||||
|
||||
// Update path with new slug
|
||||
const newPath = photo.path.replace(oldSlug, newSlug);
|
||||
const newThumbnailPath = photo.thumbnail_path ?
|
||||
photo.thumbnail_path.replace(oldSlug, newSlug) : null;
|
||||
|
||||
// Rename physical file if filename changed
|
||||
if (newFilename !== oldFilename) {
|
||||
const oldFilePath = path.join(this.storagePath, 'events/active', newSlug,
|
||||
photo.type === 'collage' ? 'collages' : 'individual', oldFilename);
|
||||
const newFilePath = path.join(this.storagePath, 'events/active', newSlug,
|
||||
photo.type === 'collage' ? 'collages' : 'individual', newFilename);
|
||||
|
||||
try {
|
||||
await fs.rename(oldFilePath, newFilePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
logger.warn('Could not rename photo file', { oldFilename, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update database record
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
filename: newFilename,
|
||||
path: newPath,
|
||||
thumbnail_path: newThumbnailPath
|
||||
});
|
||||
|
||||
renamedCount++;
|
||||
} catch (error) {
|
||||
logger.error('Error renaming photo', { photoId: photo.id, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return renamedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update database records for the rename
|
||||
* @param {object} trx - Knex transaction
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} oldSlug - Current slug
|
||||
* @param {string} newSlug - New slug
|
||||
* @param {string} newEventName - New event name
|
||||
* @returns {Promise<{newShareLink: string}>}
|
||||
*/
|
||||
async updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName) {
|
||||
const event = await trx('events').where({ id: eventId }).first();
|
||||
|
||||
// Generate new share link
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({
|
||||
slug: newSlug,
|
||||
shareToken: event.share_token
|
||||
});
|
||||
|
||||
// Update event
|
||||
await trx('events')
|
||||
.where({ id: eventId })
|
||||
.update({
|
||||
event_name: newEventName,
|
||||
slug: newSlug,
|
||||
share_link: shareLinkToStore
|
||||
});
|
||||
|
||||
return { newShareLink: shareUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redirect entry for the old slug
|
||||
* @param {object} trx - Knex transaction
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} oldSlug - Old slug
|
||||
* @param {string} newSlug - New slug
|
||||
*/
|
||||
async createSlugRedirect(trx, eventId, oldSlug, newSlug) {
|
||||
// Check if table exists
|
||||
const hasTable = await trx.schema.hasTable('slug_redirects');
|
||||
if (!hasTable) {
|
||||
logger.warn('slug_redirects table does not exist, skipping redirect creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if redirect already exists
|
||||
const existingRedirect = await trx('slug_redirects')
|
||||
.where({ old_slug: oldSlug })
|
||||
.first();
|
||||
|
||||
if (existingRedirect) {
|
||||
// Update existing redirect
|
||||
await trx('slug_redirects')
|
||||
.where({ old_slug: oldSlug })
|
||||
.update({ new_slug: newSlug });
|
||||
} else {
|
||||
// Create new redirect
|
||||
await trx('slug_redirects').insert({
|
||||
old_slug: oldSlug,
|
||||
new_slug: newSlug,
|
||||
event_id: eventId
|
||||
});
|
||||
}
|
||||
|
||||
// Also update any existing redirects pointing to the old slug
|
||||
await trx('slug_redirects')
|
||||
.where({ new_slug: oldSlug })
|
||||
.update({ new_slug: newSlug });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification email about the rename
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} newShareLink - New share link
|
||||
*/
|
||||
async sendRenamedEventEmail(eventId, newShareLink) {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) return;
|
||||
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
if (!recipientEmail) return;
|
||||
|
||||
const recipientName = event.customer_name || event.host_name ||
|
||||
(recipientEmail ? recipientEmail.split('@')[0] : 'Guest');
|
||||
|
||||
await queueEmail(eventId, recipientEmail, 'gallery_link_updated', {
|
||||
customer_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
new_gallery_link: newShareLink,
|
||||
event_date: event.event_date
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a failed rename operation
|
||||
* @param {object} backupData - Backup data from the rename attempt
|
||||
*/
|
||||
async rollbackRename(backupData) {
|
||||
try {
|
||||
if (backupData.folderRenamed && backupData.event) {
|
||||
const oldPath = path.join(this.storagePath, 'events/active', backupData.newSlug);
|
||||
const newPath = path.join(this.storagePath, 'events/active', backupData.event.slug);
|
||||
|
||||
try {
|
||||
await fs.rename(oldPath, newPath);
|
||||
logger.info('Rolled back folder rename');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rollback folder rename', { error: error.message });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Rollback failed', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to rename an event
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} newEventName - New event name
|
||||
* @param {boolean} resendEmail - Whether to resend invitation email
|
||||
* @param {object} adminUser - Admin user performing the action
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
async renameEvent(eventId, newEventName, resendEmail = false, adminUser = null) {
|
||||
const backupData = {};
|
||||
|
||||
try {
|
||||
// 1. Validate
|
||||
const validation = await this.validateRename(eventId, newEventName);
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// 2. Get current event data
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
backupData.event = event;
|
||||
backupData.newSlug = validation.newSlug;
|
||||
|
||||
const oldSlug = event.slug;
|
||||
const oldName = event.event_name;
|
||||
const newSlug = validation.newSlug;
|
||||
|
||||
// 3. Start transaction
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
// 4. Rename folder (filesystem)
|
||||
await this.renameEventFolder(oldSlug, newSlug);
|
||||
backupData.folderRenamed = true;
|
||||
|
||||
// 5. Rename photo files and update paths
|
||||
const filesRenamed = await this.renamePhotoFiles(eventId, oldName, newEventName.trim(), oldSlug, newSlug);
|
||||
|
||||
// 6. Update database records
|
||||
const { newShareLink } = await this.updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName.trim());
|
||||
|
||||
// 7. Create redirect entry
|
||||
await this.createSlugRedirect(trx, eventId, oldSlug, newSlug);
|
||||
|
||||
// 8. Log activity
|
||||
await trx('activity_logs').insert({
|
||||
activity_type: 'event_renamed',
|
||||
actor_type: adminUser ? 'admin' : 'system',
|
||||
actor_id: adminUser?.id || null,
|
||||
actor_name: adminUser?.username || 'system',
|
||||
metadata: JSON.stringify({
|
||||
old_name: oldName,
|
||||
new_name: newEventName.trim(),
|
||||
old_slug: oldSlug,
|
||||
new_slug: newSlug,
|
||||
files_renamed: filesRenamed,
|
||||
email_sent: resendEmail
|
||||
}),
|
||||
event_id: eventId
|
||||
});
|
||||
|
||||
// 9. Commit transaction
|
||||
await trx.commit();
|
||||
|
||||
// 10. Send email (after commit, non-critical)
|
||||
let emailSent = false;
|
||||
if (resendEmail) {
|
||||
try {
|
||||
await this.sendRenamedEventEmail(eventId, newShareLink);
|
||||
emailSent = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send rename notification email', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
eventId,
|
||||
oldName,
|
||||
newName: newEventName.trim(),
|
||||
oldSlug,
|
||||
newSlug,
|
||||
newShareLink,
|
||||
emailSent,
|
||||
filesRenamed
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await trx.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Event rename failed', { eventId, error: error.message });
|
||||
await this.rollbackRename(backupData);
|
||||
return { success: false, error: error.message || 'Failed to rename event' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EventRenameService();
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Event Service Layer
|
||||
* Handles all event-related business logic
|
||||
*
|
||||
* @module services/eventService
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Cache for schema detection
|
||||
let customerColumnCache = null;
|
||||
|
||||
/**
|
||||
* Check if the database has the new customer_email column
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map event for API response (normalize customer fields)
|
||||
* @param {Object} event - Database event object
|
||||
* @returns {Object} - Normalized event object
|
||||
*/
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique slug for an event
|
||||
* @param {string} eventType
|
||||
* @param {string} eventName
|
||||
* @param {string} eventDate
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create event storage folders
|
||||
* @param {string} slug - Event slug
|
||||
* @returns {Promise<string>} - Path to event folder
|
||||
*/
|
||||
const createEventFolders = async (slug) => {
|
||||
const storagePath = getStoragePath();
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
return eventPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* @param {Object} eventData - Event data
|
||||
* @returns {Promise<Object>} - Created event
|
||||
*/
|
||||
const createEvent = async (eventData) => {
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30,
|
||||
// Feedback settings
|
||||
feedback_enabled,
|
||||
allow_ratings,
|
||||
allow_likes,
|
||||
allow_comments,
|
||||
allow_favorites,
|
||||
require_name_email,
|
||||
moderate_comments,
|
||||
show_feedback_to_guests,
|
||||
// Upload settings
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
} = eventData;
|
||||
|
||||
const requirePassword = parseBooleanInput(require_password, true);
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Validate password if required
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
const error = new Error('Password does not meet security requirements');
|
||||
error.code = 'PASSWORD_INVALID';
|
||||
error.details = passwordValidation.errors;
|
||||
error.score = passwordValidation.score;
|
||||
error.feedback = passwordValidation.feedback;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const slug = await generateUniqueSlug(event_type, event_name, event_date);
|
||||
|
||||
// Generate share link
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
await createEventFolders(slug);
|
||||
|
||||
// Build insert data
|
||||
const insertData = {
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
...(customerColumnsAvailable ? { customer_name, customer_email } : {}),
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
// Feedback settings
|
||||
feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined,
|
||||
allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined,
|
||||
allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined,
|
||||
allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined,
|
||||
allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined,
|
||||
require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined,
|
||||
moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined,
|
||||
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
|
||||
// Upload settings
|
||||
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
|
||||
upload_category_id: upload_category_id || null
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(insertData).forEach(key => {
|
||||
if (insertData[key] === undefined) {
|
||||
delete insertData[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert(insertData).returning('id');
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
return {
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword,
|
||||
customer_name,
|
||||
customer_email
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all events with optional filtering
|
||||
* @param {Object} options - Filter options
|
||||
* @param {string} options.status - 'all', 'active', or 'archived'
|
||||
* @returns {Promise<Array>} - Array of events
|
||||
*/
|
||||
const getAllEvents = async (options = {}) => {
|
||||
const { status = 'all' } = options;
|
||||
|
||||
let query = db('events').select('*');
|
||||
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
}
|
||||
|
||||
const events = await query.orderBy('created_at', 'desc');
|
||||
|
||||
// Add photo counts
|
||||
for (const event of events) {
|
||||
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
return events.map(mapEventForApi);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
* @param {number} id - Event ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventById = async (id) => {
|
||||
const event = await db('events').where('id', id).first();
|
||||
return event ? mapEventForApi(event) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single event by slug
|
||||
* @param {string} slug - Event slug
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventBySlug = async (slug) => {
|
||||
const event = await db('events').where('slug', slug).first();
|
||||
return event ? mapEventForApi(event) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an event
|
||||
* @param {number} id - Event ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Object>} - Updated event
|
||||
*/
|
||||
const updateEvent = async (id, updates) => {
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
// Handle legacy field names
|
||||
if (updates.host_name || updates.host_email) {
|
||||
throw new Error('host_name and host_email are no longer supported. Use customer_name and customer_email instead.');
|
||||
}
|
||||
|
||||
// Handle customer name update
|
||||
if (updates.customer_name !== undefined) {
|
||||
const nextName = parseStringInput(updates.customer_name);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle customer email update
|
||||
if (updates.customer_email !== undefined) {
|
||||
const nextEmail = parseStringInput(updates.customer_email);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle require_password update
|
||||
if (updates.require_password !== undefined) {
|
||||
const requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
|
||||
// Get current event to check password requirements
|
||||
const event = await db('events').where('id', id).first();
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
// If enabling password and it was previously disabled, require a new password
|
||||
if (requirePasswordUpdate === true && !currentRequirePassword && !updates.password) {
|
||||
throw new Error('Password must be provided when enabling password requirement.');
|
||||
}
|
||||
|
||||
// If disabling password, generate a random hash
|
||||
if (requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle password update
|
||||
if (updates.password) {
|
||||
updates.password_hash = await bcrypt.hash(updates.password, getBcryptRounds());
|
||||
delete updates.password;
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft delete an event (mark as inactive)
|
||||
* @param {number} id - Event ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const deleteEvent = async (id) => {
|
||||
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Extend event expiration
|
||||
* @param {number} id - Event ID
|
||||
* @param {number} days - Days to extend
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const extendExpiration = async (id, days) => {
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // Reactivate if expired
|
||||
});
|
||||
|
||||
return { expires_at: newExpiration };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// Core CRUD
|
||||
createEvent,
|
||||
getAllEvents,
|
||||
getEventById,
|
||||
getEventBySlug,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
extendExpiration,
|
||||
|
||||
// Utilities
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
generateUniqueSlug,
|
||||
createEventFolders
|
||||
};
|
||||
@@ -58,11 +58,15 @@ async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Determine language based on email domain
|
||||
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en';
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
|
||||
|
||||
// Queue email to host
|
||||
await queueEmail(event.id, event.host_email, 'expiration_warning', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
// Queue email to customer
|
||||
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiration_date: await formatDate(event.expires_at, emailLang),
|
||||
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
|
||||
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
// Queue expiration emails
|
||||
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
|
||||
event_name: event.event_name,
|
||||
admin_email: event.admin_email
|
||||
admin_email: event.admin_email,
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail
|
||||
});
|
||||
|
||||
// Also notify admin
|
||||
|
||||
@@ -3,8 +3,10 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
|
||||
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
|
||||
const eventSlug = pathParts[0];
|
||||
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
|
||||
|
||||
// Check if this is an image file
|
||||
// Check if this is an image or video file
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
const detectedMime = mime.lookup(filePath) || '';
|
||||
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
|
||||
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
|
||||
// Skip temporary upload files
|
||||
const filename = path.basename(filePath);
|
||||
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
|
||||
// Get file stats
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(filePath);
|
||||
// Generate thumbnail or placeholder
|
||||
let thumbnailPath = null;
|
||||
if (isVideo) {
|
||||
thumbnailPath = await generateVideoPlaceholder(filename);
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(filePath);
|
||||
}
|
||||
|
||||
// Calculate relative thumbnail path
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
|
||||
|
||||
// Check if photo already exists
|
||||
const existingPhoto = await db('photos')
|
||||
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
|
||||
filename: path.basename(filePath),
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: stats.size
|
||||
type: isVideo ? 'video' : photoType,
|
||||
size_bytes: stats.size,
|
||||
mime_type: mimeType
|
||||
});
|
||||
|
||||
logger.info(`Added new photo: ${relativePath}`);
|
||||
|
||||
@@ -18,6 +18,29 @@ const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||
|
||||
// Helper to parse setting value (handles both JSON-encoded and plain values)
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
// Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"')
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, return the raw value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that fit value is valid for Sharp
|
||||
function validateFitValue(fit) {
|
||||
const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside'];
|
||||
if (fit && validFitValues.includes(fit)) {
|
||||
return fit;
|
||||
}
|
||||
return DEFAULT_THUMBNAIL_FIT;
|
||||
}
|
||||
|
||||
// Get thumbnail settings from database
|
||||
async function getThumbnailSettings() {
|
||||
try {
|
||||
@@ -30,16 +53,19 @@ async function getThumbnailSettings() {
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.setting_key] = s.setting_value;
|
||||
settingsMap[s.setting_key] = parseSettingValue(s.setting_value);
|
||||
});
|
||||
|
||||
|
||||
// Parse and validate fit value
|
||||
const fitValue = validateFitValue(settingsMap.thumbnail_fit);
|
||||
|
||||
return {
|
||||
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
|
||||
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
|
||||
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
|
||||
fit: fitValue,
|
||||
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
|
||||
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
|
||||
};
|
||||
@@ -211,4 +237,54 @@ async function ensureThumbnail(photo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||
async function generateVideoPlaceholder(originalFilename, options = {}) {
|
||||
const parsed = path.parse(originalFilename || '');
|
||||
const baseName = parsed.name || 'video';
|
||||
const thumbnailDir = getThumbnailPath();
|
||||
const thumbnailFilename = `thumb_${baseName}.jpg`;
|
||||
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
||||
|
||||
const settings = await getThumbnailSettings();
|
||||
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
|
||||
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch (_) {
|
||||
// ignore if missing
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const svg = `
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
|
||||
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
|
||||
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
|
||||
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
|
||||
VIDEO
|
||||
</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
await sharp(Buffer.from(svg))
|
||||
.resize(width, height, { fit: 'cover' })
|
||||
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate video placeholder thumbnail:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Photo Export Service
|
||||
* Handles exporting filtered photos in various formats
|
||||
*/
|
||||
|
||||
const archiver = require('archiver');
|
||||
const { PassThrough } = require('stream');
|
||||
const { XmpGenerator } = require('./xmpGenerator');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
class PhotoExportService {
|
||||
constructor() {
|
||||
this.xmpGenerator = new XmpGenerator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos with full feedback data
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
|
||||
* @returns {Promise<Object[]>} Photos with feedback
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.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.file_size',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
)
|
||||
.orderBy('photos.filename', 'asc');
|
||||
|
||||
if (photoIds && photoIds.length > 0) {
|
||||
query = query.whereIn('photos.id', photoIds);
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export photos in the specified format
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {number[]} photoIds - Photo IDs to export
|
||||
* @param {string} format - Export format (txt, csv, xmp, photos, json)
|
||||
* @param {Object} options - Export options
|
||||
* @returns {Promise<Object>} Export result with stream/content
|
||||
*/
|
||||
async exportPhotos(eventId, photoIds, format, options = {}) {
|
||||
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
|
||||
|
||||
if (photos.length === 0) {
|
||||
throw new Error('No photos to export');
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'txt':
|
||||
return this.exportAsTxt(photos, options);
|
||||
case 'csv':
|
||||
return this.exportAsCsv(photos, options);
|
||||
case 'xmp':
|
||||
return this.exportAsXmpZip(photos, options);
|
||||
case 'json':
|
||||
return this.exportAsJson(photos, eventId, options);
|
||||
default:
|
||||
throw new Error(`Unknown export format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as plain text filename list
|
||||
*/
|
||||
exportAsTxt(photos, options = {}) {
|
||||
const { filename_format = 'original', separator = 'newline' } = options;
|
||||
|
||||
const filenames = photos.map(photo =>
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename
|
||||
);
|
||||
|
||||
let content;
|
||||
switch (separator) {
|
||||
case 'comma':
|
||||
content = filenames.join(', ');
|
||||
break;
|
||||
case 'semicolon':
|
||||
content = filenames.join('; ');
|
||||
break;
|
||||
default:
|
||||
content = filenames.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
content,
|
||||
filename: `photo_list_${Date.now()}.txt`,
|
||||
contentType: 'text/plain'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as CSV with metadata
|
||||
*/
|
||||
exportAsCsv(photos, options = {}) {
|
||||
const { filename_format = 'original' } = options;
|
||||
|
||||
const headers = [
|
||||
'filename',
|
||||
'original_filename',
|
||||
'rating',
|
||||
'rating_count',
|
||||
'likes',
|
||||
'favorites',
|
||||
'comments',
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
'file_size',
|
||||
'created_at'
|
||||
];
|
||||
|
||||
const rows = photos.map(photo => [
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename,
|
||||
photo.original_filename || '',
|
||||
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
|
||||
photo.feedback_count || 0,
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
photo.comment_count || 0,
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
photo.file_size || '',
|
||||
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
content: csvContent,
|
||||
filename: `photo_export_${Date.now()}.csv`,
|
||||
contentType: 'text/csv'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as XMP sidecar files in a ZIP archive
|
||||
*/
|
||||
async exportAsXmpZip(photos, options = {}) {
|
||||
const { filename_format = 'original' } = options;
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const passthrough = new PassThrough();
|
||||
archive.pipe(passthrough);
|
||||
|
||||
for (const photo of photos) {
|
||||
const baseFilename = filename_format === 'original'
|
||||
? photo.original_filename
|
||||
: photo.filename;
|
||||
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
||||
|
||||
archive.append(xmpContent, { name: xmpFilename });
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
|
||||
return {
|
||||
type: 'stream',
|
||||
stream: passthrough,
|
||||
filename: `xmp_export_${Date.now()}.zip`,
|
||||
contentType: 'application/zip'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as JSON metadata
|
||||
*/
|
||||
async exportAsJson(photos, eventId, options = {}) {
|
||||
// Get event info
|
||||
const event = await db('events')
|
||||
.where('id', eventId)
|
||||
.select('event_name', 'event_date', 'slug')
|
||||
.first();
|
||||
|
||||
const exportData = {
|
||||
export_info: {
|
||||
event_name: event?.event_name || 'Unknown Event',
|
||||
event_date: event?.event_date,
|
||||
event_slug: event?.slug,
|
||||
exported_at: new Date().toISOString(),
|
||||
total_photos: photos.length
|
||||
},
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename,
|
||||
category: photo.category_name || null,
|
||||
rating: {
|
||||
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
|
||||
count: photo.feedback_count || 0
|
||||
},
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_count || 0,
|
||||
comments: photo.comment_count || 0,
|
||||
dimensions: {
|
||||
width: photo.width,
|
||||
height: photo.height
|
||||
},
|
||||
file_size: photo.file_size,
|
||||
created_at: photo.created_at
|
||||
}))
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
content: JSON.stringify(exportData, null, 2),
|
||||
filename: `photo_metadata_${Date.now()}.json`,
|
||||
contentType: 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export format display names
|
||||
*/
|
||||
static getFormatOptions() {
|
||||
return [
|
||||
{ value: 'txt', label: 'Filename List (TXT)', description: 'Simple text list of filenames' },
|
||||
{ value: 'csv', label: 'Filename List (CSV)', description: 'Spreadsheet with metadata' },
|
||||
{ value: 'xmp', label: 'XMP Sidecar Files (ZIP)', description: 'For Lightroom/Bridge/Capture One' },
|
||||
{ value: 'json', label: 'Metadata (JSON)', description: 'Structured data for automation' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PhotoExportService };
|
||||
@@ -3,25 +3,52 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
function normalizeFiles(files) {
|
||||
if (!files) return [];
|
||||
if (Array.isArray(files)) return files.filter(Boolean);
|
||||
|
||||
// Multer may expose files as an iterable object
|
||||
if (typeof files[Symbol.iterator] === 'function') {
|
||||
return Array.from(files).filter(Boolean);
|
||||
// Handle null, undefined, or falsy values
|
||||
if (!files) {
|
||||
console.log('[normalizeFiles] No files provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(files)) {
|
||||
const validFiles = files.filter(Boolean);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
|
||||
return validFiles;
|
||||
}
|
||||
|
||||
// Handle iterable objects (some multer configurations)
|
||||
try {
|
||||
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
|
||||
const validFiles = Array.from(files).filter(Boolean);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
|
||||
return validFiles;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
|
||||
}
|
||||
|
||||
// Handle plain objects (multer fieldname mapping)
|
||||
if (typeof files === 'object') {
|
||||
return Object.values(files)
|
||||
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||
.filter(Boolean);
|
||||
try {
|
||||
const validFiles = Object.values(files)
|
||||
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||
.filter(Boolean);
|
||||
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
|
||||
return validFiles;
|
||||
} catch (err) {
|
||||
console.warn('[normalizeFiles] Failed to process files object:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Unexpected type
|
||||
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -80,59 +107,110 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
|
||||
|
||||
if (!tempPath) {
|
||||
throw new Error('Uploaded file is missing a temporary path');
|
||||
const fileInfo = JSON.stringify({
|
||||
originalname: file?.originalname,
|
||||
mimetype: file?.mimetype,
|
||||
size: file?.size,
|
||||
availableKeys: Object.keys(file || {})
|
||||
});
|
||||
throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`);
|
||||
}
|
||||
|
||||
// Verify temp file exists before copying
|
||||
try {
|
||||
await fs.access(tempPath);
|
||||
} catch (accessErr) {
|
||||
console.error(`Temp file not accessible: ${tempPath}`, {
|
||||
originalname: file?.originalname,
|
||||
error: accessErr.message
|
||||
});
|
||||
throw new Error(`Uploaded file not found at temporary location: ${tempPath}`);
|
||||
}
|
||||
|
||||
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
||||
try {
|
||||
await fs.copyFile(tempPath, newPath);
|
||||
console.log(`Successfully copied ${file.originalname} to ${newPath}`);
|
||||
} catch (copyErr) {
|
||||
console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr);
|
||||
throw new Error(`Failed to copy uploaded file: ${copyErr.message}`);
|
||||
} finally {
|
||||
// Clean up temp file with better error handling
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
console.log(`Cleaned up temp file: ${tempPath}`);
|
||||
} catch (unlinkErr) {
|
||||
// Only warn if file exists but couldn't be deleted
|
||||
// ENOENT means file was already deleted, which is fine
|
||||
if (unlinkErr?.code !== 'ENOENT') {
|
||||
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
|
||||
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
|
||||
error: unlinkErr.message,
|
||||
code: unlinkErr.code
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Determine if this is a video or image
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
|
||||
// Add to database with uploaded_by field and media metadata
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
|
||||
const photoData = {
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed',
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
// Add video-specific metadata if applicable
|
||||
if (isVideo && videoMetadata) {
|
||||
photoData.duration = videoMetadata.duration;
|
||||
photoData.video_codec = videoMetadata.videoCodec;
|
||||
photoData.audio_codec = videoMetadata.audioCodec;
|
||||
photoData.width = videoMetadata.width;
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed'
|
||||
})
|
||||
.insert(photoData)
|
||||
.returning('id');
|
||||
} else {
|
||||
insertResult = await trx('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed'
|
||||
});
|
||||
insertResult = await trx('photos').insert(photoData);
|
||||
}
|
||||
|
||||
const insertedId = Array.isArray(insertResult)
|
||||
@@ -154,10 +232,28 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
size: file.size,
|
||||
type: photoType
|
||||
});
|
||||
|
||||
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.originalname}:`, error);
|
||||
if (trx) await trx.rollback();
|
||||
console.error(`Error processing file ${file.originalname}:`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
tempPath: file?.path || file?.filepath || file?.tempFilePath
|
||||
});
|
||||
|
||||
if (trx) {
|
||||
try {
|
||||
await trx.rollback();
|
||||
} catch (rollbackErr) {
|
||||
console.error('Failed to rollback transaction:', rollbackErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with other files
|
||||
// Note: Individual file failures don't stop the entire upload batch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
function resolvePhotoFilePath(event, photo) {
|
||||
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
||||
|
||||
const mode = (event.source_mode || photo.source_origin || 'managed');
|
||||
if (mode === 'reference' || photo.source_origin === 'external') {
|
||||
// IMPORTANT: photo.source_origin takes precedence over event.source_mode
|
||||
// This allows events in "reference" mode to have mixed sources:
|
||||
// - Imported photos: source_origin = 'external'
|
||||
// - Uploaded photos: source_origin = 'managed'
|
||||
const mode = (photo.source_origin || event.source_mode || 'managed');
|
||||
if (mode === 'reference' || mode === 'external') {
|
||||
if (!photo.external_relpath) {
|
||||
throw new Error('Missing external_relpath for external photo');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user