Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdff4ebb8a | |||
| 59f958b085 | |||
| 097d7a0b65 | |||
| 1273777541 | |||
| b6a960879f | |||
| 4bd153104b | |||
| e099fcf600 | |||
| 89fdf401c0 | |||
| 9cc46a1819 | |||
| f7c0e5f51a | |||
| 200581e73c | |||
| 505acf833e | |||
| 1ac5b0447a | |||
| 15a2fc2d5f | |||
| a1cf6a1156 | |||
| 481545c37b | |||
| a67df87013 | |||
| d2dbe2ea2f | |||
| 98ea5e7202 | |||
| 6b5c08e99b | |||
| 11ecad136b | |||
| 3a4dccd9f0 | |||
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 |
+31
-18
@@ -1,26 +1,24 @@
|
|||||||
# Environment Configuration Template
|
# PicPeak Development Environment Configuration
|
||||||
# Copy this file to .env and adjust values for your environment
|
# Copy this file to .env for local development
|
||||||
|
|
||||||
# Development: Use docker-compose.dev.yml
|
# SECURITY WARNING: This configuration is for development only!
|
||||||
# Production: Use docker-compose.prod.yml with .env.production.example
|
# For production, use .env.production.example
|
||||||
|
|
||||||
# JWT Secret (CRITICAL for production)
|
# JWT Secret (Change in production!)
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate secure secret with: openssl rand -base64 32
|
||||||
JWT_SECRET=dev-secret-change-in-production
|
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
|
||||||
|
|
||||||
# Application URLs
|
# Application URLs (Docker Compose development setup)
|
||||||
ADMIN_URL=http://localhost:3005
|
ADMIN_URL=http://localhost:3005
|
||||||
FRONTEND_URL=http://localhost:3005
|
FRONTEND_URL=http://localhost:3005
|
||||||
|
BACKEND_URL=http://localhost:3001
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration (SQLite for development)
|
||||||
# SQLite is used for development by default
|
|
||||||
# For production PostgreSQL config, see .env.production.example
|
|
||||||
DATABASE_CLIENT=sqlite3
|
DATABASE_CLIENT=sqlite3
|
||||||
DATABASE_PATH=./data/photo_sharing.db
|
DATABASE_PATH=./data/photo_sharing.db
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration (Mailhog for development)
|
||||||
# Development: Uses Mailhog (included in docker-compose.dev.yml)
|
# Access Mailhog UI at: http://localhost:8025
|
||||||
# Production: Configure real SMTP server
|
|
||||||
SMTP_HOST=mailhog
|
SMTP_HOST=mailhog
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
@@ -28,7 +26,22 @@ SMTP_USER=
|
|||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
EMAIL_FROM=noreply@localhost
|
EMAIL_FROM=noreply@localhost
|
||||||
|
|
||||||
# Optional: Umami Analytics
|
# Backend Port Configuration
|
||||||
UMAMI_URL=
|
PORT=3001
|
||||||
UMAMI_WEBSITE_ID=
|
|
||||||
UMAMI_HASH_SALT=
|
# Optional: Umami Analytics Backend Config
|
||||||
|
# NOTE: Primary configuration through Admin UI > Settings > Analytics
|
||||||
|
# These are fallback values for server-side tracking
|
||||||
|
# UMAMI_URL=https://analytics.example.com
|
||||||
|
# UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
# UMAMI_HASH_SALT=your-hash-salt
|
||||||
|
|
||||||
|
# Development Features
|
||||||
|
NODE_ENV=development
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Admin Setup Notes:
|
||||||
|
# 1. Run 'npm run migrate' in backend folder
|
||||||
|
# 2. Admin credentials will be auto-generated
|
||||||
|
# 3. Check ADMIN_CREDENTIALS.txt for login details
|
||||||
|
# 4. Change password on first login (required)
|
||||||
+83
-27
@@ -1,44 +1,100 @@
|
|||||||
# PicPeak Production Configuration
|
# PicPeak Production Configuration
|
||||||
# Copy this file to .env and update with your values
|
# Copy this file to .env and update with your production values
|
||||||
|
|
||||||
# Required: Security
|
# ============================================
|
||||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
|
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
|
||||||
|
# ============================================
|
||||||
|
|
||||||
# Required: URLs (update with your domain)
|
# JWT Secret - REQUIRED (minimum 32 characters)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
|
||||||
|
|
||||||
|
# Application URLs - REQUIRED (your actual domain)
|
||||||
FRONTEND_URL=https://your-domain.com
|
FRONTEND_URL=https://your-domain.com
|
||||||
BACKEND_URL=https://your-domain.com
|
BACKEND_URL=https://your-domain.com
|
||||||
ADMIN_URL=https://your-domain.com
|
ADMIN_URL=https://your-domain.com
|
||||||
|
|
||||||
# Required: Email Settings
|
# ============================================
|
||||||
SMTP_HOST=smtp.gmail.com
|
# DATABASE CONFIGURATION - REQUIRED
|
||||||
SMTP_PORT=587
|
# ============================================
|
||||||
SMTP_USER=your-email@gmail.com
|
|
||||||
SMTP_PASS=your-app-password
|
|
||||||
SMTP_FROM=your-email@gmail.com
|
|
||||||
|
|
||||||
# Required: Initial Admin Account
|
# PostgreSQL Configuration (Recommended for production)
|
||||||
ADMIN_EMAIL=admin@your-domain.com
|
|
||||||
ADMIN_PASSWORD=change-this-password
|
|
||||||
|
|
||||||
# Database (PostgreSQL recommended for production)
|
|
||||||
DATABASE_CLIENT=pg
|
DATABASE_CLIENT=pg
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres # or your database host
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=picpeak
|
DB_NAME=picpeak
|
||||||
DB_USER=picpeak
|
DB_USER=picpeak
|
||||||
DB_PASSWORD=secure-database-password
|
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
|
||||||
|
|
||||||
# Optional: Customization
|
# ============================================
|
||||||
SITE_NAME=PicPeak
|
# EMAIL CONFIGURATION - REQUIRED
|
||||||
DEFAULT_EXPIRATION_DAYS=30
|
# ============================================
|
||||||
SESSION_TIMEOUT_MINUTES=60
|
|
||||||
|
|
||||||
# Optional: Analytics (Umami)
|
# Example: Gmail with App Password
|
||||||
VITE_UMAMI_URL=
|
# SMTP_HOST=smtp.gmail.com
|
||||||
VITE_UMAMI_WEBSITE_ID=
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=your-email@gmail.com
|
||||||
|
# SMTP_PASS=your-16-char-app-password
|
||||||
|
# EMAIL_FROM=Your Name <your-email@gmail.com>
|
||||||
|
|
||||||
|
# Example: SendGrid
|
||||||
|
SMTP_HOST=smtp.sendgrid.net
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=apikey
|
||||||
|
SMTP_PASS=YOUR-SENDGRID-API-KEY
|
||||||
|
EMAIL_FROM=PicPeak <noreply@your-domain.com>
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ADMIN SETUP - AUTO-GENERATED
|
||||||
|
# ============================================
|
||||||
|
# NOTE: Admin credentials are automatically generated during setup
|
||||||
|
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
|
||||||
|
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OPTIONAL CONFIGURATION
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Umami Analytics (Optional - Fallback values)
|
||||||
|
# Primary config via Admin UI > Settings > Analytics
|
||||||
|
# UMAMI_URL=https://analytics.your-domain.com
|
||||||
|
# UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
# UMAMI_HASH_SALT=your-hash-salt
|
||||||
|
|
||||||
|
# Frontend Analytics (Optional - Fallback values)
|
||||||
|
# VITE_UMAMI_URL=https://analytics.your-domain.com
|
||||||
|
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# PERFORMANCE & SECURITY TUNING
|
||||||
|
# ============================================
|
||||||
|
|
||||||
# Advanced: Performance Tuning
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
PORT=3001
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Security Settings (Defaults are secure)
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
SESSION_TIMEOUT_MINUTES=60
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100 # per window
|
||||||
|
|
||||||
|
# Connection Pool (Adjust based on load)
|
||||||
|
DB_POOL_MIN=5
|
||||||
|
DB_POOL_MAX=25
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# DOCKER COMPOSE SPECIFIC
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Traefik Configuration (if using Traefik)
|
||||||
|
DOMAIN=your-domain.com
|
||||||
|
LETSENCRYPT_EMAIL=admin@your-domain.com
|
||||||
|
|
||||||
|
# Volume Paths (Docker)
|
||||||
|
STORAGE_PATH=/app/storage
|
||||||
|
EVENTS_PATH=/app/storage/events
|
||||||
|
ARCHIVE_PATH=/app/storage/events/archived
|
||||||
@@ -10,10 +10,10 @@ jobs:
|
|||||||
mirror:
|
mirror:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository with full history
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Full history needed for mirroring
|
fetch-depth: 0 # Full history needed for finding the commit
|
||||||
|
|
||||||
- name: Setup Git
|
- name: Setup Git
|
||||||
run: |
|
run: |
|
||||||
@@ -28,40 +28,153 @@ jobs:
|
|||||||
git status
|
git status
|
||||||
echo "Remote info:"
|
echo "Remote info:"
|
||||||
git remote -v
|
git remote -v
|
||||||
|
echo "Checking target commit exists:"
|
||||||
|
git show --oneline 7aca927937 || echo "Target commit not found!"
|
||||||
|
|
||||||
- name: Create filtered branch
|
- name: Create completely new history from specific commit
|
||||||
run: |
|
run: |
|
||||||
|
TARGET_COMMIT="7aca927937"
|
||||||
|
|
||||||
|
# Verify the target commit exists
|
||||||
|
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
|
||||||
|
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
|
||||||
|
|
||||||
# Clean up any existing github-mirror branch
|
# Clean up any existing github-mirror branch
|
||||||
git branch -D github-mirror || true
|
git branch -D github-mirror || true
|
||||||
|
|
||||||
# Create a new branch for GitHub
|
# Create a completely new orphan branch (no history)
|
||||||
git checkout --orphan github-mirror
|
git checkout --orphan github-mirror
|
||||||
|
|
||||||
# Remove sensitive files/directories
|
# Clear the staging area completely
|
||||||
# Example: Remove .env files, private configs, etc.
|
git rm -rf . || true
|
||||||
git rm -r --cached .env* || true
|
|
||||||
git rm -r --cached backend/.env* || true
|
|
||||||
git rm -r --cached frontend/.env* || true
|
|
||||||
git rm -r --cached docker-compose.prod.yml || true
|
|
||||||
git rm -r --cached .claudedocs/ || true
|
|
||||||
git rm -r --cached backend/data/ || true
|
|
||||||
git rm -r --cached backend/storage/ || true
|
|
||||||
git rm -r --cached .gitea/ || true
|
|
||||||
git rm -r --cached scripts/install-gitea-runner.sh || true
|
|
||||||
git rm -r --cached .drone* || true
|
|
||||||
git rm -r --cached .github-mirror-exclude || true
|
|
||||||
git rm -r --cached .gitattributes-github || true
|
|
||||||
git rm -r --cached photo-sharing-prd.md || true
|
|
||||||
git rm -r --cached CLAUDE.md || true
|
|
||||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
|
||||||
git rm -r --cached logs/ || true
|
|
||||||
git rm -r --cached frontend/.claudedocs/ || true
|
|
||||||
git rm -r --cached test-maintenance.sh || true
|
|
||||||
git rm -r --cached storage/ || true
|
|
||||||
|
|
||||||
|
# Get the file tree from the target commit and create initial commit
|
||||||
|
echo "Creating new history starting from $TARGET_COMMIT..."
|
||||||
|
git read-tree $TARGET_COMMIT
|
||||||
|
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
|
||||||
|
|
||||||
# Commit the changes
|
echo "✅ Created new initial commit: $(git log --oneline -1)"
|
||||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
|
||||||
|
# Now get all commits after the target commit and apply their changes
|
||||||
|
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
|
||||||
|
|
||||||
|
if [ -n "$COMMITS_AFTER_TARGET" ]; then
|
||||||
|
echo "📋 Applying changes from commits after $TARGET_COMMIT (excluding Claude commits):"
|
||||||
|
|
||||||
|
for commit in $COMMITS_AFTER_TARGET; do
|
||||||
|
# Get the commit author name
|
||||||
|
COMMIT_AUTHOR_NAME=$(git log --format="%an" -n 1 $commit)
|
||||||
|
|
||||||
|
# Skip commits by Claude
|
||||||
|
if [ "$COMMIT_AUTHOR_NAME" = "Claude" ]; then
|
||||||
|
echo "⚠️ Skipping commit by Claude: $(git log --oneline -1 $commit)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Processing: $(git log --oneline -1 $commit)"
|
||||||
|
|
||||||
|
# Get the commit message and author info
|
||||||
|
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
|
||||||
|
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
|
||||||
|
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
|
||||||
|
|
||||||
|
# Apply the changes from this commit
|
||||||
|
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
|
||||||
|
# Apply file changes
|
||||||
|
git checkout $commit -- . || true
|
||||||
|
|
||||||
|
# Stage all changes
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
# Only commit if there are changes
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
# Create new commit with original metadata but new SHA
|
||||||
|
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||||
|
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||||
|
GIT_AUTHOR_DATE="$COMMIT_DATE"
|
||||||
|
|
||||||
|
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
|
||||||
|
else
|
||||||
|
echo "⚠️ No changes to commit for $commit"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ Skipping problematic commit $commit"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ Finished creating new history"
|
||||||
|
else
|
||||||
|
echo "✅ No commits after target commit - history starts fresh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== New History Summary ==="
|
||||||
|
echo "Total commits in new history: $(git rev-list --count github-mirror)"
|
||||||
|
echo "History starts with: $(git log --oneline --reverse | head -1)"
|
||||||
|
echo "Latest commit: $(git log --oneline -1)"
|
||||||
|
|
||||||
|
- name: Remove sensitive files and directories
|
||||||
|
run: |
|
||||||
|
# Switch to the github-mirror branch
|
||||||
|
git checkout github-mirror
|
||||||
|
|
||||||
|
echo "Current files before cleanup:"
|
||||||
|
ls -la | head -10 || true
|
||||||
|
echo "..."
|
||||||
|
|
||||||
|
# Remove sensitive files/directories if they exist
|
||||||
|
echo "Removing sensitive files..."
|
||||||
|
rm -rf .env || true
|
||||||
|
rm -rf backend/.env* || true
|
||||||
|
rm -rf frontend/.env* || true
|
||||||
|
rm -rf docker-compose.prod.yml || true
|
||||||
|
rm -rf .claudedocs/ || true
|
||||||
|
rm -rf backend/data/ || true
|
||||||
|
rm -rf backend/storage/ || true
|
||||||
|
rm -rf .gitea/ || true
|
||||||
|
rm -rf scripts/install-gitea-runner.sh || true
|
||||||
|
rm -rf .drone* || true
|
||||||
|
rm -rf .github-mirror-exclude || true
|
||||||
|
rm -rf .gitattributes-github || true
|
||||||
|
rm -rf photo-sharing-prd.md || true
|
||||||
|
rm -rf CLAUDE.md || true
|
||||||
|
rm -rf PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||||
|
rm -rf logs/ || true
|
||||||
|
rm -rf frontend/.claudedocs/ || true
|
||||||
|
rm -rf test-maintenance.sh || true
|
||||||
|
rm -rf storage/ || true
|
||||||
|
|
||||||
|
echo "Sensitive files removal completed"
|
||||||
|
|
||||||
|
# Add and commit the cleanup if there are changes
|
||||||
|
git add -A
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||||
|
echo "✅ Committed cleanup of sensitive files"
|
||||||
|
else
|
||||||
|
echo "✅ No sensitive files to remove"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Final file structure (top level):"
|
||||||
|
ls -la | head -10 || true
|
||||||
|
|
||||||
|
- name: Verify completely new history
|
||||||
|
run: |
|
||||||
|
git checkout github-mirror
|
||||||
|
echo "=== Final History Verification ==="
|
||||||
|
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
|
||||||
|
echo ""
|
||||||
|
echo "Complete commit history (should start from target commit content):"
|
||||||
|
git log --oneline --reverse
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
|
||||||
|
echo "🔍 Original target commit content preserved but with new commit ID"
|
||||||
|
|
||||||
- name: Check GitHub token
|
- name: Check GitHub token
|
||||||
env:
|
env:
|
||||||
@@ -74,10 +187,13 @@ jobs:
|
|||||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Push to GitHub
|
- name: Force push completely new history to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
# Switch to github-mirror branch
|
||||||
|
git checkout github-mirror
|
||||||
|
|
||||||
# Remove existing github remote if it exists
|
# Remove existing github remote if it exists
|
||||||
git remote remove github || true
|
git remote remove github || true
|
||||||
|
|
||||||
@@ -88,12 +204,17 @@ jobs:
|
|||||||
echo "GitHub remote added:"
|
echo "GitHub remote added:"
|
||||||
git remote -v
|
git remote -v
|
||||||
|
|
||||||
# Force push the filtered branch to GitHub main
|
# Force push the completely new history to GitHub main
|
||||||
echo "Pushing to GitHub..."
|
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
||||||
|
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
||||||
git push github github-mirror:main --force
|
git push github github-mirror:main --force
|
||||||
echo "Push completed successfully!"
|
echo "✅ Force push completed - GitHub now has completely new history!"
|
||||||
|
|
||||||
- name: Workflow completed
|
- name: Workflow completed
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||||
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
||||||
|
echo "📊 History starts from commit content: 7aca927937"
|
||||||
|
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
|
||||||
|
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
|
||||||
|
echo "🆕 All commit SHAs are NEW - no connection to previous history"
|
||||||
@@ -164,11 +164,25 @@ jobs:
|
|||||||
- name: Commit version bump
|
- name: Commit version bump
|
||||||
if: steps.version.outputs.version_changed == 'true'
|
if: steps.version.outputs.version_changed == 'true'
|
||||||
run: |
|
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 }}"
|
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||||
|
|
||||||
if [ "$COMPONENT" = "both" ]; then
|
if [ "$COMPONENT" = "both" ]; then
|
||||||
git add backend/package.json backend/package-lock.json
|
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
|
||||||
git add frontend/package.json frontend/package-lock.json
|
|
||||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||||
elif [ "$COMPONENT" = "backend" ]; then
|
elif [ "$COMPONENT" = "backend" ]; then
|
||||||
git add backend/package.json backend/package-lock.json
|
git add backend/package.json backend/package-lock.json
|
||||||
@@ -178,7 +192,51 @@ jobs:
|
|||||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git push
|
# 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
|
- name: Create Git tag
|
||||||
if: steps.version.outputs.version_changed == 'true'
|
if: steps.version.outputs.version_changed == 'true'
|
||||||
|
|||||||
@@ -54,3 +54,5 @@ coverage/
|
|||||||
!storage/thumbnails/.gitkeep
|
!storage/thumbnails/.gitkeep
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
!logs/.gitkeep
|
!logs/.gitkeep
|
||||||
|
|
||||||
|
PRODUCTION_DEPLOYMENT_GUIDE.md
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Production Deployment Guide
|
|
||||||
|
|
||||||
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Frontend Configuration
|
|
||||||
|
|
||||||
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
|
|
||||||
|
|
||||||
1. Copy the production environment template:
|
|
||||||
```bash
|
|
||||||
cp frontend/.env.production.example frontend/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Set the API URL to use relative path:
|
|
||||||
```env
|
|
||||||
# frontend/.env.production
|
|
||||||
VITE_API_URL=/api
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures all API calls will use the same domain and protocol as the frontend.
|
|
||||||
|
|
||||||
### Backend Configuration
|
|
||||||
|
|
||||||
Ensure your backend `.env` file has the correct URLs:
|
|
||||||
```env
|
|
||||||
# backend/.env
|
|
||||||
FRONTEND_URL=https://yourdomain.com
|
|
||||||
ADMIN_URL=https://yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Compose Production
|
|
||||||
|
|
||||||
When using Docker Compose in production:
|
|
||||||
|
|
||||||
1. Build with production environment:
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
|
|
||||||
```
|
|
||||||
|
|
||||||
2. The frontend nginx configuration already includes proper proxy settings for:
|
|
||||||
- `/api` → Backend API
|
|
||||||
- `/photos` → Protected photo access
|
|
||||||
- `/thumbnails` → Thumbnail images
|
|
||||||
- `/uploads` → Public uploads (logos, favicons)
|
|
||||||
|
|
||||||
## Traefik Configuration
|
|
||||||
|
|
||||||
Example Traefik labels for docker-compose:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
frontend:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
|
||||||
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
|
|
||||||
|
|
||||||
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
|
|
||||||
|
|
||||||
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
|
|
||||||
|
|
||||||
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After deployment, verify:
|
|
||||||
|
|
||||||
1. Check browser console for any localhost URLs (there should be none)
|
|
||||||
2. Verify all API calls use HTTPS
|
|
||||||
3. Check that images load correctly with authentication
|
|
||||||
4. Test favicon and logo display
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
If you see console errors about localhost:
|
|
||||||
|
|
||||||
1. Ensure `VITE_API_URL=/api` in frontend environment
|
|
||||||
2. Clear browser cache
|
|
||||||
3. Rebuild frontend with production environment:
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
If images don't load:
|
|
||||||
|
|
||||||
1. Check that nginx proxy locations are configured
|
|
||||||
2. Verify authentication tokens are being sent
|
|
||||||
3. Check backend logs for authentication errors
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Production Deployment Guide
|
# Production Deployment Guide
|
||||||
|
|
||||||
This guide addresses all known production deployment issues and provides solutions.
|
This comprehensive guide addresses all production deployment scenarios and common issues.
|
||||||
|
|
||||||
## Pre-Deployment Checklist
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
@@ -8,43 +8,80 @@ This guide addresses all known production deployment issues and provides solutio
|
|||||||
Create a `.env` file with ALL required variables:
|
Create a `.env` file with ALL required variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
# CRITICAL - Must change these!
|
||||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||||
DB_PASSWORD=<strong-password>
|
DB_PASSWORD=<strong-password>
|
||||||
|
|
||||||
|
# Application URLs (your actual domain)
|
||||||
ADMIN_URL=https://yourdomain.com
|
ADMIN_URL=https://yourdomain.com
|
||||||
FRONTEND_URL=https://yourdomain.com
|
FRONTEND_URL=https://yourdomain.com
|
||||||
|
BACKEND_URL=https://yourdomain.com
|
||||||
|
|
||||||
# Database
|
# Database (PostgreSQL)
|
||||||
|
DATABASE_CLIENT=pg
|
||||||
|
DB_HOST=postgres # or external host
|
||||||
|
DB_PORT=5432
|
||||||
DB_USER=picpeak
|
DB_USER=picpeak
|
||||||
DB_NAME=picpeak
|
DB_NAME=picpeak
|
||||||
|
|
||||||
# Email (Optional but recommended)
|
# Email Configuration (required for notifications)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=your-email@gmail.com
|
SMTP_USER=your-email@gmail.com
|
||||||
SMTP_PASS=your-app-password
|
SMTP_PASS=your-app-password # Use app-specific password
|
||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=PicPeak <noreply@yourdomain.com>
|
||||||
|
|
||||||
# Umami Analytics (Optional)
|
# Port Configuration
|
||||||
UMAMI_URL=https://analytics.yourdomain.com
|
PORT=3001
|
||||||
UMAMI_WEBSITE_ID=your-website-id
|
|
||||||
UMAMI_HASH_SALT=<generate-random-string>
|
# Performance Tuning
|
||||||
|
DB_POOL_MIN=5
|
||||||
|
DB_POOL_MAX=25
|
||||||
|
NODE_ENV=production
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Optional: Umami Analytics (configured via Admin UI)
|
||||||
|
# UMAMI_URL=https://analytics.yourdomain.com
|
||||||
|
# UMAMI_WEBSITE_ID=your-website-id
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Generate Secrets
|
### 2. Generate Secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate JWT Secret
|
# Generate JWT Secret (REQUIRED)
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
|
|
||||||
# Generate Database Password
|
# Generate Database Password
|
||||||
openssl rand -base64 24
|
openssl rand -base64 24
|
||||||
|
|
||||||
# Generate Umami Hash Salt
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Frontend Configuration
|
||||||
|
|
||||||
|
For production deployment behind a reverse proxy:
|
||||||
|
|
||||||
|
### Frontend Environment
|
||||||
|
```bash
|
||||||
|
# frontend/.env.production
|
||||||
|
VITE_API_URL=/api # Uses relative path for reverse proxy
|
||||||
|
|
||||||
|
# Optional: Umami fallback (primary config via Admin UI)
|
||||||
|
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
||||||
|
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures all API calls use the same domain/protocol as the frontend.
|
||||||
|
|
||||||
|
### Nginx Proxy Configuration
|
||||||
|
|
||||||
|
The frontend nginx configuration already includes proper proxy settings for:
|
||||||
|
- `/api` → Backend API
|
||||||
|
- `/photos` → Protected photo access
|
||||||
|
- `/thumbnails` → Thumbnail images
|
||||||
|
- `/uploads` → Public uploads (logos, favicons)
|
||||||
|
|
||||||
|
All static assets are served through the nginx proxy, inheriting authentication headers.
|
||||||
|
|
||||||
## Deployment Steps
|
## Deployment Steps
|
||||||
|
|
||||||
### 1. Initial Setup
|
### 1. Initial Setup
|
||||||
@@ -96,24 +133,31 @@ docker-compose -f docker-compose.prod.yml up -d
|
|||||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Create Admin User
|
### 4. Initial Admin Setup
|
||||||
|
|
||||||
After deployment, create the first admin user:
|
The admin user is automatically created during database migration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enter backend container
|
# Run migrations (this creates admin user)
|
||||||
docker-compose -f docker-compose.prod.yml exec backend sh
|
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
|
||||||
|
|
||||||
# Create admin
|
# Admin credentials will be displayed in console and saved to ADMIN_CREDENTIALS.txt
|
||||||
node scripts/create-admin.js \
|
# Example output:
|
||||||
--username admin \
|
# ========================================
|
||||||
--email admin@yourdomain.com \
|
# ✅ Admin user created successfully!
|
||||||
--password <your-secure-password>
|
# ========================================
|
||||||
|
# Username: admin
|
||||||
|
# Password: SwiftEagle3847!
|
||||||
|
#
|
||||||
|
# ⚠️ IMPORTANT: Change password on first login
|
||||||
|
# ========================================
|
||||||
|
|
||||||
# Exit container
|
# Retrieve credentials if needed
|
||||||
exit
|
docker-compose -f docker-compose.prod.yml exec backend cat ADMIN_CREDENTIALS.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Important**: You MUST change the auto-generated password on first login.
|
||||||
|
|
||||||
### 5. Configure Email (if using database config)
|
### 5. Configure Email (if using database config)
|
||||||
|
|
||||||
1. Login to admin panel: https://yourdomain.com/admin
|
1. Login to admin panel: https://yourdomain.com/admin
|
||||||
@@ -189,6 +233,23 @@ docker-compose -f docker-compose.prod.yml logs backend | grep email
|
|||||||
|
|
||||||
## SSL/HTTPS Setup
|
## SSL/HTTPS Setup
|
||||||
|
|
||||||
|
### Option 1: Using Traefik (Recommended)
|
||||||
|
|
||||||
|
Add these labels to your docker-compose override:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
|
||||||
|
- "traefik.http.routers.picpeak.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Using Certbot
|
||||||
|
|
||||||
1. Update `nginx/sites-enabled/default` with your domain
|
1. Update `nginx/sites-enabled/default` with your domain
|
||||||
2. Run certbot:
|
2. Run certbot:
|
||||||
|
|
||||||
@@ -294,13 +355,18 @@ docker-compose -f docker-compose.prod.yml up -d
|
|||||||
|
|
||||||
- [ ] Strong JWT_SECRET (min 32 chars)
|
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||||
- [ ] Strong database password
|
- [ ] Strong database password
|
||||||
|
- [ ] Admin password changed from auto-generated one
|
||||||
- [ ] SSL/HTTPS enabled
|
- [ ] SSL/HTTPS enabled
|
||||||
- [ ] Firewall configured (only 80/443 open)
|
- [ ] Firewall configured (only 80/443 open)
|
||||||
- [ ] Regular security updates
|
- [ ] Regular security updates
|
||||||
- [ ] Backup encryption
|
- [ ] Backup encryption
|
||||||
- [ ] Access logs monitored
|
- [ ] Access logs monitored
|
||||||
- [ ] Rate limiting enabled
|
- [ ] Rate limiting enabled (built-in)
|
||||||
- [ ] File upload restrictions configured
|
- [ ] File upload restrictions configured
|
||||||
|
- [ ] Password complexity requirements configured (Admin > Settings)
|
||||||
|
- [ ] Session timeout configured (default 60 min)
|
||||||
|
- [ ] Umami analytics configured (if using)
|
||||||
|
- [ ] SMTP credentials secured with app-specific password
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
|
|||||||
+33
-16
@@ -3,39 +3,56 @@
|
|||||||
|
|
||||||
# Application
|
# Application
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3001
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
|
# Generate with: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||||
|
|
||||||
# URLs
|
# URLs (adjust for your domain)
|
||||||
ADMIN_URL=https://yourdomain.com
|
ADMIN_URL=https://photos.example.com
|
||||||
FRONTEND_URL=https://yourdomain.com
|
FRONTEND_URL=https://photos.example.com
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_CLIENT=pg
|
DATABASE_CLIENT=pg
|
||||||
DB_HOST=db
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=picpeak
|
DB_USER=picpeak
|
||||||
DB_PASSWORD=your-secure-database-password
|
DB_PASSWORD=your-secure-database-password-change-this
|
||||||
DB_NAME=picpeak
|
DB_NAME=picpeak
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration (Examples for common providers)
|
||||||
SMTP_HOST=smtp.example.com
|
# Gmail example:
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=your-email@gmail.com
|
||||||
|
# SMTP_PASS=your-app-specific-password
|
||||||
|
|
||||||
|
# SendGrid example:
|
||||||
|
SMTP_HOST=smtp.sendgrid.net
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=your-smtp-username
|
SMTP_USER=apikey
|
||||||
SMTP_PASS=your-smtp-password
|
SMTP_PASS=your-sendgrid-api-key
|
||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=noreply@example.com
|
||||||
|
|
||||||
# Storage Paths (Docker)
|
# Storage Paths
|
||||||
|
# Docker deployment:
|
||||||
STORAGE_PATH=/app/storage
|
STORAGE_PATH=/app/storage
|
||||||
EVENTS_PATH=/app/storage/events
|
EVENTS_PATH=/app/storage/events
|
||||||
ARCHIVE_PATH=/app/storage/events/archived
|
ARCHIVE_PATH=/app/storage/events/archived
|
||||||
|
|
||||||
# Analytics (Optional)
|
# Local development:
|
||||||
UMAMI_URL=https://analytics.yourdomain.com
|
# STORAGE_PATH=./storage
|
||||||
UMAMI_WEBSITE_ID=your-website-id
|
# EVENTS_PATH=./storage/events
|
||||||
|
# ARCHIVE_PATH=./storage/events/archived
|
||||||
|
|
||||||
|
# Analytics Backend Configuration (OPTIONAL)
|
||||||
|
# Used for server-side tracking only
|
||||||
|
# Primary configuration should be done through Admin UI > Settings > Analytics
|
||||||
|
# UMAMI_URL=https://analytics.example.com
|
||||||
|
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
+4
-4
@@ -40,10 +40,10 @@ const config = {
|
|||||||
keepAliveInitialDelayMillis: 0
|
keepAliveInitialDelayMillis: 0
|
||||||
},
|
},
|
||||||
pool: {
|
pool: {
|
||||||
min: 2,
|
min: 5,
|
||||||
max: 10,
|
max: 25,
|
||||||
acquireTimeoutMillis: 30000,
|
acquireTimeoutMillis: 60000,
|
||||||
createTimeoutMillis: 30000,
|
createTimeoutMillis: 60000,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
reapIntervalMillis: 1000,
|
reapIntervalMillis: 1000,
|
||||||
createRetryIntervalMillis: 200,
|
createRetryIntervalMillis: 200,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
|
|||||||
// Create database connection with built-in retry logic
|
// Create database connection with built-in retry logic
|
||||||
const db = knex(knexConfig);
|
const db = knex(knexConfig);
|
||||||
|
|
||||||
|
// Connection retry configuration
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY = 1000;
|
||||||
|
|
||||||
|
// Wrapper function to handle connection retries
|
||||||
|
async function withRetry(queryFn, retries = MAX_RETRIES) {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
return await queryFn();
|
||||||
|
} catch (error) {
|
||||||
|
const isConnectionError = error.message && (
|
||||||
|
error.message.includes('Connection terminated unexpectedly') ||
|
||||||
|
error.message.includes('Connection ended unexpectedly') ||
|
||||||
|
error.message.includes('ECONNREFUSED') ||
|
||||||
|
error.message.includes('ETIMEDOUT')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConnectionError && i < retries - 1) {
|
||||||
|
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeDatabase() {
|
async function initializeDatabase() {
|
||||||
// Events table
|
// Events table
|
||||||
const hasEventsTable = await db.schema.hasTable('events');
|
const hasEventsTable = await db.schema.hasTable('events');
|
||||||
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { db, initializeDatabase, logActivity };
|
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
@@ -11,13 +11,15 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const event = await db('events')
|
const event = await withRetry(async () => {
|
||||||
.where({
|
return await db('events')
|
||||||
id: decoded.eventId,
|
.where({
|
||||||
is_active: formatBoolean(true),
|
id: decoded.eventId,
|
||||||
is_archived: formatBoolean(false)
|
is_active: formatBoolean(true),
|
||||||
})
|
is_archived: formatBoolean(false)
|
||||||
.first();
|
})
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
|
|||||||
// Cache for session timeout setting
|
// Cache for session timeout setting
|
||||||
let cachedTimeout = null;
|
let cachedTimeout = null;
|
||||||
let cacheExpiry = 0;
|
let cacheExpiry = 0;
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
||||||
|
|
||||||
// Clean up expired sessions every 5 minutes
|
// Clean up expired sessions every 5 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -310,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
|||||||
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate totals for the period (matching /stats logic)
|
||||||
|
const totalViews = await db('access_logs')
|
||||||
|
.where('action', 'view')
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalDownloadsCount = await db('access_logs')
|
||||||
|
.whereIn('action', ['download', 'download_all'])
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalUniqueVisitors = await db('access_logs')
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.countDistinct('ip_address as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
chartData: dates,
|
chartData: dates,
|
||||||
topGalleries,
|
topGalleries,
|
||||||
devices
|
devices,
|
||||||
|
totals: {
|
||||||
|
views: totalViews?.count || 0,
|
||||||
|
downloads: totalDownloadsCount?.count || 0,
|
||||||
|
uniqueVisitors: totalUniqueVisitors?.count || 0
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Analytics error:', error);
|
console.error('Analytics error:', error);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get public settings (branding and theme)
|
// Get public settings (branding and theme)
|
||||||
@@ -7,12 +7,14 @@ router.get('/', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Fetch branding, theme, general, and security settings
|
// Fetch branding, theme, general, and security settings
|
||||||
// Note: We include analytics in the query but it might not exist yet
|
// Note: We include analytics in the query but it might not exist yet
|
||||||
const settings = await db('app_settings')
|
const settings = await withRetry(async () => {
|
||||||
.where(function() {
|
return await db('app_settings')
|
||||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
.where(function() {
|
||||||
.orWhere('setting_key', 'like', 'analytics_%');
|
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||||
})
|
.orWhere('setting_key', 'like', 'analytics_%');
|
||||||
.select('setting_key', 'setting_value');
|
})
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
});
|
||||||
|
|
||||||
// Convert to object format
|
// Convert to object format
|
||||||
const settingsObject = {};
|
const settingsObject = {};
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function processEmailQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start email queue processor
|
// Start email queue processor
|
||||||
setInterval(processEmailQueue, 60000); // Process every minute
|
// DISABLED: Using emailProcessor.js instead to prevent duplicate connections
|
||||||
|
// setInterval(processEmailQueue, 60000); // Process every minute
|
||||||
|
|
||||||
module.exports = { sendEmail, processEmailQueue };
|
module.exports = { sendEmail, processEmailQueue };
|
||||||
|
|||||||
@@ -113,6 +113,84 @@ function validatePassword(password, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get complexity settings from database
|
||||||
|
* @returns {Object} - Password complexity configuration
|
||||||
|
*/
|
||||||
|
async function getPasswordComplexitySettings() {
|
||||||
|
try {
|
||||||
|
const { db, withRetry } = require('../database/db');
|
||||||
|
|
||||||
|
// Use retry wrapper to handle connection failures
|
||||||
|
const settings = await withRetry(async () => {
|
||||||
|
return await db('app_settings')
|
||||||
|
.where('setting_key', 'security_password_complexity_level')
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings || !settings.setting_value) {
|
||||||
|
return 'moderate'; // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = typeof settings.setting_value === 'string'
|
||||||
|
? JSON.parse(settings.setting_value)
|
||||||
|
: settings.setting_value;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get password complexity settings:', error);
|
||||||
|
return 'moderate'; // Default on error - ensures app continues working
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get password configuration based on complexity level
|
||||||
|
* @param {string} complexityLevel - Complexity level (simple, moderate, strong, very_strong)
|
||||||
|
* @returns {Object} - Password configuration
|
||||||
|
*/
|
||||||
|
function getPasswordConfigForComplexity(complexityLevel) {
|
||||||
|
const configs = {
|
||||||
|
simple: {
|
||||||
|
minLength: 6,
|
||||||
|
requireUppercase: false,
|
||||||
|
requireLowercase: false,
|
||||||
|
requireNumbers: false,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 0
|
||||||
|
},
|
||||||
|
moderate: {
|
||||||
|
minLength: 8,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 2
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
minLength: 12,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 3
|
||||||
|
},
|
||||||
|
very_strong: {
|
||||||
|
minLength: 12,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: true,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 3
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[complexityLevel] || configs.moderate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate password for specific contexts (admin, gallery)
|
* Validate password for specific contexts (admin, gallery)
|
||||||
* @param {string} password - Password to validate
|
* @param {string} password - Password to validate
|
||||||
@@ -120,19 +198,16 @@ function validatePassword(password, options = {}) {
|
|||||||
* @param {Object} userData - Additional user data for context-aware validation
|
* @param {Object} userData - Additional user data for context-aware validation
|
||||||
* @returns {Object} - Validation result
|
* @returns {Object} - Validation result
|
||||||
*/
|
*/
|
||||||
function validatePasswordInContext(password, context, userData = {}) {
|
async function validatePasswordInContext(password, context, userData = {}) {
|
||||||
// For gallery context, use more lenient validation
|
// For gallery context, use dynamic complexity settings
|
||||||
if (context === 'gallery') {
|
if (context === 'gallery') {
|
||||||
// Gallery-specific validation options
|
// Get complexity settings from database
|
||||||
|
const complexityLevel = await getPasswordComplexitySettings();
|
||||||
|
|
||||||
|
// Get configuration for the complexity level
|
||||||
const galleryOptions = {
|
const galleryOptions = {
|
||||||
minLength: 6, // Reduced minimum length
|
...getPasswordConfigForComplexity(complexityLevel),
|
||||||
requireUppercase: false, // Don't require uppercase for galleries
|
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
|
||||||
requireLowercase: false, // Don't require lowercase for galleries
|
|
||||||
requireNumbers: false, // Numbers are optional
|
|
||||||
requireSpecialChars: false, // Special chars are optional
|
|
||||||
preventCommonPasswords: true, // Still prevent common passwords
|
|
||||||
minStrengthScore: 0, // Accept any score for galleries
|
|
||||||
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base validation with gallery-specific options
|
// Base validation with gallery-specific options
|
||||||
@@ -281,5 +356,7 @@ module.exports = {
|
|||||||
generateSecurePassword,
|
generateSecurePassword,
|
||||||
getBcryptRounds,
|
getBcryptRounds,
|
||||||
logPasswordValidationFailure,
|
logPasswordValidationFailure,
|
||||||
|
getPasswordComplexitySettings,
|
||||||
|
getPasswordConfigForComplexity,
|
||||||
PASSWORD_CONFIG
|
PASSWORD_CONFIG
|
||||||
};
|
};
|
||||||
+13
-8
@@ -1,11 +1,16 @@
|
|||||||
# Backend API URL
|
# Backend API URL
|
||||||
VITE_API_URL=http://localhost:3000
|
# For local development:
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
# Umami Analytics Configuration
|
# For production behind reverse proxy (Traefik, nginx, etc):
|
||||||
# Get these values from your Umami installation
|
# VITE_API_URL=/api
|
||||||
VITE_UMAMI_URL=https://analytics.yourdomain.com
|
|
||||||
VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
|
|
||||||
|
|
||||||
# Optional: Umami share URL for embedding full dashboard
|
# Umami Analytics Configuration (OPTIONAL - Fallback only)
|
||||||
# This is the public share URL from Umami's share feature
|
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
|
||||||
VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
|
# These environment variables serve as fallbacks when backend settings are not available
|
||||||
|
# Useful for: development environments, initial setup, or when backend is unavailable
|
||||||
|
#
|
||||||
|
# Example values:
|
||||||
|
# VITE_UMAMI_URL=https://analytics.example.com
|
||||||
|
# VITE_UMAMI_WEBSITE_ID=abc123def-4567-89ab-cdef-0123456789ab
|
||||||
|
# VITE_UMAMI_SHARE_URL=https://analytics.example.com/share/xyz789/wedding-photos
|
||||||
@@ -8,7 +8,11 @@ VITE_API_URL=/api
|
|||||||
# For development or if frontend/backend are on different domains:
|
# For development or if frontend/backend are on different domains:
|
||||||
# VITE_API_URL=https://api.yourdomain.com
|
# VITE_API_URL=https://api.yourdomain.com
|
||||||
|
|
||||||
# Umami Analytics Configuration (optional)
|
# Umami Analytics Configuration (OPTIONAL - Fallback only)
|
||||||
# VITE_UMAMI_URL=https://analytics.yourdomain.com
|
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
|
||||||
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
|
# These environment variables serve as fallbacks when backend settings are not available
|
||||||
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
|
#
|
||||||
|
# Real-world example values:
|
||||||
|
# VITE_UMAMI_URL=https://analytics.picpeak.com
|
||||||
|
# VITE_UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
|
||||||
|
# VITE_UMAMI_SHARE_URL=https://analytics.picpeak.com/share/Ab3Cd5Fg/picpeak-gallery
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.68",
|
"version": "1.0.74",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
// SVG Flag Components
|
||||||
|
const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||||
|
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
|
||||||
|
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l240-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
|
||||||
|
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
|
||||||
|
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||||
|
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#000" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#D00" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#FFCE00" d="M0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LanguageSelector: React.FC = () => {
|
export const LanguageSelector: React.FC = () => {
|
||||||
@@ -25,7 +44,7 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<span>{currentLanguage.flag}</span>
|
<currentLanguage.Flag className="w-5 h-5" />
|
||||||
<span>{currentLanguage.name}</span>
|
<span>{currentLanguage.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
: 'text-neutral-700'
|
: 'text-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg">{language.flag}</span>
|
<language.Flag className="w-5 h-5" />
|
||||||
<span>{language.name}</span>
|
<span>{language.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ api.interceptors.response.use(
|
|||||||
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
||||||
}
|
}
|
||||||
// Don't redirect - let the component handle the auth state
|
// Don't redirect - let the component handle the auth state
|
||||||
} else {
|
} else if (galleryMatch) {
|
||||||
// We're not on a gallery page but got a 401 from a gallery API
|
// We're not on a gallery page but got a 401 from a gallery API
|
||||||
// This shouldn't happen in normal flow, but if it does, redirect to homepage
|
// This shouldn't happen in normal flow, but if it does, redirect to homepage
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { settingsService } from '../services/settings.service';
|
||||||
|
|
||||||
export const useLocalizedDate = () => {
|
export const useLocalizedDate = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
// Fetch admin settings to get the date format
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['admin-settings-general'],
|
||||||
|
queryFn: () => settingsService.getSettingsByType('general'),
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
const getLocale = () => {
|
const getLocale = () => {
|
||||||
return i18n.language === 'de' ? de : enUS;
|
return i18n.language === 'de' ? de : enUS;
|
||||||
};
|
};
|
||||||
|
|
||||||
const format = (date: Date | string, formatStr: string) => {
|
const format = (date: Date | string, formatStr?: string) => {
|
||||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
|
// Use admin-configured date format if available and no format string provided
|
||||||
|
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
|
||||||
|
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
||||||
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
|
|||||||
return {
|
return {
|
||||||
format,
|
format,
|
||||||
formatDistanceToNow,
|
formatDistanceToNow,
|
||||||
locale: getLocale()
|
locale: getLocale(),
|
||||||
|
dateFormat: settings?.general_date_format || 'PPP'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -428,6 +428,12 @@
|
|||||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||||
"minPasswordLength": "Minimale Passwortlänge",
|
"minPasswordLength": "Minimale Passwortlänge",
|
||||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||||
|
"passwordComplexity": "Passwort-Komplexität",
|
||||||
|
"passwordComplexityHelp": "Sicherheitsstufe für Galerie-Passwörter",
|
||||||
|
"complexitySimple": "Einfach (6+ Zeichen, beliebiger Text)",
|
||||||
|
"complexityModerate": "Moderat (8+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||||
|
"complexityStrong": "Stark (12+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||||
|
"complexityVeryStrong": "Sehr stark (12+ Zeichen, alle Zeichentypen)",
|
||||||
"sessionAuth": "Sitzung & Authentifizierung",
|
"sessionAuth": "Sitzung & Authentifizierung",
|
||||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||||
@@ -721,6 +727,12 @@
|
|||||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||||
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
|
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
|
||||||
"favicon_uploaded": "Favicon hochgeladen",
|
"favicon_uploaded": "Favicon hochgeladen",
|
||||||
|
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert",
|
||||||
|
"cms_page_updated": "CMS-Seite aktualisiert: {{page}}",
|
||||||
|
"security_settings_updated": "Sicherheitseinstellungen aktualisiert",
|
||||||
|
"password_reset": "Passwort zurückgesetzt für: {{eventName}}",
|
||||||
|
"admin_logout": "Admin {{actorName}} abgemeldet",
|
||||||
|
"system_activity": "Systemaktivität: {{type}}",
|
||||||
"unknown": "Unbekannte Aktivität"
|
"unknown": "Unbekannte Aktivität"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -447,6 +447,12 @@
|
|||||||
"requirePassword": "Require password for all galleries",
|
"requirePassword": "Require password for all galleries",
|
||||||
"minPasswordLength": "Minimum Password Length",
|
"minPasswordLength": "Minimum Password Length",
|
||||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||||
|
"passwordComplexity": "Password Complexity",
|
||||||
|
"passwordComplexityHelp": "Security level required for gallery passwords",
|
||||||
|
"complexitySimple": "Simple (6+ chars, any text)",
|
||||||
|
"complexityModerate": "Moderate (8+ chars, mixed case/numbers)",
|
||||||
|
"complexityStrong": "Strong (12+ chars, uppercase/lowercase/numbers)",
|
||||||
|
"complexityVeryStrong": "Very Strong (12+ chars, all character types)",
|
||||||
"sessionAuth": "Session & Authentication",
|
"sessionAuth": "Session & Authentication",
|
||||||
"sessionTimeout": "Session Timeout (minutes)",
|
"sessionTimeout": "Session Timeout (minutes)",
|
||||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||||
@@ -796,6 +802,12 @@
|
|||||||
"category_deleted": "Category deleted: {{categoryName}}",
|
"category_deleted": "Category deleted: {{categoryName}}",
|
||||||
"general_settings_updated": "General settings updated",
|
"general_settings_updated": "General settings updated",
|
||||||
"favicon_uploaded": "Favicon uploaded",
|
"favicon_uploaded": "Favicon uploaded",
|
||||||
|
"analytics_settings_updated": "Analytics settings updated",
|
||||||
|
"cms_page_updated": "CMS page updated: {{page}}",
|
||||||
|
"security_settings_updated": "Security settings updated",
|
||||||
|
"password_reset": "Password reset for: {{eventName}}",
|
||||||
|
"admin_logout": "Admin {{actorName}} logged out",
|
||||||
|
"system_activity": "System activity: {{type}}",
|
||||||
"unknown": "Unknown activity"
|
"unknown": "Unknown activity"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { Calendar, AlertCircle, Clock } from 'lucide-react';
|
import { AlertCircle, Clock } from 'lucide-react';
|
||||||
import { differenceInDays, parseISO } from 'date-fns';
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||||
@@ -289,13 +289,9 @@ export const GalleryPage: React.FC = () => {
|
|||||||
alt={settingsData?.branding_company_name || 'PicPeak'}
|
alt={settingsData?.branding_company_name || 'PicPeak'}
|
||||||
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2 px-2" style={{ color: 'var(--color-primary, #5C8762)' }}>
|
||||||
{galleryInfo?.event_name}
|
{galleryInfo?.event_name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
|
||||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
|
||||||
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expiration Warning */}
|
{/* Expiration Warning */}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Button, Card, Loading } from '../../components/common';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { adminService } from '../../services/admin.service';
|
import { adminService } from '../../services/admin.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
// Map API response to component format
|
// Map API response to component format
|
||||||
interface ComponentAnalyticsData {
|
interface ComponentAnalyticsData {
|
||||||
@@ -53,7 +54,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
||||||
|
|
||||||
// Check if Umami is configured from settings or environment
|
// Check if Umami is configured from settings or environment
|
||||||
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({});
|
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string; enabled?: boolean }>({});
|
||||||
|
|
||||||
// Fetch analytics data from backend
|
// Fetch analytics data from backend
|
||||||
const { data: apiData, isLoading, refetch } = useQuery({
|
const { data: apiData, isLoading, refetch } = useQuery({
|
||||||
@@ -71,32 +72,57 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
queryFn: () => adminService.getDashboardStats(),
|
queryFn: () => adminService.getDashboardStats(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch public settings to get Umami config
|
// Fetch Umami config from admin settings since we're in admin panel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUmamiConfig = async () => {
|
const fetchUmamiConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
|
// Use admin API endpoint with auth token since we're in admin area
|
||||||
const settings = await response.json();
|
const response = await api.get('/admin/settings');
|
||||||
|
const settings = response.data;
|
||||||
|
|
||||||
if (settings.umami_enabled) {
|
// Transform the settings array to object
|
||||||
|
const settingsMap = settings.reduce((acc: any, setting: any) => {
|
||||||
|
acc[setting.key] = setting.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Check if Umami is enabled in admin settings
|
||||||
|
if (settingsMap.analytics_umami_enabled && settingsMap.analytics_umami_url && settingsMap.analytics_umami_website_id) {
|
||||||
setUmamiConfig({
|
setUmamiConfig({
|
||||||
url: settings.umami_url,
|
url: settingsMap.analytics_umami_url,
|
||||||
shareUrl: settings.umami_share_url
|
shareUrl: settingsMap.analytics_umami_share_url,
|
||||||
|
enabled: true
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fall back to environment variables
|
// Fall back to environment variables if they exist
|
||||||
setUmamiConfig({
|
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
url: import.meta.env.VITE_UMAMI_URL,
|
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
|
||||||
});
|
if (envUrl && envWebsiteId) {
|
||||||
|
setUmamiConfig({
|
||||||
|
url: envUrl,
|
||||||
|
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUmamiConfig({ enabled: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch Umami config:', error);
|
console.error('Failed to fetch Umami config:', error);
|
||||||
// Fall back to environment variables
|
// Fall back to environment variables if they exist
|
||||||
setUmamiConfig({
|
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
url: import.meta.env.VITE_UMAMI_URL,
|
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
|
||||||
});
|
if (envUrl && envWebsiteId) {
|
||||||
|
setUmamiConfig({
|
||||||
|
url: envUrl,
|
||||||
|
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUmamiConfig({ enabled: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,7 +465,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Notice */}
|
{/* Configuration Notice */}
|
||||||
{!umamiConfig.url && (
|
{umamiConfig.enabled === false && (
|
||||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
||||||
|
|||||||
@@ -153,13 +153,13 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
|
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||||
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
|
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -235,7 +235,7 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇬🇧 English
|
English
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLang('de')}
|
onClick={() => setEditingLang('de')}
|
||||||
@@ -245,7 +245,7 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇩🇪 Deutsch
|
Deutsch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format, addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { enUS, de } from 'date-fns/locale';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { Button, Input, Card } from '../../components/common';
|
import { Button, Input, Card } from '../../components/common';
|
||||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -57,6 +57,7 @@ const EVENT_TYPES = [
|
|||||||
export const CreateEventPageEnhanced: React.FC = () => {
|
export const CreateEventPageEnhanced: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { format } = useLocalizedDate();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||||
// const [showPreview, setShowPreview] = useState(false);
|
// const [showPreview, setShowPreview] = useState(false);
|
||||||
@@ -499,7 +500,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{formData.event_date && (
|
{formData.event_date && (
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
|
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [securitySettings, setSecuritySettings] = useState({
|
const [securitySettings, setSecuritySettings] = useState({
|
||||||
require_password: true,
|
require_password: true,
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
session_timeout_minutes: 60,
|
session_timeout_minutes: 60,
|
||||||
max_login_attempts: 5,
|
max_login_attempts: 5,
|
||||||
@@ -665,6 +666,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
max="32"
|
max="32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.passwordComplexity')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={securitySettings.password_complexity}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="simple">{t('settings.security.complexitySimple')}</option>
|
||||||
|
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
||||||
|
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
||||||
|
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.passwordComplexityHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user