Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f1c31369 | |||
| a1e9fb6ffc | |||
| 665ce5a6e7 | |||
| 8c41dd626d | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d | |||
| e0865b81b6 | |||
| d4404e39bd | |||
| 8611206396 | |||
| 39d2244e1e | |||
| eb626be22c | |||
| aaaf59817b | |||
| 2a4d38813f | |||
| ad9c6d63d3 | |||
| 8c77b30de6 | |||
| e51347d0a1 | |||
| 71e7179145 | |||
| bda76ff513 | |||
| 097ce2c205 | |||
| 1d8be3d840 | |||
| aebb8e66cb | |||
| ed2a278da2 | |||
| db2f5da66a | |||
| 19f8facc49 | |||
| b03760ab01 | |||
| 526dcd8dfc | |||
| 5b2561b6f1 | |||
| 3a6d06192a | |||
| 4b64b80b20 | |||
| ff89f96e31 | |||
| 465f997752 | |||
| 6948aaa92a | |||
| 4c7b49a5f6 | |||
| 6368f1027f | |||
| d64e7d08de | |||
| eb3751cb52 | |||
| 9fda54bd06 | |||
| 0d77a3a0a8 | |||
| 0618b78725 | |||
| 0178e71c67 | |||
| aa9b3a0227 | |||
| 410a33fecf | |||
| 05ebaaeedb | |||
| 84d0f63d36 | |||
| 6a4b549d9f | |||
| f3604b438b | |||
| 531831e84b | |||
| 90bb21e38b | |||
| 2f1a137342 | |||
| adf576fbe1 | |||
| 4264026bbe | |||
| 24b4a314a9 | |||
| ba825823a0 | |||
| fb16b7bbb8 | |||
| 8404125ff0 | |||
| 61ad2d61c1 | |||
| 9fd6b44487 | |||
| 9fe10bcce2 | |||
| f2abb40987 | |||
| 3697344cd0 | |||
| 4aa0ff705f | |||
| dc482e614a | |||
| 448882cfef | |||
| 7f9cb33a40 | |||
| 798f6211e0 | |||
| b992b151d3 | |||
| 87b8414e44 | |||
| ee13556c5c | |||
| afeb35a446 | |||
| ab324f1928 | |||
| 78ab0ad2e9 | |||
| 49c77785e7 | |||
| 1d826accdc | |||
| ceefe4f5a7 | |||
| e9171c7115 | |||
| 758c085467 | |||
| ecbc48815d | |||
| e91b138154 | |||
| dad1787aad | |||
| 909e760447 | |||
| 41857ec499 | |||
| f7a8765f58 | |||
| 214f120f7a | |||
| f26becad1d | |||
| 67ff415840 | |||
| 88659f1fa6 | |||
| c1e10f14a3 | |||
| 0881a0fa71 | |||
| e91209f7cb | |||
| 828d6bc456 | |||
| f945573f09 | |||
| 296430e4d7 | |||
| 7b517fa290 | |||
| 2c9a56f217 | |||
| 986b101040 | |||
| 0c283717cb | |||
| 4029559954 |
@@ -34,9 +34,15 @@ SMTP_PASS=your-app-specific-password
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
|
||||
# Application URLs
|
||||
# Use full origin with scheme, no trailing slash.
|
||||
# Admin UI is served by the frontend at /admin.
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com:3001
|
||||
VITE_API_URL=https://yourdomain.com:3001/api
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Port Configuration (optional)
|
||||
# BACKEND_PORT=3001
|
||||
@@ -47,7 +53,25 @@ VITE_API_URL=https://yourdomain.com:3001/api
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
# Runtime user mapping for Docker (optional)
|
||||
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
|
||||
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
|
||||
PUID=1001
|
||||
PGID=1001
|
||||
|
||||
# Analytics (Optional - Umami)
|
||||
VITE_UMAMI_URL=
|
||||
VITE_UMAMI_WEBSITE_ID=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
VITE_UMAMI_SHARE_URL=
|
||||
|
||||
# Storage variables (host paths)
|
||||
# These control where data is stored on the host. Defaults are local folders.
|
||||
APP_STORAGE=./storage
|
||||
APP_DATA=./data
|
||||
LOGS=./logs
|
||||
|
||||
# Note on FRONTEND_API_URL (documentation only):
|
||||
# When using pre-built frontend images, runtime env vars cannot override the built JS.
|
||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||
# should you change VITE_API_URL at build time.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch: # Allow manual triggering only
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
@@ -33,7 +30,7 @@ jobs:
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/ || 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
|
||||
@@ -131,4 +128,5 @@ jobs:
|
||||
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"
|
||||
echo "🔒 Sensitive files have been removed from the mirror"
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
name: Version and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitea/**'
|
||||
- '.drone.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
@@ -72,8 +67,8 @@ jobs:
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
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"
|
||||
@@ -133,10 +128,17 @@ jobs:
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
# 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
|
||||
@@ -264,4 +266,4 @@ jobs:
|
||||
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
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Docker Build and Push Workflow
|
||||
|
||||
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
|
||||
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
|
||||
- 🏷️ **Smart tagging** based on branches, versions, and commits
|
||||
- 🔒 **Security scanning** with Trivy vulnerability scanner
|
||||
- 💾 **Build caching** for faster subsequent builds
|
||||
- 📊 **Build summaries** in GitHub Actions UI
|
||||
|
||||
## Authentication
|
||||
|
||||
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
|
||||
|
||||
### Required Permissions
|
||||
|
||||
The workflow automatically sets the necessary permissions:
|
||||
- `contents: read` - To checkout the repository
|
||||
- `packages: write` - To push images to ghcr.io
|
||||
- `security-events: write` - To upload security scan results
|
||||
|
||||
## Image Tags
|
||||
|
||||
Images are automatically tagged based on the trigger event:
|
||||
|
||||
| Event | Tags Generated |
|
||||
|-------|---------------|
|
||||
| Push to main | `latest`, `main`, `main-<short-sha>` |
|
||||
| Push to develop | `develop`, `develop-<short-sha>` |
|
||||
| Pull Request | `pr-<number>` |
|
||||
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
|
||||
| Manual trigger | Based on branch + optional push |
|
||||
|
||||
## Usage
|
||||
|
||||
### Pull Images
|
||||
|
||||
Once published, images can be pulled using:
|
||||
|
||||
```bash
|
||||
# Pull backend image
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
|
||||
# Pull frontend image
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Pull specific version
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
|
||||
# Pull for specific architecture
|
||||
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
|
||||
```
|
||||
|
||||
### Using in Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
```
|
||||
|
||||
### Using in Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: picpeak-backend
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
## Manual Workflow Trigger
|
||||
|
||||
You can manually trigger the workflow from the Actions tab:
|
||||
|
||||
1. Go to Actions → "Build and Push Docker Images"
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and whether to push images
|
||||
4. Click "Run workflow"
|
||||
|
||||
## Security Scanning
|
||||
|
||||
The workflow includes Trivy vulnerability scanning that:
|
||||
- Scans for CRITICAL and HIGH severity vulnerabilities
|
||||
- Uploads results to GitHub Security tab
|
||||
- Available under Security → Code scanning alerts
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The workflow uses several optimization techniques:
|
||||
|
||||
1. **GitHub Actions Cache**: Speeds up builds by caching layers
|
||||
2. **Multi-stage builds**: Reduces final image size
|
||||
3. **Parallel builds**: Backend and frontend build simultaneously
|
||||
4. **Smart rebuilds**: Only rebuilds changed components
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
If you encounter permission errors when pushing images:
|
||||
|
||||
1. **First-time setup**: The first push creates a private package. You may need to:
|
||||
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
|
||||
- Link the package to your repository
|
||||
- Set package visibility (public/private)
|
||||
|
||||
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
|
||||
|
||||
### Build Failures
|
||||
|
||||
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
|
||||
- Missing dependencies in package.json
|
||||
- Dockerfile syntax errors
|
||||
- Network issues during package installation
|
||||
|
||||
### Image Not Found
|
||||
|
||||
If images aren't visible after successful push:
|
||||
- Check package visibility settings
|
||||
- Ensure you're authenticated to pull private images:
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
### View Packages
|
||||
|
||||
Your Docker images are available at:
|
||||
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
|
||||
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
|
||||
|
||||
### Delete Old Versions
|
||||
|
||||
To save storage, you can delete old versions:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage versions"
|
||||
3. Select versions to delete
|
||||
4. Click "Delete selected versions"
|
||||
|
||||
### Set Retention Policy
|
||||
|
||||
Configure automatic cleanup in package settings:
|
||||
1. Go to package settings
|
||||
2. Click on "Manage Actions access"
|
||||
3. Set retention days for untagged versions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic versioning** for releases (e.g., v1.2.3)
|
||||
2. **Test images locally** before pushing to production
|
||||
3. **Monitor security alerts** from Trivy scans
|
||||
4. **Clean up old images** regularly to save storage
|
||||
5. **Use specific tags** in production (avoid `latest`)
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Registry
|
||||
|
||||
To use a different registry, update the workflow:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: docker.io # or your custom registry
|
||||
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
|
||||
```
|
||||
|
||||
### Additional Platforms
|
||||
|
||||
To build for more platforms:
|
||||
|
||||
```yaml
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
```
|
||||
|
||||
### Custom Build Arguments
|
||||
|
||||
Add build arguments in the workflow:
|
||||
|
||||
```yaml
|
||||
build-args: |
|
||||
NODE_VERSION=20
|
||||
API_URL=${{ secrets.API_URL }}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
||||
- [Docker Build Action](https://github.com/docker/build-push-action)
|
||||
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
|
||||
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
|
||||
@@ -0,0 +1,229 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push images to registry'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
|
||||
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
|
||||
|
||||
jobs:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-backend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||
id: login-ghcr
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
org.opencontainers.image.vendor=PicPeak
|
||||
maintainer=${{ github.repository_owner }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
# Always build; only push when registry login succeeded
|
||||
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
build-args: |
|
||||
CACHEBUST=${{ github.run_number }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-frontend.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
timeout: '10m'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
|
||||
# automatically creates multi-arch manifests when building for multiple platforms.
|
||||
# The images are already properly tagged and include all architectures.
|
||||
|
||||
summary:
|
||||
needs: [build-backend, build-frontend]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||||
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||||
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -72,3 +72,12 @@ logs/
|
||||
storage/
|
||||
data/
|
||||
certbot/
|
||||
|
||||
# Ignore local contributor guide copy
|
||||
AGENTS.md
|
||||
|
||||
# Local artifacts from browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
|
||||
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 181 KiB |
@@ -4,27 +4,62 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
|
||||
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
|
||||
|
||||
For the easiest installation without Docker or complex configurations, use our **simple setup script**:
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/simple-setup.sh -o setup.sh && \
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including OS detection, dependencies, database setup, and service configuration. Perfect for:
|
||||
This automated script handles everything including:
|
||||
- Choice between Docker or Native installation
|
||||
- OS detection and dependency installation
|
||||
- Database setup and service configuration
|
||||
- SSL/HTTPS setup (optional)
|
||||
|
||||
Perfect for:
|
||||
- Small to medium deployments
|
||||
- Local or VPS installations
|
||||
- Users who prefer avoiding Docker complexity
|
||||
- Users new to server management
|
||||
- Quick testing and evaluation
|
||||
|
||||
👉 **See [SIMPLE_SETUP_GUIDE.md](./SIMPLE_SETUP_GUIDE.md) for detailed instructions.**
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Compose Deployment
|
||||
|
||||
This section covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
|
||||
### Option 1: Using Pre-built Images (Recommended)
|
||||
|
||||
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Use pre-built images deployment
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The production compose file uses:
|
||||
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
|
||||
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
|
||||
|
||||
Available tags:
|
||||
- `latest` - Latest stable release
|
||||
- `main` - Latest main branch build
|
||||
- `develop` - Development branch (may be unstable)
|
||||
- `v1.0.0` - Specific version tags
|
||||
|
||||
### Option 2: Building from Source
|
||||
|
||||
If you need to customize the application or the pre-built images aren't available, you can build locally:
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -36,6 +71,7 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [External Media Library](#external-media-library)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -46,6 +82,75 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Method 1: Using Pre-built Images (Fastest)
|
||||
|
||||
1. **Clone the repository for configs**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy using pre-built images**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
## External Media Library
|
||||
|
||||
PicPeak can reference an existing, read‑only media library mounted into the backend container. This avoids copying originals into PicPeak storage.
|
||||
|
||||
- Map your host library path to the container as read‑only in `docker-compose.production.yml`:
|
||||
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
|
||||
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
- In `.env`, set:
|
||||
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
|
||||
- `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
|
||||
Usage:
|
||||
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
|
||||
|
||||
Backups and Archives:
|
||||
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
|
||||
- Archiving reference events creates a manifest‑only ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
|
||||
|
||||
Local (npm) setup (no Docker):
|
||||
|
||||
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
|
||||
2. In `backend/.env` (or your shell), set:
|
||||
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
|
||||
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
|
||||
3. Start services from source:
|
||||
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
|
||||
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
|
||||
4. In Admin → Events:
|
||||
- Create an event, set “Source Mode” to “Reference (external folder)”.
|
||||
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
|
||||
- Click “Import from selected folder” to index files and generate thumbnails on demand.
|
||||
|
||||
Notes:
|
||||
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
|
||||
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
|
||||
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
|
||||
|
||||
### Method 2: Building from Source
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
@@ -64,8 +169,9 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy**
|
||||
4. **Build and deploy**
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -95,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
|
||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||
|
||||
### Backend Configuration (.env)
|
||||
Update `.env` with:
|
||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||
@@ -102,8 +217,28 @@ Update `.env` with:
|
||||
- `REDIS_PASSWORD` - Redis password
|
||||
- `SMTP_*` - Email configuration
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend URL (e.g., `http://localhost:3000` for Docker)
|
||||
- `ADMIN_URL` - Admin URL (e.g., `http://localhost:3000` for Docker)
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
```env
|
||||
DB_HOST=db.example.com
|
||||
DB_PORT=5432
|
||||
DB_USER=picpeak
|
||||
DB_PASSWORD=change_me
|
||||
DB_NAME=picpeak_prod
|
||||
```
|
||||
|
||||
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
|
||||
|
||||
### Frontend Configuration (frontend/.env)
|
||||
Create `frontend/.env` from `frontend/.env.example`:
|
||||
@@ -113,9 +248,10 @@ cp frontend/.env.example frontend/.env
|
||||
|
||||
Update `frontend/.env` with:
|
||||
- `VITE_API_URL` - Backend API URL
|
||||
- For Docker deployment: `http://localhost:3001/api`
|
||||
- For non-Docker local dev: `http://localhost:3001`
|
||||
- For production with reverse proxy: `/api`
|
||||
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
|
||||
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
|
||||
|
||||
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
|
||||
|
||||
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||
@@ -145,12 +281,29 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Build and Start Services
|
||||
### Using Pre-built Images (Fastest)
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker pull ghcr.io/the-luap/picpeak/backend:latest
|
||||
docker pull ghcr.io/the-luap/picpeak/frontend:latest
|
||||
|
||||
# Start services using production compose file
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# View running containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Building from Source (For Customization)
|
||||
|
||||
```bash
|
||||
# Build images locally
|
||||
docker compose build
|
||||
|
||||
# Or build with no cache for clean build
|
||||
docker compose build --no-cache
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
@@ -161,8 +314,8 @@ docker compose ps
|
||||
### Access Points
|
||||
|
||||
By default, services are exposed on:
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend/API: http://localhost:3001
|
||||
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
|
||||
- Backend/API: http://localhost:3001 (API only; no UI routes)
|
||||
- PostgreSQL: localhost:5432 (if needed)
|
||||
- Redis: localhost:6379 (if needed)
|
||||
|
||||
@@ -233,7 +386,11 @@ After deployment, you must complete the first login process which includes manda
|
||||
|
||||
### Step 2: Access Admin Panel
|
||||
|
||||
1. Navigate to your admin panel URL (e.g., `http://your-domain.com:3001/admin` or `https://your-domain.com/admin`)
|
||||
1. Navigate to your frontend domain and open the admin section:
|
||||
- `http://your-domain.com/admin` (behind reverse proxy)
|
||||
- `http://localhost:3000/admin` (Docker local)
|
||||
|
||||
The backend at `:3001` serves API only and does not serve the admin UI.
|
||||
2. Login using:
|
||||
- **Email**: `admin@example.com` (or your custom admin email)
|
||||
- **Password**: The auto-generated password from the logs
|
||||
@@ -263,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
@@ -307,7 +474,16 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
# 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 {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
@@ -315,21 +491,11 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Protected photos and uploads
|
||||
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;
|
||||
}
|
||||
|
||||
# Admin routes
|
||||
location /admin {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -446,14 +612,50 @@ The application includes a built-in backup service. Configure it in the admin pa
|
||||
|
||||
### Updates
|
||||
|
||||
#### Method 1: Using Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull latest changes (for configuration updates)
|
||||
git pull
|
||||
|
||||
# Pull latest images from GitHub Container Registry
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
|
||||
# Restart with new images
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
#### Method 2: Building from Source
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify services are healthy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version Updates
|
||||
|
||||
To use a specific version of the images:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.production.yml to specify version tags
|
||||
# Change: ghcr.io/the-luap/picpeak/backend:latest
|
||||
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
@@ -614,4 +816,4 @@ For issues and questions:
|
||||
- Error messages
|
||||
- Log output
|
||||
- Environment details (without secrets)
|
||||
- Steps to reproduce
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -28,11 +28,13 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
|
||||
### For Photographers
|
||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||
- 🔐 **Password Protection** - Secure client galleries
|
||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||
|
||||
### For Clients
|
||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||
@@ -45,6 +47,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
||||
### Technical Excellence
|
||||
- 🐳 **Docker Ready** - Deploy in minutes
|
||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||
- 📈 **Scalable** - From small studios to large agencies
|
||||
@@ -70,14 +73,35 @@ docker-compose up -d
|
||||
# Access at http://localhost:3005
|
||||
```
|
||||
|
||||
Note on Docker file permissions (PUID/PGID)
|
||||
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||
|
||||
## 🌐 Public Landing Page
|
||||
|
||||
Spotlight your studio with a customizable marketing page at `/`:
|
||||
|
||||
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||
- Use **Reset to default** anytime to restore the bundled template.
|
||||
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
Perfect for:
|
||||
@@ -192,11 +216,13 @@ These features are currently in beta testing and may have limited functionality
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|---------|
|
||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||
| **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 |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||
|
||||
@@ -233,4 +259,4 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
```
|
||||
@@ -166,8 +166,10 @@ sudo ./setup.sh --native --unattended \
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
|
||||
- **Native**: `http://your-server:3001/admin` (admin panel)
|
||||
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
|
||||
- Backend/API: `http://your-server:3001` (API only; no UI routes)
|
||||
|
||||
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
|
||||
|
||||
### With Domain & HTTPS
|
||||
If configured during setup:
|
||||
@@ -175,9 +177,23 @@ If configured during setup:
|
||||
- `https://your-domain.com/admin` - Admin panel
|
||||
|
||||
### Behind Existing Proxy
|
||||
Add to your Nginx/Apache configuration:
|
||||
Add to your Nginx/Apache configuration (split frontend vs backend):
|
||||
```nginx
|
||||
# Frontend (UI + /admin/*)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Backend API and protected resources
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -189,6 +205,14 @@ location / {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
location ~ ^/(photos|thumbnails|uploads) {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Managing Galleries
|
||||
@@ -268,6 +292,7 @@ sudo systemctl stop picpeak-backend picpeak-workers
|
||||
sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./setup.sh --update
|
||||
```
|
||||
|
||||
@@ -281,9 +306,9 @@ docker compose restart
|
||||
```
|
||||
|
||||
### Native Configuration
|
||||
Edit `/opt/picpeak/backend/.env`:
|
||||
Edit `/opt/picpeak/app/backend/.env`:
|
||||
```bash
|
||||
sudo nano /opt/picpeak/backend/.env
|
||||
sudo nano /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
@@ -345,7 +370,7 @@ tar -czf photos-backup.tar.gz storage/events/
|
||||
#### Native:
|
||||
```bash
|
||||
# Database backup
|
||||
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
|
||||
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
|
||||
|
||||
# Photos backup
|
||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||
@@ -422,7 +447,7 @@ ls -la ~/picpeak/storage/events/
|
||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||
|
||||
# Native
|
||||
cd /opt/picpeak/backend
|
||||
cd /opt/picpeak/app/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
@@ -434,11 +459,11 @@ sudo -u picpeak node scripts/reset-admin-password.js
|
||||
- Installation: `/tmp/picpeak-setup-*.log`
|
||||
|
||||
2. **Documentation:**
|
||||
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
|
||||
- [Full Documentation](https://github.com/the-luap/picpeak)
|
||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||
|
||||
3. **Support:**
|
||||
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
- Include: Error messages, system info (`uname -a`), installation method
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
@@ -474,7 +499,7 @@ services:
|
||||
### Native Optimization
|
||||
```bash
|
||||
# Increase Node.js memory
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
|
||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||
sudo systemctl restart picpeak-backend
|
||||
```
|
||||
|
||||
@@ -516,4 +541,4 @@ sudo ./setup.sh --native \
|
||||
|
||||
---
|
||||
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
|
||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,7 +23,7 @@ RUN npm ci --only=production
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin photos in reference mode', () => {
|
||||
let tmpDir;
|
||||
let storagePath;
|
||||
let db;
|
||||
let app;
|
||||
let categoryId;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||
storagePath = path.join(tmpDir, 'storage');
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||
try {
|
||||
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
process.env.STORAGE_PATH = storagePath;
|
||||
|
||||
resetModules();
|
||||
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||
ensureThumbnail: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||
validateUploadedFiles: (_req, _res, next) => next()
|
||||
}));
|
||||
|
||||
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||
return {
|
||||
...actual,
|
||||
validateFileType: () => true,
|
||||
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}));
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
db = dbModule.db;
|
||||
|
||||
await db.schema.dropTableIfExists('photo_feedback');
|
||||
await db.schema.dropTableIfExists('photos');
|
||||
await db.schema.dropTableIfExists('photo_categories');
|
||||
await db.schema.dropTableIfExists('events');
|
||||
|
||||
await db.schema.createTable('events', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.string('source_mode').notNullable();
|
||||
table.string('external_path');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.string('slug').notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id');
|
||||
});
|
||||
|
||||
await db.schema.createTable('photos', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').notNullable();
|
||||
table.string('filename').notNullable();
|
||||
table.string('path').notNullable();
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable();
|
||||
table.integer('size_bytes');
|
||||
table.integer('category_id');
|
||||
table.string('source_origin');
|
||||
table.string('external_relpath');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.float('average_rating').defaultTo(0);
|
||||
table.integer('like_count').defaultTo(0);
|
||||
table.integer('favorite_count').defaultTo(0);
|
||||
});
|
||||
|
||||
await db.schema.createTable('photo_feedback', (table) => {
|
||||
table.increments('id');
|
||||
table.integer('photo_id');
|
||||
table.string('feedback_type');
|
||||
table.boolean('is_approved');
|
||||
table.boolean('is_hidden');
|
||||
});
|
||||
|
||||
await db('events').insert({
|
||||
id: 1,
|
||||
slug: 'test-event',
|
||||
event_name: 'Test Event',
|
||||
source_mode: 'reference',
|
||||
external_path: 'external/library'
|
||||
});
|
||||
|
||||
const insertedCategory = await db('photo_categories').insert({
|
||||
name: 'Highlights',
|
||||
slug: 'highlights',
|
||||
is_global: true
|
||||
});
|
||||
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||
|
||||
const router = require('../../src/routes/adminPhotos');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
}
|
||||
resetModules();
|
||||
delete process.env.TEST_DATABASE_PATH;
|
||||
delete process.env.STORAGE_PATH;
|
||||
if (tmpDir) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stores managed uploads with category information and managed origin', async () => {
|
||||
const uploadResponse = await request(app)
|
||||
.post(`/api/admin/events/1/upload`)
|
||||
.field('category_id', String(categoryId))
|
||||
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('photos');
|
||||
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||
|
||||
const photo = await db('photos').first();
|
||||
expect(photo).toBeTruthy();
|
||||
expect(photo.category_id).toBe(categoryId);
|
||||
expect(photo.source_origin).toBe('managed');
|
||||
expect(photo.external_relpath).toBeNull();
|
||||
});
|
||||
|
||||
it('returns numeric category metadata when listing photos', async () => {
|
||||
await db('photos').insert({
|
||||
event_id: 1,
|
||||
filename: 'external.jpg',
|
||||
path: 'test-event/external.jpg',
|
||||
thumbnail_path: null,
|
||||
type: 'individual',
|
||||
size_bytes: 123,
|
||||
source_origin: 'external',
|
||||
external_relpath: 'individual/external.jpg'
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||
expect(managedPhoto).toBeTruthy();
|
||||
expect(managedPhoto.category_name).toBe('Highlights');
|
||||
|
||||
const filtered = await request(app)
|
||||
.get(`/api/admin/events/1/photos`)
|
||||
.query({ category_id: String(categoryId) })
|
||||
.expect(200);
|
||||
|
||||
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes category updates', async () => {
|
||||
const photo = await db('photos').first();
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||
.send({ category_id: '0' })
|
||||
.expect(200);
|
||||
|
||||
const updated = await db('photos').where({ id: photo.id }).first();
|
||||
expect(updated.category_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
|
||||
console.error('Failed to cleanup S3 objects:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||
const mockFs = require('mock-fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const path = require('path');
|
||||
const mockPath = path;
|
||||
|
||||
jest.mock('../../src/services/externalMediaService', () => ({
|
||||
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||
}));
|
||||
|
||||
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||
|
||||
describe('resolvePhotoFilePath', () => {
|
||||
const backendRoot = path.resolve(__dirname, '../../');
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (typeof originalStoragePath === 'string') {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
} else {
|
||||
delete process.env.STORAGE_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('delegates external photos to external media resolver', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||
});
|
||||
|
||||
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||
});
|
||||
|
||||
it('falls back to managed storage when external metadata is missing', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { path: 'fashion-show/new-upload.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).not.toHaveBeenCalled();
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
|
||||
});
|
||||
|
||||
it('throws when external photo is missing relative path data', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external' };
|
||||
|
||||
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
if (!process.env.SKIP_S3_TESTS) {
|
||||
process.env.SKIP_S3_TESTS = 'true';
|
||||
}
|
||||
if (!process.env.STORAGE_PATH) {
|
||||
process.env.STORAGE_PATH = '/storage';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,54 @@ require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
// Database configuration for different environments
|
||||
const resolveSqliteFilename = (filenameEnv) => {
|
||||
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||
|
||||
if (!filenameEnv) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = String(filenameEnv).trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
resolved = trimmed;
|
||||
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||
resolved = path.resolve(__dirname, trimmed);
|
||||
} else {
|
||||
resolved = path.join(__dirname, trimmed);
|
||||
}
|
||||
|
||||
const normalized = path.normalize(resolved);
|
||||
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||
|
||||
if (normalized.includes(duplicatePattern)) {
|
||||
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const sqliteConnection = (filenameEnv) => ({
|
||||
filename: resolveSqliteFilename(filenameEnv)
|
||||
});
|
||||
|
||||
const baseSqliteConfig = {
|
||||
client: 'sqlite3',
|
||||
connection: sqliteConnection(),
|
||||
useNullAsDefault: true,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
seeds: {
|
||||
directory: './seeds'
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
development: {
|
||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||
@@ -13,7 +61,7 @@ const config = {
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing'
|
||||
} : {
|
||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||
migrations: {
|
||||
@@ -24,36 +72,65 @@ const config = {
|
||||
}
|
||||
},
|
||||
|
||||
test: (() => {
|
||||
const client = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||
const isPostgres = client === 'pg';
|
||||
|
||||
return {
|
||||
...baseSqliteConfig,
|
||||
client,
|
||||
useNullAsDefault: !isPostgres,
|
||||
connection: isPostgres
|
||||
? {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'photo_sharing_test'
|
||||
}
|
||||
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
|
||||
};
|
||||
})(),
|
||||
|
||||
production: {
|
||||
client: process.env.DATABASE_CLIENT || 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
},
|
||||
pool: {
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
},
|
||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
user: process.env.DB_USER || 'picpeak',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'picpeak',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
// Connection stability settings
|
||||
connectionTimeoutMillis: 30000,
|
||||
idleTimeoutMillis: 30000,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 0
|
||||
}
|
||||
: {
|
||||
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||
},
|
||||
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||
? {
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
propagateCreateError: false
|
||||
}
|
||||
: undefined,
|
||||
migrations: {
|
||||
directory: './migrations'
|
||||
},
|
||||
acquireConnectionTimeout: 60000
|
||||
}
|
||||
};
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
|
||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||
module.exports = config[env] || config.development;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 041_add_logo_customization_settings');
|
||||
|
||||
// Add default logo customization settings
|
||||
const logoSettings = [
|
||||
{
|
||||
setting_key: 'branding_logo_size',
|
||||
setting_value: JSON.stringify('medium'),
|
||||
setting_type: 'branding',
|
||||
description: 'Logo size: small, medium, large, xlarge, or custom',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_max_height',
|
||||
setting_value: JSON.stringify(48),
|
||||
setting_type: 'branding',
|
||||
description: 'Maximum logo height in pixels (used when size is custom)',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_position',
|
||||
setting_value: JSON.stringify('left'),
|
||||
setting_type: 'branding',
|
||||
description: 'Logo position in header: left, center, right',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_header',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'branding',
|
||||
description: 'Show logo in gallery header',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_hero',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'branding',
|
||||
description: 'Show logo in hero section (for non-grid layouts)',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
},
|
||||
{
|
||||
setting_key: 'branding_logo_display_mode',
|
||||
setting_value: JSON.stringify('logo_and_text'),
|
||||
setting_type: 'branding',
|
||||
description: 'Display mode: logo_only, text_only, logo_and_text',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
// Insert settings that don't already exist
|
||||
for (const setting of logoSettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert(setting);
|
||||
console.log(`Added setting: ${setting.setting_key}`);
|
||||
} else {
|
||||
console.log(`Setting already exists: ${setting.setting_key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration 041_add_logo_customization_settings completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back migration: 041_add_logo_customization_settings');
|
||||
|
||||
// Remove the logo customization settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_size',
|
||||
'branding_logo_max_height',
|
||||
'branding_logo_position',
|
||||
'branding_logo_display_header',
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_display_mode'
|
||||
])
|
||||
.del();
|
||||
|
||||
console.log('Rollback of 041_add_logo_customization_settings completed');
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Migration 041: Add external media reference support
|
||||
* - events.source_mode: 'managed' | 'reference'
|
||||
* - events.external_path: relative path under external media root
|
||||
* - photos.source_origin: 'managed' | 'external'
|
||||
* - photos.external_relpath: relative path within event.external_path
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 041_add_external_media');
|
||||
|
||||
// events.source_mode (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => {
|
||||
table.string('source_mode').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// events.external_path (nullable)
|
||||
await addColumnIfNotExists(knex, 'events', 'external_path', (table) => {
|
||||
table.text('external_path');
|
||||
});
|
||||
|
||||
// photos.source_origin (default 'managed')
|
||||
await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => {
|
||||
table.string('source_origin').notNullable().defaultTo('managed');
|
||||
});
|
||||
|
||||
// photos.external_relpath (nullable)
|
||||
await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => {
|
||||
table.text('external_relpath');
|
||||
});
|
||||
|
||||
// Helpful index for queries
|
||||
try {
|
||||
if (knex.client.config.client === 'pg') {
|
||||
await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)");
|
||||
} else {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.index(['event_id', 'source_origin'], 'photos_event_source_idx');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Index creation skipped or failed (may already exist):', e.message);
|
||||
}
|
||||
|
||||
console.log('Migration 041_add_external_media completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 041_add_external_media');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const logger = require('../../src/utils/logger');
|
||||
|
||||
async function ensureColumn(knex, tableName, columnName, alterFn) {
|
||||
const exists = await knex.schema.hasColumn(tableName, columnName);
|
||||
if (!exists) {
|
||||
logger.info(`Adding column ${tableName}.${columnName}`);
|
||||
await knex.schema.table(tableName, alterFn);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await ensureColumn(knex, 'events', 'host_name', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
|
||||
table.integer('upload_category_id');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
|
||||
table.text('watermark_text');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
});
|
||||
|
||||
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
|
||||
table.string('uploaded_by').defaultTo('admin');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function() {
|
||||
// Non destructive migration; no rollback
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
} = require('../../src/constants/publicSiteDefaults');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const defaults = [
|
||||
{
|
||||
setting_key: 'general_public_site_enabled',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_html',
|
||||
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
|
||||
setting_type: 'general'
|
||||
},
|
||||
{
|
||||
setting_key: 'general_public_site_custom_css',
|
||||
setting_value: JSON.stringify(''),
|
||||
setting_type: 'general'
|
||||
}
|
||||
];
|
||||
|
||||
for (const setting of defaults) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
exports.up = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('require_password').notNullable().defaultTo(true);
|
||||
});
|
||||
await knex('events').update({ require_password: true });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('require_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -120,7 +120,9 @@ async function runMigrations() {
|
||||
|
||||
// Check if this is a new deployment
|
||||
// It's new if no essential tables exist OR no migrations have been applied
|
||||
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
|
||||
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
|
||||
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
|
||||
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
|
||||
|
||||
// Only detect existing schema for truly existing deployments
|
||||
if (!isNewDeployment) {
|
||||
@@ -227,4 +229,4 @@ if (require.main === module) {
|
||||
waitAndRun();
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
module.exports = { runMigrations };
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.103",
|
||||
"version": "1.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.103",
|
||||
"version": "1.1.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
@@ -34,9 +35,10 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -46,6 +48,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
@@ -3889,13 +3892,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -4727,6 +4730,28 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
@@ -4898,7 +4923,6 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4992,6 +5016,61 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -5107,6 +5186,18 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -5198,7 +5289,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5530,13 +5620,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"validator": "~13.12.0"
|
||||
"validator": "~13.15.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
@@ -6146,6 +6236,25 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -6540,6 +6649,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -7991,6 +8109,16 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mock-fs": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
|
||||
"integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8015,6 +8143,24 @@
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -8209,9 +8355,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -8520,6 +8666,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -8669,7 +8821,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -8764,6 +8915,34 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -8829,24 +9008,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -9355,6 +9516,20 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
@@ -9727,6 +9902,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||
@@ -10044,6 +10228,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -10422,9 +10624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||
"version": "13.15.20",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
|
||||
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.103",
|
||||
"version": "1.1.5",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -17,9 +17,10 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
@@ -38,9 +39,10 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -50,6 +52,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
async function pathExists(location) {
|
||||
try {
|
||||
await fsp.access(location);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFile(source, destination) {
|
||||
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await fsp.rename(source, destination);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
await fsp.copyFile(source, destination);
|
||||
await fsp.unlink(source);
|
||||
} else if (error.code === 'EEXIST') {
|
||||
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
const backendRoot = path.resolve(__dirname, '..');
|
||||
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||
|
||||
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = ['logos', 'favicons'];
|
||||
let migratedCounter = 0;
|
||||
|
||||
for (const category of categories) {
|
||||
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||
if (!fs.existsSync(legacyDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = path.join(targetUploadsRoot, category);
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(legacyDir, entry.name);
|
||||
const destinationPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (await pathExists(destinationPath)) {
|
||||
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await moveFile(sourcePath, destinationPath);
|
||||
migratedCounter += 1;
|
||||
}
|
||||
|
||||
const remaining = await fsp.readdir(legacyDir);
|
||||
if (remaining.length === 0) {
|
||||
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCounter === 0) {
|
||||
console.log('No legacy logo or favicon files needed migration.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||
}
|
||||
|
||||
migrate().catch((error) => {
|
||||
console.error('Migration failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag) => args.includes(flag);
|
||||
const getOption = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
if (index !== -1 && index + 1 < args.length) {
|
||||
return args[index + 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||
const credentialsFileArg = getOption('credentials-file');
|
||||
const resolvedCredentialsFile = credentialsFileArg
|
||||
? path.resolve(process.cwd(), credentialsFileArg)
|
||||
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
|
||||
const rl = force ? null : readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function question(prompt) {
|
||||
async function ask(prompt) {
|
||||
if (force) {
|
||||
return 'yes';
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
||||
|
||||
console.log('Found admin user:', admin.username);
|
||||
console.log('Email:', admin.email);
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
|
||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
if (!force) {
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (!force) {
|
||||
const normalized = confirm.trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
||||
});
|
||||
|
||||
// Save to file
|
||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||
await fs.mkdir(credentialsDir, { recursive: true });
|
||||
|
||||
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||
const resetInfo = `
|
||||
========================================
|
||||
PicPeak Admin Password Reset
|
||||
PicPeak Admin Credentials
|
||||
========================================
|
||||
|
||||
Password has been reset for admin account:
|
||||
Your admin account has been reset with these credentials:
|
||||
|
||||
Username: admin
|
||||
New Password: ${newPassword}
|
||||
Username: ${admin.username}
|
||||
Email: ${admin.email}
|
||||
Password: ${newPassword}
|
||||
|
||||
IMPORTANT:
|
||||
1. You MUST change this password on next login
|
||||
IMPORTANT SECURITY NOTES:
|
||||
1. You MUST change this password after first login
|
||||
2. This file contains sensitive information
|
||||
3. Delete this file after noting the password
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
Login URL: ${adminUrl}
|
||||
|
||||
Reset performed on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
|
||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
||||
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||
|
||||
console.log('\n✅ Password reset successful!\n');
|
||||
console.log('========================================');
|
||||
console.log('New Credentials:');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Username: ${admin.username}`);
|
||||
console.log(`Email: ${admin.email}`);
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. You will be required to change this password on next login');
|
||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
||||
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||
console.log('3. Delete the file after noting the password');
|
||||
console.log('========================================\n');
|
||||
|
||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
||||
console.error('❌ Error resetting password:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
if (rl) {
|
||||
rl.close();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the reset
|
||||
resetAdminPassword();
|
||||
resetAdminPassword();
|
||||
|
||||
@@ -36,4 +36,4 @@ async function setAdminPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
setAdminPassword();
|
||||
setAdminPassword();
|
||||
|
||||
@@ -12,6 +12,7 @@ logger.info('Server starting up', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
@@ -25,6 +26,12 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const {
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('./src/utils/tokenUtils');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth-enhanced');
|
||||
@@ -42,25 +49,79 @@ const PORT = process.env.PORT || 3000;
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
|
||||
// Security middleware with custom CSP
|
||||
// In native HTTP installs, do NOT force HTTPS for subresources.
|
||||
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
||||
const cspDirectives = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
'https://www.google.com',
|
||||
'https://www.gstatic.com'
|
||||
],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
|
||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||
objectSrc: ["'none'"], // Disable plugins
|
||||
mediaSrc: ["'self'"], // Audio/video
|
||||
frameSrc: ["'self'", 'https://www.google.com'],
|
||||
};
|
||||
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
||||
if (enableHsts) {
|
||||
// In helmet, an empty array enables the directive
|
||||
cspDirectives.upgradeInsecureRequests = [];
|
||||
}
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const path = req.path || '';
|
||||
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||
const adminToken = getAdminTokenFromRequest(req);
|
||||
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
|
||||
const isGalleryRequest = Boolean(slugMatch)
|
||||
|| path.startsWith('/api/gallery')
|
||||
|| path.startsWith('/gallery')
|
||||
|| path.startsWith('/api/secure-images');
|
||||
|
||||
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
|
||||
if (isAdminRequest) {
|
||||
if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
}
|
||||
} else if (isGalleryRequest) {
|
||||
if (galleryToken) {
|
||||
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||
} else if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
}
|
||||
} else if (adminToken) {
|
||||
req.headers.authorization = `Bearer ${adminToken}`;
|
||||
} else if (galleryToken) {
|
||||
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||
connectSrc: ["'self'"], // API connections
|
||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||
objectSrc: ["'none'"], // Disable plugins
|
||||
mediaSrc: ["'self'"], // Audio/video
|
||||
frameSrc: ["'none'"], // Disable iframes
|
||||
},
|
||||
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
||||
useDefaults: false,
|
||||
directives: cspDirectives,
|
||||
},
|
||||
hsts: {
|
||||
hsts: enableHsts ? {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
} : false,
|
||||
permittedCrossDomainPolicies: false,
|
||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
|
||||
}));
|
||||
@@ -72,14 +133,14 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS configuration
|
||||
// CORS configuration (apply only to API routes)
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
|
||||
|
||||
// In development, also allow localhost origins
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push(
|
||||
@@ -89,23 +150,166 @@ const corsOptions = {
|
||||
'http://localhost:3000' // Direct backend access
|
||||
);
|
||||
}
|
||||
|
||||
// Allow requests with no origin (like mobile apps or curl)
|
||||
|
||||
// Allow requests with no origin (like curl) and allow-listed origins
|
||||
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
// Do not error globally; just omit CORS headers on disallowed origins
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
// Only attach CORS to API endpoints, not static assets
|
||||
app.use('/api', cors(corsOptions));
|
||||
// Handle preflight explicitly for API paths
|
||||
app.options('/api/*', cors(corsOptions));
|
||||
|
||||
// Initialize rate limiters (they will be created dynamically)
|
||||
let generalRateLimiter;
|
||||
let authRateLimiter;
|
||||
|
||||
function composeInlineStyles(payload) {
|
||||
const { branding } = payload;
|
||||
const cssSegments = [];
|
||||
|
||||
cssSegments.push(`:root {
|
||||
--brand-primary: ${branding.colors.primary};
|
||||
--brand-accent: ${branding.colors.accent};
|
||||
--brand-background: ${branding.colors.background};
|
||||
--brand-text: ${branding.colors.text};
|
||||
}`);
|
||||
|
||||
if (payload.baseCss) {
|
||||
cssSegments.push(payload.baseCss);
|
||||
}
|
||||
|
||||
if (payload.css) {
|
||||
cssSegments.push(`/* Custom styles */\n${payload.css}`);
|
||||
}
|
||||
|
||||
return cssSegments.join('\n\n');
|
||||
}
|
||||
|
||||
function renderBrandHeader(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||
|
||||
const tagline = branding.companyTagline
|
||||
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||
: '';
|
||||
|
||||
return `<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#features">${'Features'}</a>
|
||||
<a href="#workflow">${'Workflow'}</a>
|
||||
<a href="#collections">${'Collections'}</a>
|
||||
<a href="#stories">${'Stories'}</a>
|
||||
<a href="#contact">${'Contact'}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderBrandFooter(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const footerNote = branding.footerText
|
||||
? `<p>${branding.footerText}</p>`
|
||||
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||
|
||||
const supportLink = branding.supportEmail
|
||||
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||
: '';
|
||||
|
||||
const legalLinks = `
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
${supportLink}
|
||||
`;
|
||||
|
||||
return `<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
${legalLinks}
|
||||
</div>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function buildPublicSiteDocument(payload) {
|
||||
const inlineStyles = composeInlineStyles(payload);
|
||||
const header = renderBrandHeader(payload.branding);
|
||||
const footer = renderBrandFooter(payload.branding);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
${header}
|
||||
<main class="site-main">
|
||||
${payload.html}
|
||||
</main>
|
||||
${footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function handlePublicSiteRequest(req, res, next) {
|
||||
try {
|
||||
const payload = await getPublicSitePayload();
|
||||
|
||||
if (!payload.enabled) {
|
||||
res.redirect(302, '/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildPublicSiteDocument(payload);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
|
||||
res.setHeader('ETag', payload.etag);
|
||||
res.setHeader('Vary', 'Accept-Encoding');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
|
||||
|
||||
res.status(200).send(document);
|
||||
} catch (error) {
|
||||
logger.error('Failed to render public site', { error: error.message });
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to initialize rate limiters
|
||||
async function initializeRateLimiters() {
|
||||
generalRateLimiter = await createRateLimiter();
|
||||
@@ -124,6 +328,22 @@ async function initializeRateLimiters() {
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
try {
|
||||
const started = Date.now();
|
||||
const ts = new Date().toISOString();
|
||||
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - started;
|
||||
const tsDone = new Date().toISOString();
|
||||
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||
});
|
||||
} catch (_) {}
|
||||
next();
|
||||
};
|
||||
app.use('/api', apiRequestLogger);
|
||||
|
||||
// Maintenance mode middleware - add after body parsing but before routes
|
||||
app.use(maintenanceMiddleware);
|
||||
|
||||
@@ -143,6 +363,7 @@ const secureStatic = require('./src/middleware/secureStatic');
|
||||
|
||||
// Get storage path from environment or use default
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||
|
||||
// Static file serving for photos (protected)
|
||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||
@@ -199,7 +420,8 @@ app.get('/health', async (req, res) => {
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
@@ -217,6 +439,37 @@ app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||
app.use('/api/secure-images', secureImagesRoutes);
|
||||
|
||||
// Optional: Serve built frontend (native installs)
|
||||
try {
|
||||
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
|
||||
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
|
||||
const indexPath = path.join(frontendDir, 'index.html');
|
||||
// Auto-serve when dist exists unless explicitly disabled
|
||||
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||
if (shouldServe) {
|
||||
logger.info(`Serving frontend from ${frontendDir}`);
|
||||
// Serve pre-built assets
|
||||
app.use(express.static(frontendDir));
|
||||
|
||||
// Landing page handler or SPA fallback
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
|
||||
// SPA fallback for admin + gallery routes
|
||||
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
} else {
|
||||
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
||||
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('EXPRESS ERROR HANDLER:', err);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return {
|
||||
db: mockDb,
|
||||
logActivity: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
|
||||
const buildPublicSiteRows = (overrides = {}) => ([
|
||||
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
|
||||
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
|
||||
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
|
||||
]);
|
||||
|
||||
const buildBrandingRows = (overrides = {}) => ([
|
||||
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
|
||||
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
|
||||
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
|
||||
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
|
||||
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
|
||||
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#f8fafc',
|
||||
textColor: '#0f172a'
|
||||
}) }
|
||||
]);
|
||||
|
||||
describe('publicSiteService', () => {
|
||||
beforeEach(() => {
|
||||
clearPublicSiteCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sanitizes stored HTML by stripping script tags', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||
expect(payload.html).not.toContain('<script');
|
||||
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||
});
|
||||
|
||||
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({
|
||||
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
|
||||
});
|
||||
const brandingRows = buildBrandingRows();
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.css).toContain('body { color: blue; }');
|
||||
expect(payload.css).not.toContain('@import');
|
||||
expect(payload.css).not.toContain('javascript:');
|
||||
// Client-side util should match server sanitization expectations
|
||||
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
|
||||
expect(clientSanitized).not.toContain('@import');
|
||||
expect(clientSanitized).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('injects branding tokens into the rendered payload', async () => {
|
||||
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
|
||||
const brandingRows = buildBrandingRows({
|
||||
companyName: 'Aurora Collective',
|
||||
companyTagline: 'Modern photography for timeless celebrations.',
|
||||
supportEmail: 'studio@aurora.co',
|
||||
logoUrl: '/uploads/logos/aurora.png',
|
||||
themeConfig: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#1d4ed8',
|
||||
backgroundColor: '#fafafa',
|
||||
textColor: '#171717'
|
||||
}
|
||||
});
|
||||
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||
|
||||
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||
|
||||
expect(payload.html).toContain('Aurora Collective');
|
||||
expect(payload.html).toContain('Modern photography for timeless celebrations.');
|
||||
expect(payload.html).toContain('studio@aurora.co');
|
||||
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||
expect(payload.branding.colors.primary).toBe('#5C8762');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_HTML = `
|
||||
<section class="hero" id="welcome">
|
||||
<div class="hero__inner">
|
||||
<span class="hero__badge">PicPeak Showcase</span>
|
||||
<h1>Share the story of {{company_name}}</h1>
|
||||
<p class="hero__lead">{{company_tagline}}</p>
|
||||
<div class="hero__cta">
|
||||
<a href="#features" class="button button--primary">Explore Features</a>
|
||||
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
|
||||
</div>
|
||||
<dl class="hero__stats">
|
||||
<div>
|
||||
<dt>Private invites</dt>
|
||||
<dd>Secure links for every guest</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Curated delivery</dt>
|
||||
<dd>Highlight every favourite instantly</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fully branded</dt>
|
||||
<dd>Colours, typography, and logo that match you</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="hero__visual">
|
||||
<article class="deck deck--primary">
|
||||
<header class="deck__header">
|
||||
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
|
||||
<span class="deck__title">PicPeak Gallery</span>
|
||||
</header>
|
||||
<ul class="deck__list">
|
||||
<li>Guided cover stories</li>
|
||||
<li>Guest uploads with approvals</li>
|
||||
<li>Protected high-res downloads</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="deck deck--secondary">
|
||||
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
|
||||
<p class="deck__author">— Studio Miraval</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Why teams pick PicPeak</span>
|
||||
<h2>Design-first galleries with the workflow you already love</h2>
|
||||
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<article>
|
||||
<h3>Beautiful by default</h3>
|
||||
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Guided storytelling</h3>
|
||||
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Secure sharing</h3>
|
||||
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflow" id="workflow">
|
||||
<div class="workflow__content">
|
||||
<h2>Launch in minutes</h2>
|
||||
<ol class="workflow__steps">
|
||||
<li>
|
||||
<h4>Brand it once</h4>
|
||||
<p>PicPeak automatically applies your logo, colours, and support details.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Curate sections</h4>
|
||||
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4>Share confidently</h4>
|
||||
<p>Send a single link that greets guests before they enter their private gallery.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="workflow__media">
|
||||
<figure class="workflow__browser">
|
||||
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
|
||||
<figcaption>PicPeak dashboard — trusted by studios worldwide.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collections" id="collections">
|
||||
<div class="section-head">
|
||||
<span class="section-badge">Showcase highlights</span>
|
||||
<h2>Curated sample galleries that mirror your client experience</h2>
|
||||
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
|
||||
</div>
|
||||
<div class="collection-showcase">
|
||||
<article>
|
||||
<h3>Signature Galleries</h3>
|
||||
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Welcome timelines</h3>
|
||||
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stories" id="stories">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-badge">Client notes</span>
|
||||
<h2>Experiences that keep guests coming back</h2>
|
||||
</div>
|
||||
<div class="story-grid">
|
||||
<figure>
|
||||
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
|
||||
<figcaption>— Harper & Elias</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
|
||||
<figcaption>— Jordan Rivera, Photographer</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta" id="contact">
|
||||
<div class="cta__inner">
|
||||
<div>
|
||||
<h2>Ready to welcome your guests?</h2>
|
||||
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
|
||||
</div>
|
||||
<div class="cta__actions">
|
||||
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
|
||||
<a href="#features" class="button button--ghost">Review features</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer" id="legal">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>{{company_name}}</h2>
|
||||
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/datenschutz">Privacy Policy</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="mailto:{{support_email}}">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const DEFAULT_PUBLIC_SITE_CSS = `
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
|
||||
color: var(--brand-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.brand-label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
position: relative;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.site-nav a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover::after {
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.site-main {
|
||||
padding: 3rem 0 5rem;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.features,
|
||||
.workflow,
|
||||
.collections,
|
||||
.stories,
|
||||
.cta {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero__lead {
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.hero__stats {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hero__stats dt {
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.hero__stats dd {
|
||||
margin: 0.35rem 0 0;
|
||||
color: rgba(15, 23, 42, 0.6);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.deck {
|
||||
border-radius: 20px;
|
||||
padding: 1.75rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.deck--primary {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.deck--secondary {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
|
||||
}
|
||||
|
||||
.deck__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.deck__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.deck__title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.deck__list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
color: rgba(15, 23, 42, 0.68);
|
||||
}
|
||||
|
||||
.deck__quote {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
.deck__author {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.section-head--center {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.6rem);
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.feature-grid article {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.workflow__steps {
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.workflow__steps h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--brand-text);
|
||||
}
|
||||
|
||||
.workflow__steps p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
|
||||
.workflow__browser {
|
||||
margin: 0;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.collection-showcase {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.collection-showcase article {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.story-grid {
|
||||
margin-top: 2.5rem;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.story-grid figure {
|
||||
margin: 0;
|
||||
padding: 1.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.story-grid blockquote {
|
||||
margin: 0 0 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.story-grid figcaption {
|
||||
font-weight: 600;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
|
||||
color: #fff;
|
||||
border-radius: 28px;
|
||||
padding: clamp(2.5rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.cta__inner h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.cta__inner p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.cta__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 3rem 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.footer-inner h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.footer-inner p {
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workflow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cta__inner {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_PUBLIC_SITE_TITLE,
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
};
|
||||
@@ -1,7 +1,28 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||
try {
|
||||
const isPostgres = knexConfig && knexConfig.client === 'pg';
|
||||
if (!isPostgres && knexConfig && knexConfig.connection) {
|
||||
const filename = typeof knexConfig.connection === 'object'
|
||||
? knexConfig.connection.filename
|
||||
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
|
||||
if (filename && typeof filename === 'string') {
|
||||
const dir = path.dirname(filename);
|
||||
if (dir && dir !== '.') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: log and continue; SQLite will fail later if still missing
|
||||
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
|
||||
}
|
||||
|
||||
// Create database connection with built-in retry logic
|
||||
const db = knex(knexConfig);
|
||||
|
||||
@@ -54,6 +75,14 @@ async function initializeDatabase() {
|
||||
table.boolean('is_archived').defaultTo(false);
|
||||
table.string('archive_path');
|
||||
table.datetime('archived_at');
|
||||
table.boolean('allow_user_uploads').defaultTo(false);
|
||||
table.integer('upload_category_id');
|
||||
table.boolean('allow_downloads').defaultTo(true);
|
||||
table.boolean('disable_right_click').defaultTo(false);
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
table.boolean('require_password').defaultTo(true);
|
||||
});
|
||||
} else {
|
||||
// Check if color_theme needs to be updated to TEXT type
|
||||
@@ -83,11 +112,42 @@ async function initializeDatabase() {
|
||||
archive_path TEXT,
|
||||
archived_at DATETIME,
|
||||
allow_user_uploads BOOLEAN DEFAULT 0,
|
||||
upload_category_id INTEGER
|
||||
upload_category_id INTEGER,
|
||||
allow_downloads BOOLEAN DEFAULT 1,
|
||||
disable_right_click BOOLEAN DEFAULT 0,
|
||||
watermark_downloads BOOLEAN DEFAULT 0,
|
||||
watermark_text TEXT,
|
||||
hero_photo_id INTEGER,
|
||||
require_password BOOLEAN DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||
const pragmaRows = await db.raw("PRAGMA table_info('events')");
|
||||
const existingColumns = pragmaRows.map(row => row.name);
|
||||
const selectColumns = existingColumns.map((col) => {
|
||||
switch (col) {
|
||||
case 'allow_user_uploads':
|
||||
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
|
||||
case 'upload_category_id':
|
||||
return "upload_category_id";
|
||||
case 'allow_downloads':
|
||||
return "COALESCE(allow_downloads, 1) as allow_downloads";
|
||||
case 'disable_right_click':
|
||||
return "COALESCE(disable_right_click, 0) as disable_right_click";
|
||||
case 'watermark_downloads':
|
||||
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
|
||||
case 'watermark_text':
|
||||
return 'watermark_text';
|
||||
case 'hero_photo_id':
|
||||
return 'hero_photo_id';
|
||||
case 'require_password':
|
||||
return 'COALESCE(require_password, 1) as require_password';
|
||||
default:
|
||||
return col;
|
||||
}
|
||||
});
|
||||
|
||||
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
|
||||
await db.raw('DROP TABLE events');
|
||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||
} catch (error) {
|
||||
@@ -108,6 +168,7 @@ async function initializeDatabase() {
|
||||
table.string('thumbnail_path');
|
||||
table.string('type').notNullable(); // 'collage' or 'individual'
|
||||
table.integer('size_bytes');
|
||||
table.string('uploaded_by').defaultTo('admin');
|
||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||
table.integer('view_count').defaultTo(0);
|
||||
table.integer('download_count').defaultTo(0);
|
||||
@@ -138,11 +199,24 @@ async function initializeDatabase() {
|
||||
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
|
||||
table.json('email_data');
|
||||
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('scheduled_at').defaultTo(db.fn.now());
|
||||
table.datetime('sent_at');
|
||||
table.text('error_message');
|
||||
table.integer('retry_count').defaultTo(0);
|
||||
});
|
||||
} else {
|
||||
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
|
||||
if (!hasCreatedAt) {
|
||||
await db.schema.alterTable('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
});
|
||||
try {
|
||||
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
|
||||
} catch (updateError) {
|
||||
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin users table
|
||||
@@ -160,6 +234,7 @@ async function initializeDatabase() {
|
||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||
table.datetime('last_login');
|
||||
table.string('last_login_ip');
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
} else {
|
||||
// Check if updated_at column exists
|
||||
@@ -195,6 +270,13 @@ async function initializeDatabase() {
|
||||
table.string('last_login_ip');
|
||||
});
|
||||
}
|
||||
|
||||
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
|
||||
if (!hasLanguage) {
|
||||
await db.schema.table('admin_users', (table) => {
|
||||
table.string('language', 2).defaultTo('en');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Token revocation tables
|
||||
@@ -270,6 +352,18 @@ async function initializeDatabase() {
|
||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const defaultLanguageSetting = await db('app_settings')
|
||||
.where('setting_key', 'default_language')
|
||||
.first();
|
||||
if (!defaultLanguageSetting) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'default_language',
|
||||
setting_value: JSON.stringify('en'),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Activity logs table
|
||||
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
|
||||
@@ -294,6 +388,86 @@ async function initializeDatabase() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ensureGlobalCategories();
|
||||
}
|
||||
|
||||
// Ensure photo categories exist for new deployments
|
||||
async function ensureGlobalCategories() {
|
||||
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
|
||||
if (!hasPhotoCategoriesTable) {
|
||||
await db.schema.createTable('photo_categories', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('slug', 100).notNullable();
|
||||
table.boolean('is_global').defaultTo(true);
|
||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.unique(['slug', 'event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
|
||||
if (!hasCategoryIdColumn) {
|
||||
await db.schema.alterTable('photos', (table) => {
|
||||
table.integer('category_id').references('id').inTable('photo_categories');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
|
||||
if (!hasCmsPagesTable) {
|
||||
await db.schema.createTable('cms_pages', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('slug', 100).unique().notNullable();
|
||||
table.text('title_en');
|
||||
table.text('title_de');
|
||||
table.text('content_en');
|
||||
table.text('content_de');
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
|
||||
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
|
||||
if (categoryCount === 0) {
|
||||
const defaultCategories = [
|
||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||
{ name: 'Details', slug: 'details', is_global: true },
|
||||
{ name: 'Party', slug: 'party', is_global: true },
|
||||
];
|
||||
|
||||
await db('photo_categories').insert(defaultCategories);
|
||||
}
|
||||
|
||||
const cmsPages = await db('cms_pages').select('slug');
|
||||
const existingSlugs = cmsPages.map((page) => page.slug);
|
||||
const defaultPages = [
|
||||
{
|
||||
slug: 'impressum',
|
||||
title_en: 'Legal Notice',
|
||||
title_de: 'Impressum',
|
||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
slug: 'datenschutz',
|
||||
title_en: 'Privacy Policy',
|
||||
title_de: 'Datenschutzerklärung',
|
||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const page of defaultPages) {
|
||||
if (!existingSlugs.includes(page.slug)) {
|
||||
await db('cms_pages').insert(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to log activities
|
||||
@@ -312,4 +486,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
|
||||
@@ -3,13 +3,14 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Enhanced admin authentication middleware with revocation checking
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -97,7 +98,8 @@ async function adminAuth(req, res, next) {
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -164,4 +166,4 @@ module.exports = {
|
||||
adminAuth,
|
||||
galleryAuth,
|
||||
// ... other exports
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
||||
*/
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -89,7 +90,8 @@ async function adminAuth(req, res, next) {
|
||||
*/
|
||||
async function galleryAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -151,7 +153,8 @@ async function galleryAuth(req, res, next) {
|
||||
*/
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const slug = req.params?.slug || req.requestedSlug;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
@@ -235,4 +238,4 @@ module.exports = {
|
||||
galleryAuth,
|
||||
photoAuth,
|
||||
verifyGalleryAccess
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||
let event;
|
||||
|
||||
if (!token) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
@@ -26,12 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
|
||||
let event;
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => {
|
||||
@@ -64,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
@@ -80,14 +112,14 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
||||
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
res.status(401).json({ error: 'Invalid token', details: error.message });
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyGalleryAccess
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,14 +2,14 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
console.log('PhotoAuth middleware - path:', req.path);
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
@@ -20,9 +20,9 @@ async function photoAuth(req, res, next) {
|
||||
}
|
||||
|
||||
// First check for JWT token (from gallery access)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
|
||||
if (tokenFromRequest) {
|
||||
const token = tokenFromRequest;
|
||||
try {
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
@@ -79,29 +79,36 @@ async function photoAuth(req, res, next) {
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
console.error('JWT verification failed:', err.message);
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for password header (legacy support)
|
||||
const password = req.headers['x-gallery-password'];
|
||||
|
||||
if (!password && !authHeader) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password) {
|
||||
if (!eventSlug && !password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
@@ -121,7 +128,7 @@ async function photoAuth(req, res, next) {
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Photo auth error:', error);
|
||||
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
// In-memory session tracking (in production, use Redis)
|
||||
const sessions = new Map();
|
||||
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
|
||||
|
||||
async function sessionTimeoutMiddleware(req, res, next) {
|
||||
// Skip for non-authenticated routes
|
||||
if (!req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const token = getAdminTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
@@ -150,4 +147,4 @@ module.exports = {
|
||||
sessionTimeoutMiddleware,
|
||||
endSession,
|
||||
getActiveSessions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const buildChain = ({ firstResult, updateResult } = {}) => {
|
||||
const chain = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
whereNot: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockResolvedValue(updateResult ?? 1),
|
||||
first: jest.fn().mockResolvedValue(firstResult),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
jest.mock('../../database/db', () => {
|
||||
const dbMock = jest.fn();
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.__setImplementations = (...chains) => {
|
||||
dbMock.mockReset();
|
||||
chains.forEach((chain) => {
|
||||
dbMock.mockImplementationOnce(() => chain);
|
||||
});
|
||||
};
|
||||
return {
|
||||
db: dbMock,
|
||||
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
||||
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-enhanced-v2', () => ({
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,68 @@ router.post('/change-password', [
|
||||
}
|
||||
});
|
||||
|
||||
// Update admin profile
|
||||
router.put('/profile', [
|
||||
adminAuth,
|
||||
body('username').trim().notEmpty().withMessage('Username is required'),
|
||||
body('email').trim().isEmail().withMessage('Valid email is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email } = req.body;
|
||||
const userId = req.admin.id;
|
||||
|
||||
// Check for email conflicts
|
||||
const existingEmail = await db('admin_users')
|
||||
.where('email', email)
|
||||
.whereNot('id', userId)
|
||||
.first();
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(409).json({ error: 'Email is already in use by another admin' });
|
||||
}
|
||||
|
||||
// Check username conflict (if multiple admins are supported)
|
||||
const existingUsername = await db('admin_users')
|
||||
.where('username', username)
|
||||
.whereNot('id', userId)
|
||||
.first();
|
||||
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already in use by another admin' });
|
||||
}
|
||||
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
username,
|
||||
email,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const updatedUser = await db('admin_users')
|
||||
.select('id', 'username', 'email', 'must_change_password')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
await logActivity(
|
||||
'admin_profile_updated',
|
||||
{ admin_id: userId, updated_fields: ['username', 'email'] },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: username }
|
||||
);
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Admin profile update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -96,4 +158,4 @@ router.post('/logout', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -221,7 +221,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -243,7 +247,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
||||
const { stdout } = await execAsync(testCommand);
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: config.host || config.destination,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -274,7 +282,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -327,7 +335,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -344,7 +352,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -375,7 +383,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup manifest:', error);
|
||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
||||
res.status(404).json({ error: 'Backup manifest not found' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -436,7 +444,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 buckets:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -473,7 +481,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 files:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to list S3 files' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -535,7 +543,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup S3 backups:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -587,7 +595,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('S3 upload test failed:', error);
|
||||
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
|
||||
res.status(500).json({ error: 'S3 upload test failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -675,7 +683,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup:', error);
|
||||
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to download backup' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -744,7 +752,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get file checksums:', error);
|
||||
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to get file checksums' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -847,7 +855,7 @@ router.post('/estimate', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to estimate backup size:', error);
|
||||
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
|
||||
res.status(500).json({ error: 'Failed to estimate backup size' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -945,12 +953,13 @@ async function validateManifestData(manifestData) {
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Manifest validation error', { error: error.message });
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation error: ${error.message}`,
|
||||
details: { error: error.message }
|
||||
error: 'Validation error encountered while processing manifest',
|
||||
details: { hint: 'See server logs for diagnostic details.' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
const normalizeBoolean = (val, defaultValue = true) => {
|
||||
if (val === undefined || val === null) return defaultValue;
|
||||
if (typeof val === 'boolean') return val;
|
||||
if (typeof val === 'number') return val !== 0;
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const requirePassword = normalizeBoolean(input, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
|
||||
body('watermark_text').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
@@ -58,6 +104,7 @@ router.post('/', adminAuth, [
|
||||
disable_right_click = false,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
require_password: requirePasswordInput = true,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
allow_ratings = true,
|
||||
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
|
||||
show_feedback_to_guests = true
|
||||
} = req.body;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
// Debug logging
|
||||
console.log('Download control values:', {
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
require_password: requirePassword,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
@@ -82,18 +132,24 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
let passwordValidation = null;
|
||||
let galleryPassword = password;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
} else {
|
||||
galleryPassword = '';
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Generate share link
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
@@ -155,7 +215,8 @@ router.post('/', adminAuth, [
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -180,7 +241,7 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at },
|
||||
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
@@ -211,6 +272,7 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
require_password: requirePassword,
|
||||
share_link: shareLink,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
@@ -396,21 +458,66 @@ router.put('/:id', adminAuth, [
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
body('watermark_text').optional().trim(),
|
||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
||||
console.log('Request body:', req.body);
|
||||
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const updates = { ...req.body };
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
|
||||
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
|
||||
updates.external_path = trimmedPath || null;
|
||||
}
|
||||
|
||||
if (updates.source_mode === 'managed') {
|
||||
updates.external_path = null;
|
||||
}
|
||||
|
||||
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Log the update request for debugging
|
||||
console.log('Update event request:', {
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
updates,
|
||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||
@@ -425,6 +532,18 @@ router.put('/:id', adminAuth, [
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -515,13 +634,11 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
// Provide more specific error messages
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||
details: error.message
|
||||
error: 'Cannot delete event due to existing references. Please contact support.'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
error: 'Failed to delete event'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -780,7 +897,7 @@ router.post('/bulk-archive', adminAuth, [
|
||||
results.failed.push({
|
||||
id: event.id,
|
||||
name: event.event_name,
|
||||
error: error.message
|
||||
error: 'Failed to archive event. Check server logs for details.'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -806,4 +923,4 @@ router.post('/bulk-archive', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/admin/external-media/list?path=relative/dir
|
||||
router.get('/list', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
||||
const result = await list(relPath);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.warn('Invalid external media path requested', {
|
||||
path: req.query.path,
|
||||
error: error.message
|
||||
});
|
||||
res.status(400).json({ error: 'Invalid external media path' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to recursively collect files under a directory, filtered by image extensions
|
||||
async function walkDir(dir, baseDir) {
|
||||
const results = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
results.push(...await walkDir(full, baseDir));
|
||||
} else if (e.isFile()) {
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
|
||||
const rel = path.relative(baseDir, full);
|
||||
results.push({ full, rel, name: e.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// POST /api/admin/events/:id/import-external
|
||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.id);
|
||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||
if (!external_path) return res.status(400).json({ error: 'external_path is required' });
|
||||
|
||||
// Load event
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
const baseAbs = resolveExternalPath({ external_path }, '');
|
||||
// Collect files
|
||||
const files = recursive ? await walkDir(baseAbs, baseAbs) : (await fs.readdir(baseAbs, { withFileTypes: true }))
|
||||
.filter(e => e.isFile())
|
||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||
|
||||
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||
let skipped = 0;
|
||||
const preparedFiles = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const stats = await fs.stat(f.full);
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
if (segs[0] === map.collages) type = 'collage';
|
||||
if (segs[0] === map.individual) type = 'individual';
|
||||
preparedFiles.push({ ...f, type, size: stats.size });
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupeMap = new Map();
|
||||
for (const file of preparedFiles) {
|
||||
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||
const existing = dedupeMap.get(dedupeKey);
|
||||
if (!existing || file.size > existing.size) {
|
||||
if (existing) skipped++;
|
||||
dedupeMap.set(dedupeKey, file);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
|
||||
// Insert photos
|
||||
for (const f of dedupeMap.values()) {
|
||||
// Infer type by subfolder names
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
if (segs[0] === map.collages) type = 'collage';
|
||||
if (segs[0] === map.individual) type = 'individual';
|
||||
|
||||
try {
|
||||
// Check if already exists (by external_relpath)
|
||||
const exists = await db('photos')
|
||||
.where({ event_id: eventId, external_relpath: f.rel })
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
const stats = await fs.stat(f.full);
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
filename: f.name,
|
||||
// Keep path as a hint for legacy code but not used for resolution in external mode
|
||||
path: path.join(event.slug, f.name),
|
||||
thumbnail_path: null,
|
||||
type,
|
||||
size_bytes: stats.size,
|
||||
source_origin: 'external',
|
||||
external_relpath: f.rel
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
imported += (inserted?.length ? 1 : 0);
|
||||
} catch (e) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update event fields
|
||||
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
|
||||
|
||||
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed
|
||||
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
|
||||
|
||||
res.json({ imported, skipped, thumbnailsQueued: 0 });
|
||||
} catch (error) {
|
||||
logger.error('External media import failed', {
|
||||
eventId: req.params.id,
|
||||
externalPath: req.body?.external_path,
|
||||
error: error.message
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to import external media' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -60,8 +60,8 @@ router.put('/events/:eventId/feedback-settings',
|
||||
settings: updatedSettings
|
||||
}, eventId, {
|
||||
type: 'admin',
|
||||
id: req.user.id,
|
||||
name: req.user.username
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
|
||||
res.json(updatedSettings);
|
||||
@@ -167,7 +167,7 @@ router.put('/feedback/:feedbackId/:action',
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
}
|
||||
|
||||
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
|
||||
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -184,7 +184,7 @@ router.delete('/feedback/:feedbackId',
|
||||
try {
|
||||
const { feedbackId } = req.params;
|
||||
|
||||
await feedbackService.deleteFeedback(feedbackId, req.user.id);
|
||||
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -368,7 +368,7 @@ router.post('/word-filters',
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error.message === 'Word filter already exists') {
|
||||
return res.status(409).json({ error: error.message });
|
||||
return res.status(409).json({ error: 'Word filter already exists' });
|
||||
}
|
||||
logger.error('Error adding word filter:', error);
|
||||
res.status(500).json({ error: 'Failed to add word filter' });
|
||||
@@ -430,4 +430,4 @@ function convertToCSV(data) {
|
||||
return [csvHeaders, ...csvRows].join('\n');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Delete all notifications
|
||||
router.delete('/clear-all', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const deletedCount = await db('activity_logs').delete();
|
||||
res.json({
|
||||
message: 'All notifications cleared',
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Clear all notifications error:', error);
|
||||
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,6 +13,24 @@ const router = express.Router();
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const parseCategoryId = (value) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value === 0 ? null : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'null') return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed === 0 ? null : parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
@@ -158,16 +176,16 @@ 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;
|
||||
const numericCategoryId = parseCategoryId(category_id);
|
||||
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
@@ -239,7 +257,9 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
category_id: numericCategoryId,
|
||||
source_origin: 'managed'
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -475,9 +495,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const normalizedCategoryId = parseCategoryId(category_id);
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ category_id: category_id || null });
|
||||
.update({ category_id: normalizedCategoryId });
|
||||
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
@@ -578,7 +600,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
// Update photos
|
||||
const updateData = {};
|
||||
if (updates.category_id !== undefined) {
|
||||
updateData.category_id = updates.category_id || null;
|
||||
updateData.category_id = parseCategoryId(updates.category_id);
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
@@ -606,8 +628,9 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const filePath = resolvePhotoFilePath(event, photo);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
@@ -631,14 +654,22 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select('photos.*');
|
||||
.select(
|
||||
'photos.*',
|
||||
'pc.name as category_display_name',
|
||||
'pc.slug as category_display_slug'
|
||||
);
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
if (category_id === '') {
|
||||
// No filter when empty string is provided
|
||||
} else if (category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else if (/^\d+$/.test(category_id)) {
|
||||
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
@@ -664,7 +695,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ photos: [] });
|
||||
}
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -684,12 +719,16 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
category_id: photo.category_id !== null && photo.category_id !== undefined
|
||||
? Number(photo.category_id)
|
||||
: null,
|
||||
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.category_display_slug || photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
@@ -719,8 +758,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const filePath = resolvePhotoFilePath(event, photo);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
@@ -798,8 +838,9 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
storagePath: getStoragePath()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('Error fetching admin photo debug data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photo debug data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -82,7 +82,7 @@ router.post('/validate', [
|
||||
logger.error('Restore validation failed:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
error: 'Restore validation failed',
|
||||
logs: restoreService.restoreLog
|
||||
});
|
||||
}
|
||||
@@ -162,7 +162,7 @@ router.post('/start', [
|
||||
logger.error('Failed to start restore:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: 'Failed to start restore operation'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -458,4 +458,4 @@ async function getBackupConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,12 +8,24 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
} = require('../constants/publicSiteDefaults');
|
||||
const {
|
||||
clearPublicSiteCache,
|
||||
getDefaultPublicSitePayload,
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const router = express.Router();
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for logo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -43,7 +55,7 @@ const upload = multer({
|
||||
// Configure multer for favicon uploads
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -170,7 +182,13 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
watermark_size,
|
||||
favicon_url,
|
||||
logo_url,
|
||||
watermark_logo_url
|
||||
watermark_logo_url,
|
||||
logo_size,
|
||||
logo_max_height,
|
||||
logo_position,
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode
|
||||
} = req.body;
|
||||
|
||||
const brandingSettings = {
|
||||
@@ -184,7 +202,13 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
watermark_size,
|
||||
favicon_url,
|
||||
logo_url,
|
||||
watermark_logo_url
|
||||
watermark_logo_url,
|
||||
logo_size,
|
||||
logo_max_height,
|
||||
logo_position,
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode
|
||||
};
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
@@ -206,7 +230,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||
// Delete the file from filesystem
|
||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
console.log('Deleted favicon file:', faviconPath);
|
||||
@@ -236,7 +261,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||
// Delete the file from filesystem
|
||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
console.log('Deleted logo file:', logoPath);
|
||||
@@ -272,6 +298,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ company_name })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
@@ -431,6 +459,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||
});
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Theme update error:', error);
|
||||
@@ -441,7 +471,39 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = { ...req.body };
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
|
||||
settings.general_public_site_html = settings.general_public_site_html.trim();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
|
||||
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
|
||||
}
|
||||
|
||||
const enableToggle = settings.general_public_site_enabled;
|
||||
if (enableToggle === true) {
|
||||
let htmlValue = settings.general_public_site_html;
|
||||
|
||||
if (htmlValue === undefined) {
|
||||
const currentSettings = await getRawPublicSiteSettings();
|
||||
htmlValue = currentSettings.general_public_site_html;
|
||||
}
|
||||
|
||||
if (!htmlValue || !String(htmlValue).trim()) {
|
||||
return res.status(400).json({
|
||||
error: 'Public site HTML must be provided before enabling the public landing page.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -464,6 +526,10 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'general_settings_updated',
|
||||
@@ -581,7 +647,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const storagePath = getStoragePath();
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
@@ -591,11 +657,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
let diskStats = null;
|
||||
let rawDiskTotal = null;
|
||||
let rawDiskFree = null;
|
||||
let rawDiskAvailable = null;
|
||||
try {
|
||||
diskStats = await fs.statfs(storagePath);
|
||||
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
|
||||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||
} catch (diskError) {
|
||||
console.error('Disk stats error:', diskError.message);
|
||||
}
|
||||
|
||||
const clampDiskValue = (value) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Treat unusually large virtualised values as unreliable (>50TB)
|
||||
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
|
||||
if (value > MAX_REASONABLE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
let diskTotal = null;
|
||||
let diskFree = null;
|
||||
let diskAvailable = null;
|
||||
|
||||
if (diskStats) {
|
||||
diskTotal = clampDiskValue(rawDiskTotal);
|
||||
diskFree = clampDiskValue(rawDiskFree);
|
||||
diskAvailable = clampDiskValue(rawDiskAvailable);
|
||||
|
||||
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
|
||||
diskAvailable = null;
|
||||
}
|
||||
if (diskTotal && diskFree && diskFree > diskTotal) {
|
||||
diskFree = null;
|
||||
}
|
||||
}
|
||||
|
||||
const totalUsed = totalStorage?.total || 0;
|
||||
|
||||
const parseBytesValue = (value) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(numeric);
|
||||
};
|
||||
|
||||
const parseEnvOverride = (bytesVar, gbVar) => {
|
||||
if (process.env[bytesVar]) {
|
||||
return parseBytesValue(process.env[bytesVar]);
|
||||
}
|
||||
if (process.env[gbVar]) {
|
||||
const value = parseBytesValue(process.env[gbVar]);
|
||||
return value ? value * 1024 * 1024 * 1024 : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let configuredSoftLimit = null;
|
||||
let capacityOverrideDb = null;
|
||||
let availableOverrideDb = null;
|
||||
|
||||
try {
|
||||
const storageSettings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_storage_soft_limit_bytes',
|
||||
'general_storage_capacity_override_bytes',
|
||||
'general_storage_available_override_bytes'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
storageSettings.forEach((setting) => {
|
||||
let parsedValue = null;
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
parsedValue = JSON.parse(setting.setting_value);
|
||||
} catch (error) {
|
||||
parsedValue = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (setting.setting_key) {
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage settings read error:', error.message);
|
||||
}
|
||||
|
||||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
|
||||
|
||||
let capacityOverrideBytes = null;
|
||||
let availableOverrideBytes = null;
|
||||
let overrideSource = null;
|
||||
|
||||
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
|
||||
capacityOverrideBytes = capacityOverrideEnv;
|
||||
availableOverrideBytes = availableOverrideEnv;
|
||||
overrideSource = 'env';
|
||||
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
|
||||
capacityOverrideBytes = capacityOverrideDb;
|
||||
availableOverrideBytes = availableOverrideDb;
|
||||
overrideSource = 'settings';
|
||||
}
|
||||
|
||||
if (capacityOverrideBytes != null) {
|
||||
diskTotal = capacityOverrideBytes;
|
||||
if (availableOverrideBytes == null) {
|
||||
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
|
||||
} else {
|
||||
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
|
||||
}
|
||||
diskFree = diskAvailable;
|
||||
} else if (availableOverrideBytes != null) {
|
||||
diskAvailable = Math.max(availableOverrideBytes, 0);
|
||||
diskFree = diskAvailable;
|
||||
}
|
||||
|
||||
let recommendedSoftLimit = null;
|
||||
if (diskTotal && diskAvailable) {
|
||||
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
|
||||
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
|
||||
} else if (diskTotal) {
|
||||
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
|
||||
} else if (diskAvailable) {
|
||||
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
|
||||
}
|
||||
|
||||
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
|
||||
recommendedSoftLimit = totalUsed;
|
||||
}
|
||||
|
||||
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
if (!recommendedSoftLimit && fallbackSoftLimit) {
|
||||
recommendedSoftLimit = fallbackSoftLimit;
|
||||
}
|
||||
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
|
||||
|
||||
const diskMetricsReliable = Boolean(diskTotal);
|
||||
|
||||
res.json({
|
||||
total_used: totalStorage.total || 0,
|
||||
total_used: totalUsed,
|
||||
archive_storage: archiveStorage,
|
||||
storage_by_event: storageByEvent,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default
|
||||
storage_limit: effectiveSoftLimit,
|
||||
storage_soft_limit: effectiveSoftLimit,
|
||||
configured_soft_limit: configuredSoftLimit,
|
||||
recommended_soft_limit: recommendedSoftLimit,
|
||||
soft_limit_configured: Boolean(configuredSoftLimit),
|
||||
disk_total: diskTotal,
|
||||
disk_free: diskFree,
|
||||
disk_available: diskAvailable,
|
||||
disk_total_raw: rawDiskTotal,
|
||||
disk_free_raw: rawDiskFree,
|
||||
disk_available_raw: rawDiskAvailable,
|
||||
disk_metrics_reliable: diskMetricsReliable,
|
||||
disk_override_source: overrideSource
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage info error:', error);
|
||||
@@ -705,4 +950,79 @@ router.put('/security/rate-limit', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Get default public site template
|
||||
router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
res.json({
|
||||
enabled: false,
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding,
|
||||
meta: {
|
||||
title: defaults.title,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load public site defaults:', error);
|
||||
res.status(500).json({ error: 'Failed to load defaults' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset public site template to defaults
|
||||
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const entries = [
|
||||
{
|
||||
key: 'general_public_site_html',
|
||||
value: DEFAULT_PUBLIC_SITE_HTML.trim()
|
||||
},
|
||||
{
|
||||
key: 'general_public_site_custom_css',
|
||||
value: ''
|
||||
}
|
||||
];
|
||||
|
||||
for (const { key, value } of entries) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
const defaults = await getDefaultPublicSitePayload();
|
||||
|
||||
await logActivity('public_site_reset_to_default',
|
||||
{
|
||||
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Public site template reset to defaults',
|
||||
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||
css: '',
|
||||
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||
branding: defaults.branding
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to reset public site template:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -208,10 +209,14 @@ router.get('/database', adminAuth, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
// Table might not exist
|
||||
logger.warn('Failed to retrieve table info', {
|
||||
table,
|
||||
error: error.message
|
||||
});
|
||||
tableInfo.push({
|
||||
name: table,
|
||||
rows: 0,
|
||||
error: error.message
|
||||
error: 'Unable to retrieve table details'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -226,4 +231,4 @@ router.get('/database', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
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_fail'
|
||||
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 gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
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,
|
||||
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -372,4 +389,4 @@ router.post('/password-strength', [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -14,6 +14,14 @@ const {
|
||||
} = 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
|
||||
@@ -91,6 +99,8 @@ router.post('/admin/login', [
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
@@ -110,13 +120,14 @@ router.post('/admin/login', [
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
const adminToken = getAdminTokenFromRequest(req);
|
||||
const galleryToken = getGalleryTokenFromRequest(req);
|
||||
const token = adminToken || galleryToken;
|
||||
|
||||
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', {
|
||||
@@ -124,11 +135,23 @@ router.post('/logout', async (req, res) => {
|
||||
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
|
||||
// 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);
|
||||
@@ -139,7 +162,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -150,55 +173,68 @@ router.post('/gallery/verify', [
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
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);
|
||||
|
||||
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_fail'
|
||||
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 gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
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,
|
||||
@@ -209,6 +245,8 @@ router.post('/gallery/verify', [
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
@@ -221,7 +259,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -230,15 +269,97 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
|
||||
// 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 token = req.headers.authorization?.split(' ')[1];
|
||||
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);
|
||||
|
||||
@@ -250,7 +371,9 @@ router.get('/session', async (req, res) => {
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug
|
||||
user: decoded.username || decoded.eventSlug,
|
||||
eventSlug: decoded.eventSlug,
|
||||
adminUsername: decoded.username
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
@@ -263,4 +386,4 @@ router.get('/session', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,11 +4,34 @@ const bcrypt = require('bcrypt');
|
||||
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 fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail(),
|
||||
body('admin_email').isEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -32,10 +65,28 @@ router.post('/', adminAuth, [
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${slug}/${shareToken}`;
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
const shareLinkSlug = `${slug}/${shareToken}`;
|
||||
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: shareLinkSlug,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -88,17 +145,18 @@ router.post('/', adminAuth, [
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_link: fullShareLink,
|
||||
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 || ''
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: fullShareLink,
|
||||
expires_at,
|
||||
require_password: requirePassword
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const updates = { ...req.body };
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
|
||||
// If updating password, hash it
|
||||
if (updates.password) {
|
||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
||||
delete updates.password;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const archiver = require('archiver');
|
||||
@@ -8,8 +7,8 @@ const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
@@ -20,7 +19,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
.first();
|
||||
|
||||
@@ -37,7 +36,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
res.json({ valid: true });
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
res.status(500).json({ error: 'Failed to verify token', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to verify token' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,9 +47,22 @@ router.get('/:slug/info', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug: slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'allow_downloads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
@@ -74,6 +86,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
@@ -81,28 +95,110 @@ router.get('/:slug/info', async (req, res) => {
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
requires_password: true,
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: event.allow_downloads !== false,
|
||||
disable_right_click: event.disable_right_click === true,
|
||||
watermark_downloads: event.watermark_downloads === true,
|
||||
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
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all photos
|
||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
|
||||
// First get all photos
|
||||
const photos = await db('photos')
|
||||
let photos = await db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||
if (filter) {
|
||||
const filterTokens = new Set(
|
||||
String(filter)
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
if (filterTokens.size > 0) {
|
||||
// Treat "saved" / "favorite" synonyms as favorites
|
||||
if (filterTokens.has('saved')) {
|
||||
filterTokens.add('favorited');
|
||||
}
|
||||
if (filterTokens.has('favorite')) {
|
||||
filterTokens.add('favorited');
|
||||
}
|
||||
|
||||
const include = new Set();
|
||||
|
||||
const includeBy = (predicate) => {
|
||||
photos.forEach(photo => {
|
||||
if (predicate(photo)) {
|
||||
include.add(photo.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let guestFeedbackByType = null;
|
||||
if (guest_id) {
|
||||
const guestFeedbackRows = await db('photo_feedback')
|
||||
.where({ event_id: req.event.id, guest_identifier: guest_id })
|
||||
.select('photo_id', 'feedback_type');
|
||||
|
||||
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
|
||||
if (!acc[row.feedback_type]) {
|
||||
acc[row.feedback_type] = new Set();
|
||||
}
|
||||
acc[row.feedback_type].add(row.photo_id);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const includeGuestMatches = (type) => {
|
||||
const ids = guestFeedbackByType?.[type];
|
||||
if (ids && ids.size > 0) {
|
||||
ids.forEach(id => include.add(id));
|
||||
}
|
||||
};
|
||||
|
||||
if (filterTokens.has('liked')) {
|
||||
includeGuestMatches('like');
|
||||
includeBy(photo => (photo.like_count || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('favorited')) {
|
||||
includeGuestMatches('favorite');
|
||||
includeBy(photo => (photo.favorite_count || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('rated')) {
|
||||
includeGuestMatches('rating');
|
||||
includeBy(photo => (photo.average_rating || 0) > 0);
|
||||
}
|
||||
|
||||
if (filterTokens.has('commented')) {
|
||||
includeGuestMatches('comment');
|
||||
const commentedRows = await db('photo_feedback')
|
||||
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||
.groupBy('photo_id')
|
||||
.select('photo_id');
|
||||
commentedRows.forEach(row => include.add(row.photo_id));
|
||||
}
|
||||
|
||||
photos = photos.filter(photo => include.has(photo.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -150,13 +246,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
overlay_protection: req.event.overlay_protection !== false
|
||||
};
|
||||
|
||||
console.log('[Gallery Photos] Event data:', {
|
||||
id: req.event.id,
|
||||
slug: req.params.slug,
|
||||
protection_level: req.event.protection_level,
|
||||
calculated_protection: protectionSettings.protection_level,
|
||||
is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard')
|
||||
});
|
||||
|
||||
res.json({
|
||||
event: {
|
||||
@@ -181,8 +270,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
@@ -209,7 +296,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photos', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -243,16 +330,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
@@ -271,9 +359,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
res.download(filePath, photo.filename);
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error processing gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
@@ -316,16 +419,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping photo in bulk download due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the file name in the archive
|
||||
@@ -340,11 +444,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Add original file
|
||||
archive.file(filePath, { name: archiveName });
|
||||
}
|
||||
}
|
||||
@@ -359,18 +470,117 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
action: 'download_all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating bulk gallery download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to create download archive' });
|
||||
}
|
||||
});
|
||||
|
||||
// Test route
|
||||
router.get('/:slug/photo-test/:photoId',
|
||||
verifyGalleryAccess,
|
||||
(req, res) => {
|
||||
console.log('TEST ROUTE EXECUTED!');
|
||||
res.json({ message: 'Test route works!', photoId: req.params.photoId });
|
||||
// Download selected photos as ZIP
|
||||
router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
|
||||
if (!ids.length) {
|
||||
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
|
||||
}
|
||||
|
||||
// Clean IDs
|
||||
const photoIds = ids
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
.slice(0, 500);
|
||||
|
||||
if (photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||
}
|
||||
|
||||
// Fetch photos
|
||||
const photos = await db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.whereIn('photos.id', photoIds)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found for selected IDs' });
|
||||
}
|
||||
|
||||
const archiveName = `${req.event.slug}-selected.zip`;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Zip error generating selected download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: err.message,
|
||||
});
|
||||
try {
|
||||
res.status(500).end();
|
||||
} catch (_) {
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
// Check watermark settings similar to download-all
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
archive.file(filePath, { name });
|
||||
}
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping selected photo due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
|
||||
await db('access_logs').insert({
|
||||
event_id: req.event.id,
|
||||
ip_address: req.ip,
|
||||
user_agent: req.headers['user-agent'],
|
||||
action: 'download_selected'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in download-selected:', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
@@ -400,18 +610,9 @@ router.get('/:slug/photo/:photoId',
|
||||
});
|
||||
}
|
||||
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
}
|
||||
// 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
|
||||
@@ -453,7 +654,7 @@ router.get('/:slug/photo/:photoId',
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve photo', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -619,12 +820,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
// Process uploaded photos
|
||||
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
||||
|
||||
// Clean up temp files
|
||||
const fs = require('fs').promises;
|
||||
for (const file of req.files) {
|
||||
await fs.unlink(file.path).catch(console.error);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Photos uploaded successfully',
|
||||
count: results.length,
|
||||
|
||||
@@ -24,14 +24,15 @@ router.get('/:slug/feedback-settings',
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
// Only send relevant settings to guests
|
||||
// Convert SQLite boolean values (0/1) to proper booleans
|
||||
const guestSettings = {
|
||||
feedback_enabled: settings.feedback_enabled,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
require_name_email: settings.require_name_email,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||
feedback_enabled: Boolean(settings.feedback_enabled),
|
||||
allow_ratings: Boolean(settings.allow_ratings),
|
||||
allow_likes: Boolean(settings.allow_likes),
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
require_name_email: Boolean(settings.require_name_email),
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
|
||||
};
|
||||
|
||||
res.json(guestSettings);
|
||||
|
||||
@@ -42,6 +42,12 @@ router.get('/', async (req, res) => {
|
||||
branding_watermark_size: settingsObject.branding_watermark_size || 15,
|
||||
branding_favicon_url: settingsObject.branding_favicon_url || '',
|
||||
branding_logo_url: settingsObject.branding_logo_url || '',
|
||||
branding_logo_size: settingsObject.branding_logo_size || 'medium',
|
||||
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
|
||||
branding_logo_position: settingsObject.branding_logo_position || 'left',
|
||||
branding_logo_display_header: settingsObject.branding_logo_display_header !== false,
|
||||
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
|
||||
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
|
||||
theme_config: settingsObject.theme_config || null,
|
||||
default_language: settingsObject.general_default_language || 'en',
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Generate secure token for image access
|
||||
*/
|
||||
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||||
|
||||
try {
|
||||
console.log('Secure image route hit:', {
|
||||
slug: slug,
|
||||
photoId: photoId,
|
||||
logger.debug('Secure image route hit', {
|
||||
slug,
|
||||
photoId,
|
||||
tokenLength: token?.length,
|
||||
headers: req.headers.authorization ? 'present' : 'absent'
|
||||
hasAuthHeader: Boolean(req.headers.authorization),
|
||||
});
|
||||
const { fragment } = req.query;
|
||||
|
||||
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure token generation', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get protection settings for this event
|
||||
const protectionSettings = {
|
||||
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Apply watermark if enabled
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
@@ -546,4 +552,4 @@ module.exports = {
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
|
||||
let cachedRoot = null;
|
||||
|
||||
function resolveDefaultRoot() {
|
||||
const containerDefault = '/external-media';
|
||||
try {
|
||||
if (fsSync.existsSync(containerDefault)) {
|
||||
return containerDefault;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors, fallback below
|
||||
}
|
||||
|
||||
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
|
||||
try {
|
||||
if (fsSync.existsSync(localFallback)) {
|
||||
return localFallback;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore and return container default
|
||||
}
|
||||
|
||||
return containerDefault;
|
||||
}
|
||||
|
||||
function getExternalMediaRoot() {
|
||||
if (cachedRoot) {
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
const configured = process.env.EXTERNAL_MEDIA_ROOT;
|
||||
if (configured && configured.trim()) {
|
||||
const resolvedConfigured = path.resolve(configured.trim());
|
||||
try {
|
||||
if (fsSync.existsSync(resolvedConfigured)) {
|
||||
cachedRoot = resolvedConfigured;
|
||||
return cachedRoot;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors and fall back to defaults
|
||||
}
|
||||
}
|
||||
|
||||
cachedRoot = resolveDefaultRoot();
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
function isUnderRoot(p) {
|
||||
const root = path.resolve(getExternalMediaRoot());
|
||||
const resolved = path.resolve(p);
|
||||
return resolved === root || resolved.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
async function list(relativePath = '') {
|
||||
const root = getExternalMediaRoot();
|
||||
// Normalize and ensure safe join under root
|
||||
const targetDir = safePathJoin(root, relativePath || '.');
|
||||
|
||||
const entries = [];
|
||||
try {
|
||||
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
||||
for (const d of dirents) {
|
||||
// Skip hidden files and directories
|
||||
if (d.name.startsWith('.')) continue;
|
||||
const full = path.join(targetDir, d.name);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) continue;
|
||||
|
||||
if (d.isDirectory()) {
|
||||
entries.push({ name: d.name, type: 'dir' });
|
||||
} else if (d.isFile()) {
|
||||
const ext = path.extname(d.name).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
|
||||
entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Propagate errors for caller to handle (e.g., invalid path)
|
||||
throw e;
|
||||
}
|
||||
|
||||
const rootResolved = path.resolve(root);
|
||||
const currentResolved = path.resolve(targetDir);
|
||||
const canNavigateUp = currentResolved !== rootResolved;
|
||||
|
||||
// Return normalized relative path from root
|
||||
const relFromRoot = path.relative(rootResolved, currentResolved);
|
||||
|
||||
return { path: relFromRoot, entries, canNavigateUp };
|
||||
}
|
||||
|
||||
function resolveExternalPath(event, relpath) {
|
||||
const root = getExternalMediaRoot();
|
||||
const base = event?.external_path ? path.join(event.external_path) : '';
|
||||
const combined = base ? path.join(base, relpath || '') : (relpath || '');
|
||||
return safePathJoin(root, combined);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getExternalMediaRoot,
|
||||
isUnderRoot,
|
||||
list,
|
||||
resolveExternalPath,
|
||||
};
|
||||
@@ -390,6 +390,76 @@ class FeedbackService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered photos based on feedback criteria
|
||||
* @param {number} eventId - Event ID
|
||||
* @param {string} guestIdentifier - Guest identifier
|
||||
* @param {object} filters - Filter criteria
|
||||
* @param {boolean} filters.liked - Include liked photos
|
||||
* @param {boolean} filters.favorited - Include favorited photos
|
||||
* @param {string} filters.operator - 'AND' or 'OR' for multiple filters
|
||||
* @returns {Promise<number[]>} Array of photo IDs that match criteria
|
||||
*/
|
||||
async getFilteredPhotos(eventId, guestIdentifier, filters = {}) {
|
||||
try {
|
||||
const { liked, favorited, operator = 'OR' } = filters;
|
||||
|
||||
// If no filters specified, return all photos
|
||||
if (!liked && !favorited) {
|
||||
const allPhotos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id');
|
||||
return allPhotos.map(p => p.id);
|
||||
}
|
||||
|
||||
// Build query based on filters
|
||||
let query = db('photo_feedback')
|
||||
.where('event_id', eventId)
|
||||
.where('guest_identifier', guestIdentifier)
|
||||
.where('is_hidden', false);
|
||||
|
||||
// Apply filter logic
|
||||
if (operator === 'AND' && liked && favorited) {
|
||||
// For AND operation, we need photos that have both types of feedback
|
||||
const likedPhotos = await db('photo_feedback')
|
||||
.where('event_id', eventId)
|
||||
.where('guest_identifier', guestIdentifier)
|
||||
.where('feedback_type', 'like')
|
||||
.where('is_hidden', false)
|
||||
.select('photo_id');
|
||||
|
||||
const favoritedPhotos = await db('photo_feedback')
|
||||
.where('event_id', eventId)
|
||||
.where('guest_identifier', guestIdentifier)
|
||||
.where('feedback_type', 'favorite')
|
||||
.where('is_hidden', false)
|
||||
.select('photo_id');
|
||||
|
||||
const likedIds = new Set(likedPhotos.map(p => p.photo_id));
|
||||
const favoritedIds = new Set(favoritedPhotos.map(p => p.photo_id));
|
||||
|
||||
// Return intersection of both sets
|
||||
return Array.from(likedIds).filter(id => favoritedIds.has(id));
|
||||
} else {
|
||||
// OR operation or single filter
|
||||
const feedbackTypes = [];
|
||||
if (liked) feedbackTypes.push('like');
|
||||
if (favorited) feedbackTypes.push('favorite');
|
||||
|
||||
query.whereIn('feedback_type', feedbackTypes);
|
||||
}
|
||||
|
||||
const filteredPhotos = await query
|
||||
.distinct('photo_id')
|
||||
.select('photo_id');
|
||||
|
||||
return filteredPhotos.map(p => p.photo_id);
|
||||
} catch (error) {
|
||||
logger.error('Error getting filtered photos:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackService();
|
||||
@@ -132,7 +132,8 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
|
||||
const msg = (error && error.message) ? error.message : String(error);
|
||||
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`);
|
||||
|
||||
// Clean up any partially created file
|
||||
try {
|
||||
@@ -171,8 +172,18 @@ async function isThumbnailValid(thumbnailPath) {
|
||||
* Regenerate thumbnail if it's broken or missing
|
||||
*/
|
||||
async function ensureThumbnail(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
const { db } = require('../database/db');
|
||||
const { resolvePhotoFilePath } = require('./photoResolver');
|
||||
let originalPath;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
originalPath = resolvePhotoFilePath(event, photo);
|
||||
logger.info(`Ensuring thumbnail for photo ${photo.id} from source: ${originalPath}`);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original path for thumbnail (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if thumbnail exists and is valid
|
||||
if (photo.thumbnail_path) {
|
||||
|
||||
@@ -7,9 +7,32 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (typeof files === 'object') {
|
||||
return Object.values(files)
|
||||
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
|
||||
const uploadedPhotos = [];
|
||||
|
||||
const fileList = normalizeFiles(files);
|
||||
|
||||
if (fileList.length === 0) {
|
||||
return uploadedPhotos;
|
||||
}
|
||||
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
@@ -17,7 +40,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
}
|
||||
|
||||
// Process each file
|
||||
for (const file of files) {
|
||||
for (const file of fileList) {
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
@@ -35,8 +58,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
counter = (existingCount.count || 0) + 1;
|
||||
|
||||
const existingCountValue = Number(existingCount?.count ?? 0);
|
||||
counter = existingCountValue + 1;
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
@@ -53,9 +77,24 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
const newPath = path.join(destPath, newFilename);
|
||||
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
|
||||
|
||||
if (!tempPath) {
|
||||
throw new Error('Uploaded file is missing a temporary path');
|
||||
}
|
||||
|
||||
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
||||
await fs.copyFile(file.path, newPath);
|
||||
await fs.unlink(file.path);
|
||||
try {
|
||||
await fs.copyFile(tempPath, newPath);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (unlinkErr) {
|
||||
if (unlinkErr?.code !== 'ENOENT') {
|
||||
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(newPath);
|
||||
@@ -66,15 +105,46 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
const [photoId] = await trx('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size
|
||||
});
|
||||
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
|
||||
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'
|
||||
})
|
||||
.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'
|
||||
});
|
||||
}
|
||||
|
||||
const insertedId = Array.isArray(insertResult)
|
||||
? (insertResult[0]?.id ?? insertResult[0])
|
||||
: insertResult;
|
||||
|
||||
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
|
||||
|
||||
if (photoId === undefined || photoId === null) {
|
||||
throw new Error('Failed to determine inserted photo ID');
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
await trx.commit();
|
||||
|
||||
@@ -96,4 +166,4 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
|
||||
module.exports = {
|
||||
processUploadedPhotos
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const path = require('path');
|
||||
const { resolveExternalPath } = require('./externalMediaService');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Resolve absolute photo file path based on event + photo origin
|
||||
* Managed: storage/events/active + photo.path (legacy variants supported)
|
||||
* External reference: EXTERNAL_MEDIA_ROOT + event.external_path + photo.external_relpath
|
||||
*/
|
||||
function resolvePhotoFilePath(event, photo) {
|
||||
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
||||
|
||||
const isExternal = photo.source_origin === 'external' ||
|
||||
(!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external'));
|
||||
|
||||
if (isExternal) {
|
||||
if (!photo.external_relpath) {
|
||||
throw new Error('Missing external_relpath for external photo');
|
||||
}
|
||||
// Normalize duplicate leaf segments (e.g., event.external_path ends with 'individual'
|
||||
// and external_relpath starts with 'individual/') to avoid double segment like
|
||||
// '/external-media/.../individual/individual/file.jpg'
|
||||
let rel = photo.external_relpath;
|
||||
try {
|
||||
const lastSeg = path.basename(event.external_path || '');
|
||||
const firstSeg = rel.split(path.sep)[0];
|
||||
if (lastSeg && firstSeg && lastSeg === firstSeg) {
|
||||
rel = rel.split(path.sep).slice(1).join(path.sep) || '';
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore normalization errors
|
||||
}
|
||||
return resolveExternalPath(event, rel);
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const eventsRoot = path.join(storagePath, 'events/active');
|
||||
|
||||
if (photo.path && photo.path.startsWith('events/active/')) {
|
||||
// Legacy paths already include prefix; normalize via safe join
|
||||
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
|
||||
}
|
||||
|
||||
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
|
||||
return safePathJoin(eventsRoot, relativeSegment);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolvePhotoFilePath,
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
const crypto = require('crypto');
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const {
|
||||
DEFAULT_PUBLIC_SITE_TITLE,
|
||||
DEFAULT_PUBLIC_SITE_HTML,
|
||||
DEFAULT_PUBLIC_SITE_CSS,
|
||||
} = require('../constants/publicSiteDefaults');
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000);
|
||||
|
||||
let cachedPayload = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const ALLOWED_HTML_TAGS = [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div',
|
||||
'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span',
|
||||
'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
|
||||
'ul'
|
||||
];
|
||||
|
||||
const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden'];
|
||||
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublicSiteSettings() {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'general_public_site_enabled',
|
||||
'general_public_site_html',
|
||||
'general_public_site_custom_css'
|
||||
]);
|
||||
|
||||
const map = {
|
||||
general_public_site_enabled: false,
|
||||
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||
general_public_site_custom_css: ''
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parsed = parseSettingValue(row.setting_value);
|
||||
map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed;
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function sanitizeBrandUrl(url) {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith('javascript:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function fetchBrandingContext() {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_company_name',
|
||||
'branding_company_tagline',
|
||||
'branding_support_email',
|
||||
'branding_logo_url',
|
||||
'branding_footer_text',
|
||||
'theme_config'
|
||||
]);
|
||||
|
||||
const context = {
|
||||
companyName: null,
|
||||
companyTagline: null,
|
||||
supportEmail: null,
|
||||
logoUrl: null,
|
||||
footerText: null,
|
||||
colors: {
|
||||
primary: '#16a34a',
|
||||
accent: '#0f766e',
|
||||
background: '#f4fbf6',
|
||||
text: '#0f172a'
|
||||
}
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parsed = parseSettingValue(row.setting_value);
|
||||
switch (row.setting_key) {
|
||||
case 'branding_company_name':
|
||||
context.companyName = parsed || context.companyName;
|
||||
break;
|
||||
case 'branding_company_tagline':
|
||||
context.companyTagline = parsed || context.companyTagline;
|
||||
break;
|
||||
case 'branding_support_email':
|
||||
context.supportEmail = parsed || context.supportEmail;
|
||||
break;
|
||||
case 'branding_logo_url':
|
||||
context.logoUrl = sanitizeBrandUrl(parsed);
|
||||
break;
|
||||
case 'branding_footer_text':
|
||||
context.footerText = parsed || context.footerText;
|
||||
break;
|
||||
case 'theme_config': {
|
||||
try {
|
||||
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
||||
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
||||
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
||||
context.colors.text = themeConfig.textColor || context.colors.text;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function sanitizeHtmlPayload(html) {
|
||||
const sanitized = sanitizeHtml(html || '', {
|
||||
allowedTags: ALLOWED_HTML_TAGS,
|
||||
allowedAttributes: {
|
||||
'*': COMMON_ATTRIBUTES,
|
||||
a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES],
|
||||
button: ['type', ...COMMON_ATTRIBUTES]
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => {
|
||||
const transformed = { ...attribs };
|
||||
if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) {
|
||||
// sanitize-html will remove disallowed schemes, but we guard as well
|
||||
delete transformed.href;
|
||||
}
|
||||
|
||||
if (transformed.target === '_blank') {
|
||||
transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer';
|
||||
}
|
||||
|
||||
return { tagName, attribs: transformed };
|
||||
}
|
||||
},
|
||||
nonBooleanAttributes: ['target'],
|
||||
parser: {
|
||||
lowerCaseAttributeNames: true
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function buildCachedPayload(raw) {
|
||||
const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML);
|
||||
const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || '');
|
||||
const enabled = Boolean(raw.publicSite.general_public_site_enabled);
|
||||
const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE;
|
||||
const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS);
|
||||
|
||||
const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding);
|
||||
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`)
|
||||
.digest('hex');
|
||||
|
||||
return {
|
||||
enabled,
|
||||
html: substitutedHtml,
|
||||
css: sanitizedCss,
|
||||
baseCss,
|
||||
title,
|
||||
branding: raw.branding,
|
||||
etag: `W/"${hash}"`
|
||||
};
|
||||
}
|
||||
|
||||
async function getPublicSitePayload({ bypassCache = false } = {}) {
|
||||
if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) {
|
||||
return cachedPayload;
|
||||
}
|
||||
|
||||
const [publicSite, branding] = await Promise.all([
|
||||
fetchPublicSiteSettings(),
|
||||
fetchBrandingContext()
|
||||
]);
|
||||
|
||||
const payload = buildCachedPayload({ publicSite, branding });
|
||||
|
||||
cachedPayload = payload;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearPublicSiteCache() {
|
||||
cachedPayload = null;
|
||||
cacheExpiresAt = 0;
|
||||
}
|
||||
|
||||
async function getDefaultPublicSitePayload() {
|
||||
const branding = await fetchBrandingContext();
|
||||
return buildCachedPayload({
|
||||
publicSite: {
|
||||
general_public_site_enabled: false,
|
||||
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||
general_public_site_custom_css: ''
|
||||
},
|
||||
branding
|
||||
});
|
||||
}
|
||||
|
||||
async function getRawPublicSiteSettings() {
|
||||
return fetchPublicSiteSettings();
|
||||
}
|
||||
|
||||
function applyBrandTokens(html, branding) {
|
||||
if (!html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
|
||||
brand_primary_hex: branding.colors?.primary || '#2563eb',
|
||||
brand_accent_hex: branding.colors?.accent || '#1d4ed8',
|
||||
brand_background_hex: branding.colors?.background || '#f8fafc',
|
||||
brand_text_hex: branding.colors?.text || '#0f172a'
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||
(_, key) => tokens[key] || '');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPublicSitePayload,
|
||||
clearPublicSiteCache,
|
||||
getDefaultPublicSitePayload,
|
||||
getRawPublicSiteSettings
|
||||
};
|
||||
@@ -2,6 +2,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
// Cache for rate limit settings
|
||||
let settingsCache = null;
|
||||
@@ -95,12 +96,9 @@ function clearSettingsCache() {
|
||||
*/
|
||||
function isAuthenticated(req) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if token is valid
|
||||
@@ -280,4 +278,4 @@ module.exports = {
|
||||
createAuthRateLimiter,
|
||||
isAuthenticated,
|
||||
shouldSkipRateLimit
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const S3StorageAdapter = require('../s3Storage');
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { S3Client, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs');
|
||||
const stream = require('stream');
|
||||
@@ -24,6 +24,10 @@ describe('S3StorageAdapter', () => {
|
||||
send: mockSend
|
||||
};
|
||||
S3Client.mockImplementation(() => mockS3Client);
|
||||
|
||||
HeadBucketCommand.mockImplementation((input) => ({ input }));
|
||||
HeadObjectCommand.mockImplementation((input) => ({ input }));
|
||||
ListObjectsV2Command.mockImplementation((input) => ({ input }));
|
||||
|
||||
// Create adapter instance
|
||||
s3Storage = new S3StorageAdapter({
|
||||
@@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => {
|
||||
const result = await s3Storage.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket' }
|
||||
})
|
||||
);
|
||||
expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' });
|
||||
});
|
||||
|
||||
it('should throw error on connection failure', async () => {
|
||||
@@ -132,24 +132,25 @@ describe('S3StorageAdapter', () => {
|
||||
|
||||
it('should track upload progress', async () => {
|
||||
const onProgress = jest.fn();
|
||||
let progressCallback;
|
||||
|
||||
mockUpload.on.mockImplementation((event, callback) => {
|
||||
if (event === 'httpUploadProgress') {
|
||||
progressCallback = callback;
|
||||
}
|
||||
return mockUpload;
|
||||
Upload.mockImplementation(() => {
|
||||
const uploadInstance = {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'httpUploadProgress') {
|
||||
handler({ loaded: 512, total: 1024 });
|
||||
}
|
||||
return uploadInstance;
|
||||
}),
|
||||
done: mockDone
|
||||
};
|
||||
return uploadInstance;
|
||||
});
|
||||
|
||||
|
||||
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
||||
onProgress
|
||||
});
|
||||
|
||||
// Simulate progress
|
||||
progressCallback({ loaded: 512, total: 1024 });
|
||||
|
||||
|
||||
await uploadPromise;
|
||||
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
||||
});
|
||||
|
||||
@@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => {
|
||||
const result = await s3Storage.exists('test-key');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { Bucket: 'test-bucket', Key: 'test-key' }
|
||||
})
|
||||
);
|
||||
expect(HeadObjectCommand).toHaveBeenCalledWith({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'test-key'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false if object does not exist', async () => {
|
||||
@@ -220,58 +220,51 @@ describe('S3StorageAdapter', () => {
|
||||
it('should retry on retryable errors', async () => {
|
||||
const retryableError = new Error('Connection reset');
|
||||
retryableError.code = 'ECONNRESET';
|
||||
|
||||
// First attempt fails, second succeeds
|
||||
mockSend
|
||||
|
||||
const operation = jest.fn()
|
||||
.mockRejectedValueOnce(retryableError)
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
|
||||
// Advance timers
|
||||
jest.runAllTimers();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
||||
|
||||
jest.useRealTimers();
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const originalRandom = Math.random;
|
||||
const originalDelay = s3Storage.config.retryDelay;
|
||||
Math.random = jest.fn(() => 0);
|
||||
s3Storage.config.retryDelay = 0;
|
||||
|
||||
const result = await s3Storage._retryOperation(operation);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
|
||||
Math.random = originalRandom;
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const nonRetryableError = new Error('Invalid credentials');
|
||||
nonRetryableError.code = 'InvalidCredentials';
|
||||
|
||||
mockSend.mockRejectedValueOnce(nonRetryableError);
|
||||
|
||||
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const operation = jest.fn().mockRejectedValueOnce(nonRetryableError);
|
||||
|
||||
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials');
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop retrying after max attempts', async () => {
|
||||
const retryableError = new Error('Service unavailable');
|
||||
retryableError.code = 'ServiceUnavailable';
|
||||
|
||||
mockSend.mockRejectedValue(retryableError);
|
||||
|
||||
// Mock setTimeout to speed up test
|
||||
jest.useFakeTimers();
|
||||
|
||||
const promise = s3Storage.exists('test-key');
|
||||
|
||||
// Advance timers for all retries
|
||||
for (let i = 0; i < 4; i++) {
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
await expect(promise).rejects.toThrow('Service unavailable');
|
||||
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
||||
|
||||
jest.useRealTimers();
|
||||
const operation = jest.fn().mockRejectedValue(retryableError);
|
||||
|
||||
const originalRandom = Math.random;
|
||||
const originalDelay = s3Storage.config.retryDelay;
|
||||
Math.random = jest.fn(() => 0);
|
||||
s3Storage.config.retryDelay = 0;
|
||||
|
||||
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Service unavailable');
|
||||
expect(operation).toHaveBeenCalledTimes(4); // initial + 3 retries
|
||||
|
||||
Math.random = originalRandom;
|
||||
s3Storage.config.retryDelay = originalDelay;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,4 +301,4 @@ describe('S3StorageAdapter', () => {
|
||||
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
function sanitizeCss(css) {
|
||||
if (!css || typeof css !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeCss,
|
||||
};
|
||||
@@ -227,11 +227,17 @@ async function validateGuestRequirements(settings, guestData) {
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!guestData.guest_name || guestData.guest_name.trim().length === 0) {
|
||||
// Check for name - handle both undefined and empty strings
|
||||
const name = guestData.guest_name;
|
||||
if (!name || (typeof name === 'string' && name.trim().length === 0)) {
|
||||
errors.push('Name is required');
|
||||
}
|
||||
|
||||
if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) {
|
||||
// Check for email - handle both undefined and empty strings
|
||||
const email = guestData.guest_email;
|
||||
if (!email || (typeof email === 'string' && email.trim().length === 0)) {
|
||||
errors.push('Email is required');
|
||||
} else if (email && typeof email === 'string' && !validator.isEmail(email.trim())) {
|
||||
errors.push('Valid email is required');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
const ADMIN_COOKIE_NAME = 'admin_token';
|
||||
const GALLERY_COOKIE_NAME = 'gallery_token';
|
||||
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
||||
|
||||
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const secureCookie = (() => {
|
||||
if (typeof process.env.COOKIE_SECURE === 'string') {
|
||||
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
|
||||
}
|
||||
// Default to false so native HTTP installs stay functional. Operators can
|
||||
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
|
||||
return false;
|
||||
})();
|
||||
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
function buildCookieBaseOptions() {
|
||||
const options = {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: sameSiteDefault,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
if (cookieDomain) {
|
||||
options.domain = cookieDomain;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
||||
return {
|
||||
...buildCookieBaseOptions(),
|
||||
maxAge: maxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeSlugForCookie(slug = '') {
|
||||
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function setAdminAuthCookie(res, token) {
|
||||
if (!token) return;
|
||||
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
|
||||
}
|
||||
|
||||
function clearAdminAuthCookie(res) {
|
||||
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
|
||||
}
|
||||
|
||||
function setGalleryAuthCookies(res, token, slug) {
|
||||
if (!token) return;
|
||||
const options = buildCookieOptionsWithExpiry();
|
||||
res.cookie(GALLERY_COOKIE_NAME, token, options);
|
||||
if (slug) {
|
||||
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
res.cookie(cookieName, token, options);
|
||||
}
|
||||
}
|
||||
|
||||
function clearGalleryAuthCookies(res, slug) {
|
||||
const baseOptions = buildCookieBaseOptions();
|
||||
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
|
||||
|
||||
const cookies = res.req?.cookies || {};
|
||||
|
||||
if (slug) {
|
||||
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
res.clearCookie(cookieName, baseOptions);
|
||||
} else {
|
||||
Object.keys(cookies).forEach((name) => {
|
||||
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
|
||||
res.clearCookie(name, baseOptions);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminTokenFromRequest(req) {
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
return header.substring(7);
|
||||
}
|
||||
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
||||
}
|
||||
|
||||
function getGalleryTokenFromRequest(req, slug) {
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
return header.substring(7);
|
||||
}
|
||||
|
||||
if (!req.cookies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||
if (req.cookies[cookieName]) {
|
||||
return req.cookies[cookieName];
|
||||
}
|
||||
}
|
||||
|
||||
if (req.cookies[GALLERY_COOKIE_NAME]) {
|
||||
return req.cookies[GALLERY_COOKIE_NAME];
|
||||
}
|
||||
|
||||
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GALLERY_COOKIE_PREFIX));
|
||||
if (prefixed) {
|
||||
return req.cookies[prefixed];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADMIN_COOKIE_NAME,
|
||||
GALLERY_COOKIE_NAME,
|
||||
GALLERY_COOKIE_PREFIX,
|
||||
sanitizeSlugForCookie,
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
setGalleryAuthCookies,
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Test script for filter functionality
|
||||
const axios = require('axios');
|
||||
|
||||
const API_URL = 'http://localhost:3001/api';
|
||||
const TEST_SLUG = 'wedding-test-feedback-event-2025-09-02';
|
||||
const TEST_PASSWORD = 'StrongTiger3610%';
|
||||
|
||||
async function testFilterFunctionality() {
|
||||
try {
|
||||
console.log('Testing filter functionality...\n');
|
||||
|
||||
// 1. Authenticate to get JWT token
|
||||
console.log('1. Authenticating with gallery...');
|
||||
const authResponse = await axios.post(`${API_URL}/auth/gallery-login`, {
|
||||
slug: TEST_SLUG,
|
||||
password: TEST_PASSWORD
|
||||
});
|
||||
|
||||
const token = authResponse.data.token;
|
||||
console.log('✅ Authentication successful\n');
|
||||
|
||||
// 2. Test fetching all photos (no filter)
|
||||
console.log('2. Fetching all photos (no filter)...');
|
||||
const allPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log(`✅ Found ${allPhotosResponse.data.photos.length} total photos\n`);
|
||||
|
||||
// 3. Test fetching with liked filter
|
||||
console.log('3. Testing filter for liked photos...');
|
||||
const guestId = 'test_guest_123';
|
||||
const likedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
|
||||
params: { filter: 'liked', guest_id: guestId },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log(`✅ Found ${likedPhotosResponse.data.photos.length} liked photos for guest ${guestId}\n`);
|
||||
|
||||
// 4. Test fetching with favorited filter
|
||||
console.log('4. Testing filter for favorited photos...');
|
||||
const favoritedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
|
||||
params: { filter: 'favorited', guest_id: guestId },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log(`✅ Found ${favoritedPhotosResponse.data.photos.length} favorited photos for guest ${guestId}\n`);
|
||||
|
||||
// 5. Test combined filter
|
||||
console.log('5. Testing combined filter (liked OR favorited)...');
|
||||
const combinedPhotosResponse = await axios.get(`${API_URL}/gallery/${TEST_SLUG}/photos`, {
|
||||
params: { filter: 'liked,favorited', guest_id: guestId },
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log(`✅ Found ${combinedPhotosResponse.data.photos.length} photos that are liked OR favorited\n`);
|
||||
|
||||
console.log('🎉 All filter tests passed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error.response?.data || error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testFilterFunctionality();
|
||||
@@ -3,19 +3,45 @@
|
||||
|
||||
set -e
|
||||
|
||||
host="$DB_HOST"
|
||||
host="${DB_HOST:-postgres}"
|
||||
port="${DB_PORT:-5432}"
|
||||
user="${DB_USER:-picpeak}"
|
||||
target_db="${DB_NAME:-picpeak}"
|
||||
default_db="${DB_CHECK_DB:-postgres}"
|
||||
|
||||
sanitize_identifier() {
|
||||
printf '%s' "$1" | sed "s/'/''/g"
|
||||
}
|
||||
|
||||
echo "Waiting for PostgreSQL at $host:$port..."
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
|
||||
# Wait for PostgreSQL server to accept connections (using the default database)
|
||||
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do
|
||||
>&2 echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
>&2 echo "PostgreSQL is up - executing command"
|
||||
>&2 echo "PostgreSQL is up - verifying target database \"$target_db\""
|
||||
|
||||
# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised)
|
||||
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
|
||||
|
||||
if [ "$db_exists" != "1" ]; then
|
||||
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
|
||||
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
|
||||
>&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible."
|
||||
exit 1
|
||||
fi
|
||||
>&2 echo "Database \"$target_db\" created successfully."
|
||||
fi
|
||||
|
||||
# Wait until the target database itself is ready to accept connections
|
||||
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do
|
||||
>&2 echo "Waiting for database \"$target_db\" to accept connections..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
>&2 echo "Target database \"$target_db\" is ready."
|
||||
|
||||
# Run migrations (use safe runner in production)
|
||||
echo "Running database migrations..."
|
||||
@@ -26,4 +52,4 @@ else
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: picpeak-postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-picpeak}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME:-picpeak}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- picpeak-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: picpeak-redis
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- picpeak-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
container_name: picpeak-backend
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- REDIS_HOST=redis
|
||||
- PHOTOS_DIR=/app/storage/events
|
||||
volumes:
|
||||
- ${APP_STORAGE}:/app/storage
|
||||
- ${LOGS}:/app/logs
|
||||
- ${APP_DATA}:/app/data
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3000"
|
||||
networks:
|
||||
- picpeak-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Backend exposes /health on internal port 3000
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
container_name: picpeak-frontend
|
||||
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
|
||||
# Prefer keeping API base as '/api' in builds to avoid CORS.
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
networks:
|
||||
- picpeak-network
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Optional: Nginx reverse proxy for production with SSL
|
||||
# Uncomment and configure if you want built-in HTTPS support
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# container_name: picpeak-nginx
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
# - ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
# - ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
# networks:
|
||||
# - picpeak-network
|
||||
# depends_on:
|
||||
# - frontend
|
||||
# - backend
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
picpeak-network:
|
||||
driver: bridge
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- DATABASE_CLIENT=pg
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
|
||||
- DB_TYPE=postgresql
|
||||
@@ -19,6 +20,7 @@ services:
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE:-false}
|
||||
@@ -29,6 +31,11 @@ services:
|
||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||
- TZ=${TZ:-UTC}
|
||||
- STORAGE_PATH=/app/storage
|
||||
# Optional: run container as matching host user to avoid bind mount permission issues
|
||||
- PUID=${PUID:-1001}
|
||||
- PGID=${PGID:-1001}
|
||||
# Use host-matching user ID/GID so bind-mounted folders are writable
|
||||
user: "${PUID:-1001}:${PGID:-1001}"
|
||||
volumes:
|
||||
- ./events:/app/events
|
||||
- ./data:/app/data
|
||||
@@ -94,7 +101,7 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||
@@ -122,4 +129,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
picpeak-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
||||
- Check the console output from when you ran migrations
|
||||
- File is created in the backend directory root
|
||||
- File might have been deleted for security (as recommended)
|
||||
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
|
||||
- [ ] Stored new password in password manager
|
||||
- [ ] Tested login with new password
|
||||
- [ ] Set up additional admin accounts if needed
|
||||
- [ ] Configured password policies for organization
|
||||
- [ ] Configured password policies for organization
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION
|
||||
|
||||
# Add labels for GitHub Container Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -19,5 +19,28 @@ export default tseslint.config([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-useless-escape': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-debugger': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -4,6 +4,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Allow larger file uploads (up to 100MB)
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -49,6 +53,10 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
# Allow larger uploads for API endpoints
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
}
|
||||
|
||||
# Photo serving proxy
|
||||
@@ -93,6 +101,19 @@ server {
|
||||
proxy_cache_valid 404 1m;
|
||||
}
|
||||
|
||||
# Delegate root requests to backend for public landing page handling
|
||||
location = / {
|
||||
proxy_pass http://backend:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -102,4 +123,4 @@ server {
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.103",
|
||||
"version": "1.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"build": "cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||
"build:check": "tsc -b && cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
@@ -23,14 +24,14 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.3.2",
|
||||
"axios": "^1.12.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "0.525.0",
|
||||
@@ -47,19 +48,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.5.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
AnalyticsPage,
|
||||
BrandingPage,
|
||||
SettingsPage,
|
||||
CMSPage,
|
||||
BackupManagement
|
||||
} from './pages/admin';
|
||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||
|
||||
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
try {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Return empty object if settings can't be fetched
|
||||
return {};
|
||||
}
|
||||
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MaintenanceMode } from './MaintenanceMode';
|
||||
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
||||
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
|
||||
import { setMaintenanceModeCallback, api } from '../config/api';
|
||||
|
||||
interface MaintenanceWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,10 +12,38 @@ interface MaintenanceWrapperProps {
|
||||
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
||||
const [hasAdminSession, setHasAdminSession] = useState(false);
|
||||
|
||||
// Check if current route is admin route
|
||||
const isAdminRoute = location.pathname.startsWith('/admin');
|
||||
const hasAdminAuth = !!getAuthToken(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const checkAdminSession = async () => {
|
||||
if (!isAdminRoute) {
|
||||
setHasAdminSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
|
||||
if (isMounted) {
|
||||
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setHasAdminSession(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAdminSession();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isAdminRoute]);
|
||||
|
||||
// Register the maintenance mode callback
|
||||
useEffect(() => {
|
||||
@@ -37,7 +65,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 503) {
|
||||
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
|
||||
if (!isAdminRoute || !hasAdminAuth) {
|
||||
if (!isAdminRoute || !hasAdminSession) {
|
||||
setMaintenanceMode(true);
|
||||
return { maintenance: true };
|
||||
}
|
||||
@@ -47,13 +75,13 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
},
|
||||
staleTime: 30000, // Check every 30 seconds
|
||||
retry: false, // Don't retry on failure
|
||||
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
|
||||
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
|
||||
});
|
||||
|
||||
// Show maintenance page if in maintenance mode and not on admin route with auth
|
||||
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
|
||||
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
|
||||
return <MaintenanceMode />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setImageSrc(null);
|
||||
|
||||
// Make authenticated request to get the image
|
||||
const response = await api.get(src, {
|
||||
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
if (!cancelled) {
|
||||
// Create object URL from blob
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(imageUrl);
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
// Cleanup function
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
}
|
||||
|
||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||