diff --git a/.env.example b/.env.example index 9fd62522..656ffeb1 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,34 @@ NODE_ENV=production # JWT Secret (generate with: openssl rand -base64 64) JWT_SECRET=your_very_long_random_jwt_secret_here +# Auth cookie Secure flag +# unset - default: follows NODE_ENV (production=true, dev=false) +# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) +# false - never set Secure (allows HTTP; cookies not protected on HTTPS) +# auto - decide per request: Secure on HTTPS, not on HTTP +# +# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS +# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain +# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads +# req.secure from Express, which respects the X-Forwarded-Proto header +# when the proxy is in the trust list. +# +# Requirements for auto mode: +# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS +# requests. Standard configs for NPM/Traefik/Caddy do this by default. +# 2. The proxy must be on a trusted IP range. By default PicPeak trusts +# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x, +# 192.168.x, link-local). Proxies outside those ranges need custom +# trust proxy configuration. +# COOKIE_SECURE=auto + +# Cookie SameSite attribute (Lax | Strict | None). Default: Lax +# COOKIE_SAMESITE=Lax + +# Cookie Domain — set this if serving auth cookies across subdomains. +# Leave unset for same-origin setups. +# COOKIE_DOMAIN=.example.com + # Database Configuration (PostgreSQL) DATABASE_CLIENT=pg DB_USER=picpeak @@ -86,6 +114,85 @@ APP_STORAGE=./storage APP_DATA=./data LOGS=./logs +# ─── Storage Backend ──────────────────────────────────────────────────────── +# PicPeak can store photos, thumbnails and archive zips on the local filesystem +# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2, +# Backblaze B2, Wasabi, DigitalOcean Spaces, …). +# +# STORAGE_BACKEND=local (default) +# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every +# existing deployment keeps working unchanged. +# +# STORAGE_BACKEND=s3 +# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is +# disabled in this mode (S3 has no inotify) — every photo must enter via the +# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy +# existing local content to S3 before flipping the env. +# +# STORAGE_BACKEND=local +# +# STORAGE_S3_BUCKET=picpeak +# STORAGE_S3_REGION=us-east-1 +# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx +# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx +# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS. +# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com +# Optional namespace prefix inside the bucket — useful for multi-deployment buckets. +# STORAGE_S3_PREFIX=picpeak +# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set +# STORAGE_S3_SSL=true +# +# Minimum IAM policy (AWS S3) for the bucket above: +# { +# "Version": "2012-10-17", +# "Statement": [{ +# "Effect": "Allow", +# "Action": [ +# "s3:GetObject", "s3:PutObject", "s3:DeleteObject", +# "s3:ListBucket", "s3:GetBucketLocation" +# ], +# "Resource": [ +# "arn:aws:s3:::picpeak", +# "arn:aws:s3:::picpeak/*" +# ] +# }] +# } +# +# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless +# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1. + +# ─── Outbound Webhooks (#327) ──────────────────────────────────────────────── +# PicPeak POSTs event/photo lifecycle notifications to URLs you configure +# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a +# per-webhook secret in the X-PicPeak-Signature header. +# +# WEBHOOK_ALLOW_PRIVATE_URLS (default: false) +# Block URLs resolving to private IPs / loopback / .local etc. as an +# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on +# the same docker network or localhost. Production deployments must +# leave this OFF. +# WEBHOOK_ALLOW_PRIVATE_URLS=false +# +# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000) +# How often the worker polls webhook_deliveries for pending rows. +# WEBHOOK_DELIVERY_INTERVAL_MS=5000 +# +# WEBHOOK_DELIVERY_CONCURRENCY (default: 5) +# Maximum in-flight deliveries per worker tick. One slow consumer can +# monopolize all 5 slots — bump this if your receivers are slow OR ship +# a separate webhook-only deployment. +# WEBHOOK_DELIVERY_CONCURRENCY=5 +# +# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000) +# Per-request timeout. Beyond this, the delivery is recorded as a +# network error and retried. +# WEBHOOK_HTTP_TIMEOUT_MS=10000 +# +# WEBHOOK_MAX_ATTEMPTS (default: 5) +# Total attempts before a delivery is marked failed. Backoff between +# attempts is exponential: 1m, 5m, 30m, 2h, 12h. +# WEBHOOK_MAX_ATTEMPTS=5 + # 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 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7a87dbb2..4e273b96 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,10 +1,22 @@ name: Build and Push Docker Images # This workflow is triggered by: -# - Push to main/develop branches (builds 'latest' or branch-tagged images) +# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images) # - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images) # - GitHub Releases (created by Release Please) +# - Pull requests (build verification only, no push by default) # - Manual workflow dispatch +# +# Multi-arch strategy: +# Each image (backend, frontend) is built once per architecture on a +# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on +# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up +# merge job combines the digests into a multi-arch manifest and applies +# the human-readable tags. This is the pattern documented at +# https://docs.docker.com/build/ci/github-actions/multi-platform/ +# +# Native runners are used instead of QEMU because npm install under +# QEMU was previously too slow/unreliable for regular branch builds. on: push: @@ -27,51 +39,44 @@ on: env: REGISTRY: ghcr.io - BACKEND_IMAGE_NAME: ${{ github.repository }}/backend - FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend + # BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the + # "Compute image names" step. GHCR requires all-lowercase repository names, + # but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/..."). + # Computing them with bash parameter expansion (${VAR,,}) keeps the workflow + # working on forks regardless of the owner's name casing. jobs: + # ----------------------------------------------------------------------------- + # Backend: per-arch build, then merge into a multi-arch manifest + # ----------------------------------------------------------------------------- build-backend: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write - security-events: write steps: - name: Checkout code uses: actions/checkout@v4 - - name: Determine build context - id: context + - name: Compute image names (lowercase for GHCR) run: | - # Determine if this is a beta or stable release - if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then - echo "channel=beta" >> $GITHUB_OUTPUT - echo "is_prerelease=true" >> $GITHUB_OUTPUT - else - echo "channel=stable" >> $GITHUB_OUTPUT - echo "is_prerelease=false" >> $GITHUB_OUTPUT - fi + repo_lc="${GITHUB_REPOSITORY,,}" + echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" - - name: Determine build platforms - id: platforms + - name: Prepare platform pair run: | - # Only build ARM64 for tagged releases (v*.*.*) - # QEMU emulation is too slow/unreliable for npm operations on regular builds - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then - echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT - echo "skip_qemu=false" >> $GITHUB_OUTPUT - else - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "skip_qemu=true" >> $GITHUB_OUTPUT - fi - - - name: Set up QEMU - if: steps.platforms.outputs.skip_qemu != 'true' - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -86,6 +91,108 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Determine if pushing + id: push-decision + run: | + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + else + echo "push=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract metadata for Backend (labels only) + 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 }} + + - name: Build Backend image (push by digest) + id: build + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }} + outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }} + build-args: | + CACHEBUST=${{ github.run_number }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta-backend.outputs.version }} + + - name: Export digest + if: steps.push-decision.outputs.push == 'true' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: steps.push-decision.outputs.push == 'true' + uses: actions/upload-artifact@v4 + with: + name: digests-backend-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-backend: + needs: build-backend + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + security-events: write + # Only run when at least one digest was pushed (i.e. not on PRs without push intent). + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + + steps: + - name: Compute image names (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-backend-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + id: login-ghcr + continue-on-error: true + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine build context + id: context + run: | + if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + echo "channel=beta" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "channel=stable" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + - name: Extract metadata for Backend id: meta-backend uses: docker/metadata-action@v5 @@ -107,23 +214,15 @@ jobs: type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} - - name: Build and push Backend Docker image - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/Dockerfile - # 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: ${{ steps.platforms.outputs.platforms }} - 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: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *) + + - name: Inspect manifest + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }} - name: Run Trivy vulnerability scanner if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' @@ -142,47 +241,37 @@ jobs: sarif_file: 'trivy-backend.sarif' category: 'backend-vulnerabilities' + # ----------------------------------------------------------------------------- + # Frontend: per-arch build, then merge into a multi-arch manifest + # ----------------------------------------------------------------------------- build-frontend: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write - security-events: write steps: - name: Checkout code uses: actions/checkout@v4 - - name: Determine build context - id: context + - name: Compute image names (lowercase for GHCR) run: | - # Determine if this is a beta or stable release - if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then - echo "channel=beta" >> $GITHUB_OUTPUT - echo "is_prerelease=true" >> $GITHUB_OUTPUT - else - echo "channel=stable" >> $GITHUB_OUTPUT - echo "is_prerelease=false" >> $GITHUB_OUTPUT - fi + repo_lc="${GITHUB_REPOSITORY,,}" + echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" - - name: Determine build platforms - id: platforms + - name: Prepare platform pair run: | - # Only build ARM64 for tagged releases (v*.*.*) - # QEMU emulation is too slow/unreliable for npm operations on regular builds - if [[ "${{ github.ref }}" == refs/tags/v* ]]; then - echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT - echo "skip_qemu=false" >> $GITHUB_OUTPUT - else - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "skip_qemu=true" >> $GITHUB_OUTPUT - fi - - - name: Set up QEMU - if: steps.platforms.outputs.skip_qemu != 'true' - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -197,6 +286,107 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Determine if pushing + id: push-decision + run: | + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + else + echo "push=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract metadata for Frontend (labels only) + 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 }} + + - name: Build Frontend image (push by digest) + id: build + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta-frontend.outputs.labels }} + cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }} + outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }} + build-args: | + CACHEBUST=${{ github.run_number }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta-frontend.outputs.version }} + + - name: Export digest + if: steps.push-decision.outputs.push == 'true' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: steps.push-decision.outputs.push == 'true' + uses: actions/upload-artifact@v4 + with: + name: digests-frontend-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-frontend: + needs: build-frontend + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + security-events: write + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + + steps: + - name: Compute image names (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-frontend-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + id: login-ghcr + continue-on-error: true + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine build context + id: context + run: | + if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + echo "channel=beta" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "channel=stable" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + - name: Extract metadata for Frontend id: meta-frontend uses: docker/metadata-action@v5 @@ -218,23 +408,15 @@ jobs: type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} - - name: Build and push Frontend Docker image - uses: docker/build-push-action@v5 - with: - context: ./frontend - file: ./frontend/Dockerfile - # 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: ${{ steps.platforms.outputs.platforms }} - 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: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *) + + - name: Inspect manifest + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }} - name: Run Trivy vulnerability scanner if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success' @@ -253,45 +435,69 @@ jobs: 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] + needs: [build-backend, merge-backend, build-frontend, merge-frontend] if: always() runs-on: ubuntu-latest permissions: contents: read - + steps: + - name: Compute image names (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + - 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 + echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY else - echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY + echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY fi - + + if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then + echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then + echo "ℹ️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then - echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY + echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY else - echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY + echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY fi - + + if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then + echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then + echo "ℹ️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $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 "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY + echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $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 "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY - echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY + echo "- Short SHA" >> $GITHUB_STEP_SUMMARY echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY + echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY + echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 931739a7..52f31cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,9 @@ backend/data/ backend/docs/ backend/logs/ logs/ -storage/ +# Anchored to repo root: matches the top-level runtime storage dir, +# NOT backend/src/services/storage/ (the storage backend abstraction code). +/storage/ data/ certbot/ @@ -94,12 +96,24 @@ docs/FRONTEND_ARCHITECTURE.md docs/DEVELOPER_ONBOARDING.md docs/ENVIRONMENT_VARIABLES.md +# Build artifact: OpenAPI spec generated locally + synced into the +# picpeak-docs repo. Never tracked here — the docs site at +# docs.picpeak.app is the source of truth. +docs/openapi.json +docs/openapi.yaml + # Local backup directory (from testing) backup/ # Local artifacts from browser tooling .playwright-mcp/ +# Local-only E2E suite (never pushed; runs as pre-push gate on this machine) +tests/e2e/local/ +playwright-local-results/ +e2e-test.log +scripts/e2e-local.sh + # Local SQLite files in backend backend/*.sqlite* backend/*.db diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 07c082e7..f254ab1a 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.17.1-beta.0" + ".": "3.42.1-beta.0" } diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b5439b8b..094e9dae 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1 @@ -{ - ".": "2.6.5" -} +{ ".": "3.42.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6507ff14..e4aafcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,45 +5,777 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.6.5](https://github.com/the-luap/picpeak/compare/v2.6.4...v2.6.5) (2026-04-08) +## [3.42.1](https://github.com/the-luap/picpeak/compare/v2.6.5...v3.42.1) (2026-05-07) + +Stable release promoting the entire `beta` channel to `main`. Brings ~300 commits of features, fixes, and infrastructure improvements that have been baked on the beta channel since v2.6.5. Highlights below; full per-version notes follow in the beta history. + +### Major themes since v2.6.5 + +* **Multi-administrator support with RBAC** — super admin / admin / editor roles, fine-grained permissions +* **Async upload pipeline** — bytes-on-wire returns 202; sharp/ffmpeg/EXIF/watermark/webhooks happen in a background worker pool +* **Self-hosted webfonts** — filesystem-driven scanner, GDPR-compliant, replaces Google Fonts CDN +* **8-token CI palette + force color mode** — full theme customization across admin and public site +* **Native multi-arch Docker images** — Apple Silicon and ARM64 Linux supported natively +* **Native S3 storage backend** — S3 + S3-compatible providers +* **Comprehensive video support** — upload, stream, and play MP4/WebM/MOV alongside photos +* **Outbound webhooks** — event/photo lifecycle push API with HMAC signatures +* **Gallery layout system** — decoupled header style from layout, banner option, theme-aware skeletons, and lazy-loaded folder picker +* **Multilingual email templates** — translations table for EN/DE/NL/PT/RU +* **Bulk operations** — bulk delete with password confirmation, bulk archive +* **Photo dimensions backfill** — true masonry layout with portrait/landscape sizing +* **Customer client access** — separate review/visibility area before the gallery is shared with guests +* **Image security** — devtools detection, watermarking, right-click prevention, secure thumbnails + +### Bug fixes (highlights from beta) + +* `/auth/session` symmetry fixes for the admin-login redirect-loop family (#350, #355, #363, #398) +* Email template renderer: handle `{{#if}}` conditionals, fix CSS leak in plain-text fallback, gate publish-from-draft password placeholder, gate `external_url` in public response +* Customer email caller/template variable drift across gallery_created, expiration_warning, archive_complete, gallery_expired +* Full-URL `gallery_link` in all email types (was path-only in 3 sites) +* ffmpeg/ffprobe installed via apk for Alpine compatibility (was glibc-bundled binary) +* Theme-aware skeleton tiles, dark theme white-flash on first paint +* Admin events search and counters not bounded to first 100 records (#346) +* Login redirect loop with stale admin cookies (#350) — three rounds of asymmetry fixes + +## [3.42.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.0-beta.0...v3.42.1-beta.0) (2026-05-07) + + +### Bug Fixes + +* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([04e928d](https://github.com/the-luap/picpeak/commit/04e928d7621743d9d99797f0996f8c7aa50e7b2d)) +* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([0c80abd](https://github.com/the-luap/picpeak/commit/0c80abd57b806b9df01429a093c30c12c80c0601)) + +## [3.42.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.41.0-beta.0...v3.42.0-beta.0) (2026-05-07) + + +### Features + +* **gallery:** icon-only menu, accent Download CTA ([#386](https://github.com/the-luap/picpeak/issues/386)) ([876b35b](https://github.com/the-luap/picpeak/commit/876b35b4a512f70cfc19561e35ce9d915a599547)) + +## [3.41.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.40.1-beta.0...v3.41.0-beta.0) (2026-05-06) + + +### Features + +* **branding:** 8-token CI palette + force color mode + dark-mode consistency ([8050927](https://github.com/the-luap/picpeak/commit/80509276074b8125b6d676839afabb0b6f89206f)) +* **branding:** force color mode (dark or light) site-wide ([5a162fc](https://github.com/the-luap/picpeak/commit/5a162fc8bec47a49cb1bcaa92ff72e197e8d2e42)) +* **branding:** inline force color mode with auto-save + clearer palette help text ([67d7d8d](https://github.com/the-luap/picpeak/commit/67d7d8d3fa25ceab0eda02b291f2e220b222f84a)) +* **email:** expand email palette to 8 tokens + Sync from Branding button ([47b6b39](https://github.com/the-luap/picpeak/commit/47b6b39f3a942aee93b970031d95a952cb769d09)) +* **events:** Sync from Branding button in gallery theme customizer + clarified default inheritance ([bdbe7b8](https://github.com/the-luap/picpeak/commit/bdbe7b80a13b8b215ac544ba9105100e792eeda2)) +* **i18n:** add Brazilian Portuguese (pt-BR) locale ([375f512](https://github.com/the-luap/picpeak/commit/375f51285b5db9c0dfcc04761d24927282e57796)) +* **i18n:** improve pt locale with pt-BR phrasings, remove duplicate pt-BR file ([f25559c](https://github.com/the-luap/picpeak/commit/f25559c0e76776f7cfe8d187e1fea05e751bbafe)) +* **theme:** expand color settings to 8-token CI palette + alt button ([114aab5](https://github.com/the-luap/picpeak/commit/114aab57771a4bba03a9e5c616c75a37c9b25969)) + + +### Bug Fixes + +* **admin:** tab underlines use accent (not accent-dark) for proper highlight color ([565ae45](https://github.com/the-luap/picpeak/commit/565ae45ca71e46166c8bbfc0eb0b6da92d74f120)) +* **branding:** admin sidebar uses accent-dark, primary buttons follow CI token ([fc2bce3](https://github.com/the-luap/picpeak/commit/fc2bce3a01f02b2d131ca4ce1c8e81fc9dc62755)) +* **branding:** comprehensive sweep — replace remaining primary-* legacy colors with accent tokens ([578a174](https://github.com/the-luap/picpeak/commit/578a1745b8d010eeeb261d3452fd192b1ec7bcf8)) +* **branding:** selected-state accent colors, force-mode actually flips galleries, compact color picker layout ([5b410ed](https://github.com/the-luap/picpeak/commit/5b410ed9f87daad8e96345a86897f2a9e9419802)) +* **branding:** working tooltips, high-contrast selected states, gallery chrome follows accent ([b19bb0c](https://github.com/the-luap/picpeak/commit/b19bb0c6208744f329cb3e99f4e26a83f191710a)) +* **cms:** apply dark mode to CMS editor, public CMS, and admin modals ([d2a10f6](https://github.com/the-luap/picpeak/commit/d2a10f6523655488267d6f68835d7adb46dcf962)) +* **theme:** centralise force-mode enforcement inside ThemeContext so every gallery flips ([21188f4](https://github.com/the-luap/picpeak/commit/21188f48d76dd29bc1251bcc6faf9d6d96c805b5)) + +## [3.40.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.40.0-beta.0...v3.40.1-beta.0) (2026-05-04) + + +### Bug Fixes + +* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([c8e09c2](https://github.com/the-luap/picpeak/commit/c8e09c2a2a7d0920901560317eecd773b83251c0)) +* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([b106da1](https://github.com/the-luap/picpeak/commit/b106da1ededa27fc8727f2c0e74a9182e6e9c895)) + +## [3.40.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.39.1-beta.0...v3.40.0-beta.0) (2026-05-04) + + +### Features + +* **branding:** per-family generic fallback via meta.json ([dcff451](https://github.com/the-luap/picpeak/commit/dcff4515721482e06c2ef1c1eb34f9e12754c07c)) +* **branding:** self-hosted webfonts with filesystem scanner ([d04bf28](https://github.com/the-luap/picpeak/commit/d04bf288084144bf53ef0ba988fa32ed703d7351)) + + +### Bug Fixes + +* **fonts:** drop immutable Cache-Control to allow font replacement rollout ([5703fcb](https://github.com/the-luap/picpeak/commit/5703fcb80680155e3b637dd5fc15c430de963c40)) ### Documentation -* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c)) -* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646)) +* **fonts:** cache rollout, stale-list note, meta.json ([bd0e052](https://github.com/the-luap/picpeak/commit/bd0e052b1a1847718151a16117dacc6c42a2178e)) -## [2.6.4](https://github.com/the-luap/picpeak/compare/v2.6.3...v2.6.4) (2026-04-08) - - -### Bug Fixes - -* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f)) -* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd)) - -## [2.6.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07) +## [3.39.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.39.0-beta.0...v3.39.1-beta.0) (2026-05-04) ### Documentation -* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d)) -* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872)) +* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([c60ab74](https://github.com/the-luap/picpeak/commit/c60ab74ae2daabc4b11fea1f1b2df728294b03c8)) +* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([dbe0a30](https://github.com/the-luap/picpeak/commit/dbe0a3055bd2c71981cb7d9cf43c2b22b9e3276c)) -## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16) +## [3.39.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.38.0-beta.0...v3.39.0-beta.0) (2026-05-04) + + +### Features + +* **gallery:** decouple header style from layout, add banner option ([1f1a856](https://github.com/the-luap/picpeak/commit/1f1a856083b1966ed4b32a23a14442f1727cecef)) + +## [3.38.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.37.0-beta.0...v3.38.0-beta.0) (2026-05-04) + + +### Features + +* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([647aea2](https://github.com/the-luap/picpeak/commit/647aea21ae42fe0d089bf45568702617a25b98e4)) + +## [3.37.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.36.0-beta.0...v3.37.0-beta.0) (2026-05-04) + + +### Features + +* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([d561db8](https://github.com/the-luap/picpeak/commit/d561db802b04db8fbb38819a22e840532e775ef0)) +* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([ffb4318](https://github.com/the-luap/picpeak/commit/ffb4318a1f667e273cd59805673b125d2f17699b)) + +## [3.36.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.35.0-beta.0...v3.36.0-beta.0) (2026-05-04) + + +### Features + +* **events:** prefill admin email + admin picker on event creation ([3fe8e61](https://github.com/the-luap/picpeak/commit/3fe8e61bd1175e35dcb61604447e5d9c2e902ec5)) + +## [3.35.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.2-beta.0...v3.35.0-beta.0) (2026-05-04) + + +### Features + +* **events:** tree view for external media folder picker ([cdd40ac](https://github.com/the-luap/picpeak/commit/cdd40acb4591d4eb8f80a79c69201556eab1bfd0)) +* **events:** tree view for external media folder picker ([f927b09](https://github.com/the-luap/picpeak/commit/f927b09c70f3b6a5c81c3726609a29680b96b6fc)) ### Bug Fixes -* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f)) -* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf)) +* **events:** match scrollbar to theme in external folder tree picker ([bd42ee1](https://github.com/the-luap/picpeak/commit/bd42ee1ce03b8f6e7b011b53f2c71453be931cc6)) -## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11) +## [3.34.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.1-beta.0...v3.34.2-beta.0) (2026-05-04) ### Bug Fixes +* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([3ab8a64](https://github.com/the-luap/picpeak/commit/3ab8a64a24f1600e674f77d39139e33857b4dfc8)) +* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([96818c7](https://github.com/the-luap/picpeak/commit/96818c7ae8de0d8fd478cd901ea25a3272eee85d)) + +## [3.34.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.34.0-beta.0...v3.34.1-beta.0) (2026-05-03) + + +### Bug Fixes + +* **cms:** nl/pt/ru i18n + gate external_url in public response ([08d0462](https://github.com/the-luap/picpeak/commit/08d046276bf259e8511b01415141f51b8484f967)) +* **cms:** nl/pt/ru i18n + gate external_url in public response ([bce5c1f](https://github.com/the-luap/picpeak/commit/bce5c1f725043965c2499515f18e93e9578bd204)) + +## [3.34.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.2-beta.0...v3.34.0-beta.0) (2026-05-03) + + +### Features + +* **cms:** add external URL toggle for imprint and privacy pages ([b2c8161](https://github.com/the-luap/picpeak/commit/b2c8161a43c2d0b09d6783e791b3f26862254824)) +* **cms:** add per-page external URL override — backend ([66423bb](https://github.com/the-luap/picpeak/commit/66423bb65e83b6204509c9a98d783ba8255c3364)) +* **cms:** admin UI for external imprint/privacy URL ([a4e3d10](https://github.com/the-luap/picpeak/commit/a4e3d10fb0c97ea07c4b08d16c0947945d8a7576)) +* **cms:** redirect legal links to external URL when configured ([c5bba50](https://github.com/the-luap/picpeak/commit/c5bba505ac92b5257f6bb1c069b8bc23ea6a5a1b)) + +## [3.33.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.1-beta.0...v3.33.2-beta.0) (2026-05-03) + + +### Bug Fixes + +* **events:** admin-set password on reset, full-URL gallery_link in all emails ([0d1f82d](https://github.com/the-luap/picpeak/commit/0d1f82d31a2f9e30bf193496ac203eaf8dfd856b)) +* **events:** admin-set password on reset, full-URL gallery_link in all emails ([ff50c74](https://github.com/the-luap/picpeak/commit/ff50c74e1912ccba60f7ccdbead92b76de91388b)) + +## [3.33.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.33.0-beta.0...v3.33.1-beta.0) (2026-05-03) + + +### Bug Fixes + +* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([0767203](https://github.com/the-luap/picpeak/commit/07672038d4ac31fc601adfb2338104223856ba71)) +* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([e8052ad](https://github.com/the-luap/picpeak/commit/e8052adf1d2f1717652ac5d6b8cd8bcc01787189)) + +## [3.33.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.5-beta.0...v3.33.0-beta.0) (2026-05-02) + + +### Features + +* native multi-arch Docker images (Apple Silicon, ARM64 Linux) ([df30618](https://github.com/the-luap/picpeak/commit/df3061893d154152b75b8ab0d07e0b1e0078431d)) + +## [3.32.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.4-beta.0...v3.32.5-beta.0) (2026-05-02) + + +### Bug Fixes + +* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([f529c9e](https://github.com/the-luap/picpeak/commit/f529c9e3d72f0e3496951dfa5d160afda9a1ac51)) +* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([1a530ae](https://github.com/the-luap/picpeak/commit/1a530aeaa2d61b34d9721a555b71631c7101c58e)) + +## [3.32.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.3-beta.0...v3.32.4-beta.0) (2026-05-01) + + +### Bug Fixes + +* **events:** stop mapping branding_logo_position onto hero_logo_position ([af2b062](https://github.com/the-luap/picpeak/commit/af2b0628cb4f79a147366665d35c098012071216)) +* **events:** stop mapping branding_logo_position onto hero_logo_position ([ef1c875](https://github.com/the-luap/picpeak/commit/ef1c875f6ec1e02657006cb09cd0b1d868ec2fc0)) +* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([07b41e6](https://github.com/the-luap/picpeak/commit/07b41e691d2e8a71f775c667d805a2f9adc10590)) +* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([f81a872](https://github.com/the-luap/picpeak/commit/f81a8728e67b313ac43f55c94fb635abf9beca05)) + +## [3.32.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.2-beta.0...v3.32.3-beta.0) (2026-05-01) + + +### Bug Fixes + +* **auth:** /auth/session must verify issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([83dedbc](https://github.com/the-luap/picpeak/commit/83dedbcd45e34a924594dd83f6e3561f776576fb)) +* **auth:** make /auth/session verify the issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([88a6c6a](https://github.com/the-luap/picpeak/commit/88a6c6a7fba7e1419a021f4870518f0b76ac6494)) +* **events:** coerce expires_in_days to Number before addDays ([e5712d8](https://github.com/the-luap/picpeak/commit/e5712d8ffe2f0ed980e1df5e1263876af7202b76)) +* **events:** coerce expires_in_days to Number before addDays ([db29d0e](https://github.com/the-luap/picpeak/commit/db29d0e2788f63cc9eb0a43ec58313387acb0c0d)) + +## [3.32.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.1-beta.0...v3.32.2-beta.0) (2026-05-01) + + +### Bug Fixes + +* events search/counters ([#346](https://github.com/the-luap/picpeak/issues/346)), lazy gallery skeleton ([#321](https://github.com/the-luap/picpeak/issues/321)), smooth lightbox swipe ([#348](https://github.com/the-luap/picpeak/issues/348)) ([6229b38](https://github.com/the-luap/picpeak/commit/6229b38bac90cc0c538a72688efae3be77a3bb08)) +* **events:** server-side search/pagination to remove first-100 cap ([#346](https://github.com/the-luap/picpeak/issues/346)) ([a5b20ca](https://github.com/the-luap/picpeak/commit/a5b20ca3fe77df665d4a9744413d7ee4054858f0)) +* **gallery:** lazy-render skeleton grid for fast loads ([#321](https://github.com/the-luap/picpeak/issues/321) follow-up) ([d9d8137](https://github.com/the-luap/picpeak/commit/d9d81372b80f7d44dca54b7993f52c36574048c9)) +* **lightbox:** smooth carousel swipe + drop instructional hint ([#348](https://github.com/the-luap/picpeak/issues/348)) ([743086d](https://github.com/the-luap/picpeak/commit/743086d3cb9100fb163bc9d04d968e5b611a1f99)) + +## [3.32.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.32.0-beta.0...v3.32.1-beta.0) (2026-04-30) + + +### Documentation + +* move documentation to docs.picpeak.app, drop in-repo copies ([02ed5d4](https://github.com/the-luap/picpeak/commit/02ed5d400736f966283a138dedde2455448067ff)) +* move documentation to docs.picpeak.app, drop in-repo copies ([0faf9b3](https://github.com/the-luap/picpeak/commit/0faf9b32816f5f94aa584d2336cdb1e0b7082239)) + +## [3.32.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.31.1-beta.0...v3.32.0-beta.0) (2026-04-29) + + +### Features + +* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([7ea4801](https://github.com/the-luap/picpeak/commit/7ea4801544fd5cd8bca1907a71b5c4e96ee77649)) +* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([1e69d5f](https://github.com/the-luap/picpeak/commit/1e69d5ff71ac2d1d133b0e40637b437d7cc8bc4f)) + +## [3.31.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.31.0-beta.0...v3.31.1-beta.0) (2026-04-28) + + +### Bug Fixes + +* **events:** show customer phone in event details view ([#331](https://github.com/the-luap/picpeak/issues/331)) ([4c73d22](https://github.com/the-luap/picpeak/commit/4c73d228ed98b8ec05bec2824aee7ce066a184e1)) +* **gallery:** single-finger swipe nav in mobile lightbox ([#332](https://github.com/the-luap/picpeak/issues/332)) ([4c8eba0](https://github.com/the-luap/picpeak/commit/4c8eba0cb43635d92a53d90c58b19007136c1c12)) +* **gallery:** use ref for swipe-start to avoid stale-closure miss ([#332](https://github.com/the-luap/picpeak/issues/332)) ([fcddfe0](https://github.com/the-luap/picpeak/commit/fcddfe094b2a01963f7b420afa886e7d5dae4390)) +* **lightbox:** mobile toolbar clipping + iOS safe-area + viewport-fit ([#336](https://github.com/the-luap/picpeak/issues/336)) ([42a7ae4](https://github.com/the-luap/picpeak/commit/42a7ae4be8fe7b12104ae036465c9c4117606378)) +* mobile lightbox + share previews + customer phone bug triage ([1e40677](https://github.com/the-luap/picpeak/commit/1e4067713ce9a808a7b49319bc262e5c9a6599c6)) +* **share:** OG/Twitter-card metadata for gallery share URLs ([#333](https://github.com/the-luap/picpeak/issues/333)) ([5275621](https://github.com/the-luap/picpeak/commit/5275621fcd38f1ec09b54595163ecd5e63614b1a)) + +## [3.31.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.30.0-beta.0...v3.31.0-beta.0) (2026-04-28) + + +### Features + +* **frontend:** dedupe /public/settings via shared usePublicSettings hook ([#325](https://github.com/the-luap/picpeak/issues/325)) ([3d4ae4d](https://github.com/the-luap/picpeak/commit/3d4ae4d7e9f9995d93563e8092e05215362afb3b)) +* native S3 storage backend ([#328](https://github.com/the-luap/picpeak/issues/328)) + presigned download follow-up ([1b717ce](https://github.com/the-luap/picpeak/commit/1b717ce5ededa343d2fbb7e1c3493b4434743565)) +* outbound webhooks for event/photo lifecycle ([#327](https://github.com/the-luap/picpeak/issues/327)) ([c488f48](https://github.com/the-luap/picpeak/commit/c488f481caacf0d63dafc47f509e8de2708bc30f)) +* presigned download UI + S3 prefix walker auto-importer (follow-ups) ([446d80a](https://github.com/the-luap/picpeak/commit/446d80a4cc5eb0389994e29585b2a98dad373db2)) +* S3 storage + webhooks + settings dedupe + backup fixes ([06d54be](https://github.com/the-luap/picpeak/commit/06d54bec4d0afc4a1b9ba6f2449ed7d79f1d3e8f)) + + +### Bug Fixes + +* **backup:** cron schedule mapping + manifest format detection + bigint coerce ([ab4095f](https://github.com/the-luap/picpeak/commit/ab4095f5928b1476009cddfd3444d6f5b58b034d)) +* **backup:** incremental backups against S3 + jsonb stats parsing ([e232f9f](https://github.com/the-luap/picpeak/commit/e232f9f2cf54aeba1e16d769397428206a0f1801)) + +## [3.30.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.29.1-beta.0...v3.30.0-beta.0) (2026-04-27) + + +### Features + +* customisable 404 + gallery-not-found pages via CMS ([#324](https://github.com/the-luap/picpeak/issues/324)) ([4f77905](https://github.com/the-luap/picpeak/commit/4f77905b87bea474b3d2496350996deaad041230)) +* optional customer phone field gated by global toggle ([#322](https://github.com/the-luap/picpeak/issues/322)) ([be6cb28](https://github.com/the-luap/picpeak/commit/be6cb28c8097d2277c1af2a32cf8bc88ebbc7136)) +* public v1 API + token management + OpenAPI docs ([#322](https://github.com/the-luap/picpeak/issues/322)) ([808b15b](https://github.com/the-luap/picpeak/commit/808b15bafbcdab6ea55aff7f0e507153f513a70a)) + + +### Bug Fixes + +* dedupe parallel admin 401 redirects to /admin/login ([038e84c](https://github.com/the-luap/picpeak/commit/038e84cae7f56a0a1af8c71b85881ca5d320c6e3)) +* floor password_changed_at when comparing against JWT iat ([793e410](https://github.com/the-luap/picpeak/commit/793e410554b461522fbe24014dfd3baa915da2bb)) +* theme picker buttons no longer submit the parent form ([#326](https://github.com/the-luap/picpeak/issues/326)) ([2eead52](https://github.com/the-luap/picpeak/commit/2eead523193ccb7f23eb767097ad9698e8312833)) +* theme save without Live Preview, Branding default on new events, gallery loading flicker ([#323](https://github.com/the-luap/picpeak/issues/323), [#321](https://github.com/the-luap/picpeak/issues/321)) ([822be9a](https://github.com/the-luap/picpeak/commit/822be9a9b2716f1832a4cb6fccd53602e3cbab51)) +* theme-preset match loop ignores extra fields like logoUrl ([#323](https://github.com/the-luap/picpeak/issues/323)) ([b63a877](https://github.com/the-luap/picpeak/commit/b63a8774c4b44733b903736b2ca5a472a884055e)) + + +### Documentation + +* add Buy Me a Coffee badge + Support section ([46bc894](https://github.com/the-luap/picpeak/commit/46bc894d917bd55dbd9bafaa64fd38db21488b81)) + +## [3.29.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.29.0-beta.0...v3.29.1-beta.0) (2026-04-26) + + +### Bug Fixes + +* address bugs and feature requests from discussion [#317](https://github.com/the-luap/picpeak/issues/317) ([6cfff6f](https://github.com/the-luap/picpeak/commit/6cfff6f6a6dbdc5bc1e9fe4fbce5795cdb1855c6)) +* discussion [#317](https://github.com/the-luap/picpeak/issues/317) issues and [#318](https://github.com/the-luap/picpeak/issues/318) archive crash ([2f2f405](https://github.com/the-luap/picpeak/commit/2f2f405d9bc2831b3bbe2ca7fbf726d61382dc38)) +* prevent backend crash on archive when admin_email is null ([#318](https://github.com/the-luap/picpeak/issues/318)) ([e4b0f96](https://github.com/the-luap/picpeak/commit/e4b0f961b75952b6907cc2291fa256215c09c80c)) + +## [3.29.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.3-beta.0...v3.29.0-beta.0) (2026-04-23) + + +### Features + +* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([d3f1206](https://github.com/the-luap/picpeak/commit/d3f12068164a6bfe6c4a3817ad2fc2e8ed7abf4f)) +* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([e18afd3](https://github.com/the-luap/picpeak/commit/e18afd3e6b0b5a4cdb4873fb227d1b1d2bf35f21)) + +## [3.28.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.2-beta.0...v3.28.3-beta.0) (2026-04-13) + + +### Bug Fixes + +* revert /api prefix in adminPhotos.js to avoid double-prefix ([094276d](https://github.com/the-luap/picpeak/commit/094276d3cc7117eee30e4bcbce487e54f0eacb29)) +* revert /api prefix in adminPhotos.js to avoid double-prefix ([#307](https://github.com/the-luap/picpeak/issues/307)) ([ceb2a09](https://github.com/the-luap/picpeak/commit/ceb2a09f483b4754fda232c5c1f7acb8971aac10)) + +## [3.28.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.1-beta.0...v3.28.2-beta.0) (2026-04-12) + + +### Bug Fixes + +* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([b05c36a](https://github.com/the-luap/picpeak/commit/b05c36ac810a557a2ac088ab7bec39bb76f9a2ae)) +* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([9323bef](https://github.com/the-luap/picpeak/commit/9323befdd99d64b85cca89af24ac1b7034d72eee)) + +## [3.28.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.0-beta.0...v3.28.1-beta.0) (2026-04-12) + + +### Bug Fixes + +* apply sort direction in gallery and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([3716ff5](https://github.com/the-luap/picpeak/commit/3716ff50854766bde588fbd6b9027f8647e59150)) +* apply sort direction in gallery view and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([dffe057](https://github.com/the-luap/picpeak/commit/dffe057772c922ab6a213e25f171157e0c2badf8)) + +## [3.28.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.27.0-beta.0...v3.28.0-beta.0) (2026-04-11) + + +### Features + +* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([b1dfbe4](https://github.com/the-luap/picpeak/commit/b1dfbe4c2fe271d8087974d02cf724f04058bdc9)) +* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([15a8ab4](https://github.com/the-luap/picpeak/commit/15a8ab41fd1c94e3397d300b161cd1fdd459ea05)) + + +### Bug Fixes + +* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([54badef](https://github.com/the-luap/picpeak/commit/54badefc51b834d55530722f87c81a6ade33e35b)) +* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([77f07e9](https://github.com/the-luap/picpeak/commit/77f07e9329e47f6ac5040f2e85d2710ebbea3ced)) + +## [3.27.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.2-beta.0...v3.27.0-beta.0) (2026-04-11) + + +### Features + +* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0)) +* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8)) +* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3)) +* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32)) +* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9)) +* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285)) +* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad)) +* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6)) +* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb)) +* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d)) +* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef)) +* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab)) +* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777)) +* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a)) +* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3)) +* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39)) +* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9)) +* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2)) +* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118) +* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d)) +* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde)) +* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826)) +* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2)) +* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f)) +* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85)) +* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc)) +* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b)) +* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2)) +* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024)) +* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181) +* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d)) +* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942)) +* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c)) +* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7)) +* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7)) +* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4)) +* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1)) +* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a)) +* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1)) +* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d)) +* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7)) +* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a)) +* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f)) +* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d)) +* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3)) +* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75)) +* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452)) +* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)) +* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f)) +* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53)) +* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae)) +* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a)) +* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03)) +* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c)) +* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd)) +* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19) +* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c)) +* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5)) +* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0)) +* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74)) +* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51)) +* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e)) +* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120)) +* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5)) +* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c)) +* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d)) +* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f)) +* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55)) +* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f)) +* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112) +* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d)) +* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555)) +* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112)) +* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4)) +* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39)) +* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a)) +* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451)) +* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163)) +* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53)) +* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9)) +* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4)) +* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4)) + + +### Bug Fixes + +* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669)) +* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5)) +* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc)) +* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff)) +* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb)) +* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69)) +* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530)) +* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19) +* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942)) +* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a)) +* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c)) +* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f)) +* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560)) +* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00)) +* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409)) +* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117) +* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af)) +* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6)) +* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c)) +* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc)) +* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129) +* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129) +* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1)) +* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4)) +* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee)) +* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3)) +* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716)) +* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db)) +* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0)) +* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e)) +* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e)) +* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6)) +* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136) +* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5)) +* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a)) +* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968)) +* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4)) +* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116)) +* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4)) +* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de)) +* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e)) +* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab)) +* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343)) +* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f)) +* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70)) +* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c)) +* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc)) +* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37)) +* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764)) +* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1)) +* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980)) +* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef)) +* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488)) +* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef)) +* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e)) +* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113) +* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113) +* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d)) +* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c)) +* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4)) +* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973)) +* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0)) +* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75)) +* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616)) +* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8)) +* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55)) +* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547)) +* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967)) +* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36)) +* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97)) +* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107) +* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3)) +* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c)) +* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951)) +* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137) +* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a)) +* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d)) +* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa)) +* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578)) +* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f)) +* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7)) +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83)) +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898)) +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9)) +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da)) +* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1)) +* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8)) +* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9)) +* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269) +* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b)) +* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9)) +* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c)) +* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce)) +* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa)) +* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059)) +* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8)) +* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50)) +* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24)) +* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1)) +* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da)) +* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879)) +* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58)) +* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9)) +* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403)) +* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9)) +* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1)) +* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8)) +* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113) +* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4)) +* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25)) +* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01)) +* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475)) +* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48)) +* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5)) +* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4)) * update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c)) * update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223) +* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc)) +* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc)) +* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f)) +* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074)) +* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c)) +* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68)) +* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8)) +* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6)) +* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a)) +* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51)) +* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392)) +* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229) +* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2)) + + +### Documentation + +* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d)) +* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a)) +* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78)) +* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10)) +* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c)) +* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb)) + +## [3.26.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.1-beta.0...v3.26.2-beta.0) (2026-04-11) + + +### Bug Fixes + +* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530)) +* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51)) + +## [3.26.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.0-beta.0...v3.26.1-beta.0) (2026-04-09) + + +### Bug Fixes + +* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f)) +* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560)) +* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1)) +* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58)) + + +### Documentation + +* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78)) +* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10)) + +## [3.26.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.25.0-beta.0...v3.26.0-beta.0) (2026-04-09) + + +### Features + +* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451)) +* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163)) + + +### Bug Fixes + +* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9)) +* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269) + +## [3.25.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.1-beta.0...v3.25.0-beta.0) (2026-04-08) + + +### Features + +* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d)) +* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7)) + +## [3.24.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.0-beta.0...v3.24.1-beta.0) (2026-04-05) + + +### Bug Fixes + +* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b)) +* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9)) + +## [3.24.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.23.0-beta.0...v3.24.0-beta.0) (2026-04-04) + + +### Features + +* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4)) +* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4)) + +## [3.23.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.22.0-beta.0...v3.23.0-beta.0) (2026-04-04) + + +### Features + +* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5)) +* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0)) + + +### Bug Fixes + +* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967)) +* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36)) + +## [3.22.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.1-beta.0...v3.22.0-beta.0) (2026-03-25) + + +### Features + +* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6)) +* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb)) + +## [3.21.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.0-beta.0...v3.21.1-beta.0) (2026-03-22) + + +### Bug Fixes + +* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69)) + +## [3.21.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.1-beta.0...v3.21.0-beta.0) (2026-03-18) + + +### Features + +* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f)) + + +### Bug Fixes + +* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2)) + +## [3.20.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.0-beta.0...v3.20.1-beta.0) (2026-03-17) + + +### Bug Fixes + +* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb)) + +## [3.20.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.2-beta.0...v3.20.0-beta.0) (2026-03-17) + + +### Features + +* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55)) +* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f)) + +## [3.19.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.1-beta.0...v3.19.2-beta.0) (2026-03-16) + + +### Bug Fixes + +* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50)) +* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da)) + +## [3.19.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.0-beta.0...v3.19.1-beta.0) (2026-03-16) + + +### Bug Fixes + +* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a)) +* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7)) + +## [3.19.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.2-beta.0...v3.19.0-beta.0) (2026-03-16) + + +### Features + +* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85)) +* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc)) + +## [3.18.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.1-beta.0...v3.18.2-beta.0) (2026-03-16) + + +### Bug Fixes + +* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f)) +* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475)) + +## [3.18.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.0-beta.0...v3.18.1-beta.0) (2026-03-16) + + +### Bug Fixes + +* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392)) +* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229) + +## [3.18.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.2-beta.0...v3.18.0-beta.0) (2026-03-16) + + +### Features + +* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d)) +* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555)) +* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9)) + + +### Bug Fixes + +* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8)) + +## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11) + + +### Bug Fixes + +* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4)) +* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223) +* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c)) +* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68)) ## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08) @@ -931,3 +1663,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.1.15] - Previous Release Initial stable release with core functionality. + +--- + +# Pre-3.x history (main 2.x channel) + +## [2.6.5](https://github.com/the-luap/picpeak/compare/v2.6.4...v2.6.5) (2026-04-08) + + +### Documentation + +* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c)) +* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646)) + +## [2.6.4](https://github.com/the-luap/picpeak/compare/v2.6.3...v2.6.4) (2026-04-08) + + +### Bug Fixes + +* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f)) +* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd)) + +## [2.6.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07) + + +### Documentation + +* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d)) +* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872)) + +## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16) + + +### Bug Fixes + +* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f)) +* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf)) + +## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11) diff --git a/README.md b/README.md index 241c97d2..2d0ffa85 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,16 @@ We welcome contributions — bug fixes, features, translations, documentation. S - [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide - [Security Policy](SECURITY.md) +## Contributors + +Thanks to the people whose code, reports, and feedback have shaped PicPeak: + +- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors. +- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter. + +If you've contributed and aren't listed here, please open a PR. + + ## License MIT — use it for personal or commercial projects. diff --git a/SIMPLE_SETUP.md b/SIMPLE_SETUP.md index b2b9ac56..498535ee 100644 --- a/SIMPLE_SETUP.md +++ b/SIMPLE_SETUP.md @@ -219,29 +219,36 @@ location ~ ^/(photos|thumbnails|uploads) { ### Creating a Gallery -#### Method 1: Via Admin Panel (Recommended) -1. Login to admin panel +#### Via Admin Panel +1. Login to admin panel at `/admin` 2. Click "Create New Event" -3. Configure settings and upload photos +3. Configure settings (name, date, password, customer email) +4. Upload photos via drag & drop in the Photos tab +5. Publish the gallery when ready + +#### Adding Photos via File System + +> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone. + +Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails. -#### Method 2: File System ```bash -# Docker installation -mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024 -cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/ +# Docker installation — copy photos into an existing event's folder +cp /path/to/photos/*.jpg ~/picpeak/storage/events/active// # Native installation -sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024 -sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/ -sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024 +sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active// +sudo chown -R picpeak:picpeak /opt/picpeak/events/active/ ``` +The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files. + ### Gallery Structure ``` -wedding-smith-2024/ -├── collages/ # Group photos -├── individual/ # Individual photos -└── thumbnails/ # Auto-generated thumbnails +/ +├── collages/ # Group photos (optional subfolder) +├── individual/ # Individual photos (optional subfolder) +└── photo.jpg # Photos at root level also work ``` ## 🔧 Service Management @@ -461,8 +468,8 @@ sudo -u picpeak node scripts/reset-admin-password.js - Installation: `/tmp/picpeak-setup-*.log` 2. **Documentation:** - - [Full Documentation](https://github.com/the-luap/picpeak) - - [Deployment Guide](./DEPLOYMENT_GUIDE.md) + - [Full Documentation](https://docs.picpeak.app) + - [Deployment Guide](https://docs.picpeak.app/deployment) 3. **Support:** - [GitHub Issues](https://github.com/the-luap/picpeak/issues) diff --git a/backend/.env.example b/backend/.env.example index 7d7a12d2..ba134257 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,34 @@ PORT=3001 # Generate with: openssl rand -base64 32 JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 +# Auth cookie Secure flag +# unset - default: follows NODE_ENV (production=true, dev=false) +# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) +# false - never set Secure (allows HTTP; cookies not protected on HTTPS) +# auto - decide per request: Secure on HTTPS, not on HTTP +# +# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS +# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain +# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads +# req.secure from Express, which respects the X-Forwarded-Proto header +# when the proxy is in the trust list. +# +# Requirements for auto mode: +# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS +# requests. Standard configs for NPM/Traefik/Caddy do this by default. +# 2. The proxy must be on a trusted IP range. By default PicPeak trusts +# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x, +# 192.168.x, link-local). Proxies outside those ranges need custom +# trust proxy configuration. +# COOKIE_SECURE=auto + +# Cookie SameSite attribute (Lax | Strict | None). Default: Lax +# COOKIE_SAMESITE=Lax + +# Cookie Domain — set this if serving auth cookies across subdomains. +# Leave unset for same-origin setups. +# COOKIE_DOMAIN=.example.com + # URLs (adjust for your domain) ADMIN_URL=https://photos.example.com FRONTEND_URL=https://photos.example.com diff --git a/backend/Dockerfile b/backend/Dockerfile index 90de8cf1..9a35453a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder # Add build arguments ARG CACHEBUST=1 @@ -23,18 +23,24 @@ RUN npm ci --omit=dev COPY . . # Production stage -FROM node:20-alpine +FROM node:22-alpine WORKDIR /app # Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs) RUN apk upgrade --no-cache -# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps -RUN npm install -g npm@latest +# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps +# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues) +RUN npm install -g npm@10 -# Install dumb-init for proper signal handling and postgresql-client for database checks -RUN apk add --no-cache dumb-init postgresql-client +# Install dumb-init for proper signal handling, postgresql-client for database +# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships +# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm +# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably +# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video +# pipeline calls via fluent-ffmpeg.ffprobe()). +RUN apk add --no-cache dumb-init postgresql-client ffmpeg # Create non-root user RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 8054bdab..2f056807 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -5,8 +5,10 @@ WORKDIR /app # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) RUN apk upgrade --no-cache -# Install dumb-init for proper signal handling -RUN apk add --no-cache dumb-init +# Install dumb-init for proper signal handling and ffmpeg for video uploads. +# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl; +# the npm-bundled binary doesn't run reliably on Alpine. Match production. +RUN apk add --no-cache dumb-init ffmpeg # Copy package files COPY package*.json ./ diff --git a/backend/__tests__/integration/backup-s3.test.js b/backend/__tests__/integration/backup-s3.test.js index c08821db..afe1bb3c 100644 --- a/backend/__tests__/integration/backup-s3.test.js +++ b/backend/__tests__/integration/backup-s3.test.js @@ -7,12 +7,15 @@ const crypto = require('crypto'); // Load services const backupService = require('../../src/services/backupService'); const S3StorageAdapter = require('../../src/services/storage/s3Storage'); -const { db, initialize: initDb } = require('../../src/database/db'); +const { db, initializeDatabase: initDb } = require('../../src/database/db'); const logger = require('../../src/utils/logger'); // Test configuration +// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104). +// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running +// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.). const TEST_CONFIG = { - endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000', + endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104', accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin', secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin', bucket: 'test-backup-bucket-' + Date.now(), @@ -56,9 +59,17 @@ describe('S3 Backup Integration Tests', () => { } } - // Initialize database - await initDb(); - await db.migrate.latest(); + // Schema is expected to already be applied by `npm run migrate` against + // the dev database. db.migrate.latest() can't be used here because + // PicPeak's custom run-migrations.js tracks state in the `migrations` + // table (not knex's `knex_migrations`), so knex would try to re-apply + // every migration and crash on duplicate-table errors. + const ok = await db.schema.hasTable('events') + && await db.schema.hasTable('app_settings') + && await db.schema.hasTable('backup_runs'); + if (!ok) { + throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.'); + } // Create test storage directory testStoragePath = path.join(__dirname, '../fixtures/test-storage'); @@ -69,10 +80,12 @@ describe('S3 Backup Integration Tests', () => { await setupTestData(); // Mock logger to reduce noise - logger.info = jest.fn(); - logger.debug = jest.fn(); - logger.warn = jest.fn(); - logger.error = jest.fn(); + if (process.env.UNMOCK_LOGGER !== 'true') { + logger.info = jest.fn(); + logger.debug = jest.fn(); + logger.warn = jest.fn(); + logger.error = jest.fn(); + } }); afterAll(async () => { @@ -165,8 +178,9 @@ describe('S3 Backup Integration Tests', () => { .first(); expect(backupRun.status).toBe('completed'); - expect(backupRun.files_backed_up).toBeGreaterThan(0); - expect(backupRun.total_size_bytes).toBeGreaterThan(0); + // pg driver returns bigint columns as strings; coerce for the size assertion. + expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0); + expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0); // Verify files in S3 const s3Objects = await listS3Objects(); @@ -269,13 +283,16 @@ describe('S3 Backup Integration Tests', () => { .first(); expect(secondRun.id).not.toBe(firstRun.id); - expect(secondRun.files_backed_up).toBe(1); // Only modified file + expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file - // Check manifest indicates incremental + // Check manifest indicates incremental. The current manifest schema + // groups counts under `incremental.changes.*` (added/modified/deleted/ + // unchanged + size_difference) — see backupManifest.generateIncrementalManifest. if (secondRun.manifest_path) { const manifest = await backupService.getBackupManifest(secondRun.id); expect(manifest.manifest.incremental).toBeDefined(); - expect(manifest.manifest.incremental.modified_files_count).toBe(1); + expect(manifest.manifest.incremental.changes).toBeDefined(); + expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1); } }); @@ -468,15 +485,16 @@ describe('S3 Backup Integration Tests', () => { { setting_key: 'backup_max_file_size_mb', setting_value: '100' } ]; + // Schema drift: app_settings has no created_at column anymore and the + // unique constraint is on setting_key alone, not (setting_type, key). for (const setting of settings) { await db('app_settings') .insert({ setting_type: 'backup', ...setting, - created_at: new Date(), - updated_at: new Date() + updated_at: new Date(), }) - .onConflict(['setting_type', 'setting_key']) + .onConflict('setting_key') .merge(); } } diff --git a/backend/__tests__/integration/imageProcessor.storage.test.js b/backend/__tests__/integration/imageProcessor.storage.test.js new file mode 100644 index 00000000..f9d5140e --- /dev/null +++ b/backend/__tests__/integration/imageProcessor.storage.test.js @@ -0,0 +1,165 @@ +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const os = require('os'); +const crypto = require('crypto'); +const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); +const sharp = require('sharp'); + +const LocalFsStorage = require('../../src/services/storage/LocalFsStorage'); +const S3StorageBackend = require('../../src/services/storage/S3StorageBackend'); +const storageModule = require('../../src/services/storage'); + +// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults. +jest.mock('../../src/database/db', () => ({ + db: () => { + throw new Error('db disabled in this test'); + }, +})); + +const TEST_S3 = { + endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104', + accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin', + secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin', + region: 'us-east-1', +}; + +const skipS3 = process.env.SKIP_S3_TESTS === 'true'; + +function backendCases() { + const cases = [ + { + name: 'LocalFsStorage', + async setup() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-')); + const storage = new LocalFsStorage({ root }); + await storage.init(); + return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) }; + }, + }, + ]; + if (!skipS3) { + cases.push({ + name: 'S3StorageBackend (MinIO)', + async setup() { + const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`; + const s3Client = new S3Client({ + endpoint: TEST_S3.endpoint, + region: TEST_S3.region, + credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey }, + forcePathStyle: true, + }); + await s3Client.send(new CreateBucketCommand({ Bucket: bucket })); + const storage = new S3StorageBackend({ + bucket, + region: TEST_S3.region, + endpoint: TEST_S3.endpoint, + accessKeyId: TEST_S3.accessKeyId, + secretAccessKey: TEST_S3.secretAccessKey, + forcePathStyle: true, + sslEnabled: false, + }); + await storage.init(); + return { + storage, + async cleanup() { + const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket })); + if (list.Contents?.length) { + await s3Client.send(new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) }, + })); + } + await s3Client.send(new DeleteBucketCommand({ Bucket: bucket })); + }, + }; + }, + }); + } + return cases; +} + +async function makeSourceJpeg(targetDir, name) { + const localPath = path.join(targetDir, name); + // 800x600 random RGB image so sharp has something realistic to thumbnail. + const width = 800; + const height = 600; + const buf = Buffer.alloc(width * height * 3); + for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256; + await sharp(buf, { raw: { width, height, channels: 3 } }) + .jpeg({ quality: 90 }) + .toFile(localPath); + return localPath; +} + +describe.each(backendCases())('imageProcessor through $name', ({ setup }) => { + let storage; + let cleanup; + let tmpDir; + let imageProcessor; + + beforeAll(async () => { + ({ storage, cleanup } = await setup()); + storageModule.setStorageForTesting(storage); + // Require AFTER setStorageForTesting so the module sees our injection. + delete require.cache[require.resolve('../../src/services/imageProcessor')]; + imageProcessor = require('../../src/services/imageProcessor'); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-')); + }, 30000); + + afterAll(async () => { + storageModule.resetStorage(); + if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + if (cleanup) await cleanup(); + }); + + test('generateThumbnail writes through storage and returns a relative key', async () => { + const src = await makeSourceJpeg(tmpDir, 'sample.jpg'); + const key = await imageProcessor.generateThumbnail(src); + expect(key).toBe('thumbnails/thumb_sample.jpg'); + + expect(await storage.exists(key)).toBe(true); + const stat = await storage.stat(key); + expect(stat.size).toBeGreaterThan(100); + + // Verify the bytes are a valid JPEG by re-parsing with sharp on local mode. + if (storage.kind() === 'local') { + const meta = await sharp(storage.resolveLocalPath(key)).metadata(); + expect(meta.format).toBe('jpeg'); + expect(meta.width).toBeLessThanOrEqual(300); + } + }); + + test('generateHeroImage writes through storage and returns a relative key', async () => { + const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg'); + const key = await imageProcessor.generateHeroImage(src); + expect(key).toBe('heroes/hero_hero-source.jpg'); + expect(await storage.exists(key)).toBe(true); + }); + + test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => { + const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg'); + const key = await imageProcessor.generateThumbnail(src); + expect(await imageProcessor.isThumbnailValid(key)).toBe(true); + expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false); + }); + + test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => { + const key = await imageProcessor.generateVideoPlaceholder('demo.mp4'); + expect(key).toBe('thumbnails/thumb_demo.jpg'); + expect(await storage.exists(key)).toBe(true); + }); + + test('withLocalCopy yields a usable local path on both backends', async () => { + const sourceKey = 'fixture/withlocal.jpg'; + const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg'); + const buf = await fs.readFile(src); + await storage.put(sourceKey, buf, { contentType: 'image/jpeg' }); + + const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => { + const meta = await sharp(localPath).metadata(); + return meta.width; + }); + expect(seenSize).toBe(800); + }); +}); diff --git a/backend/__tests__/integration/storageBackend.test.js b/backend/__tests__/integration/storageBackend.test.js new file mode 100644 index 00000000..afcce6f8 --- /dev/null +++ b/backend/__tests__/integration/storageBackend.test.js @@ -0,0 +1,189 @@ +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs').promises; +const os = require('os'); +const crypto = require('crypto'); +const { Readable } = require('stream'); +const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); + +const LocalFsStorage = require('../../src/services/storage/LocalFsStorage'); +const S3StorageBackend = require('../../src/services/storage/S3StorageBackend'); + +// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed. +const TEST_S3 = { + endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104', + accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin', + secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin', + region: 'us-east-1', +}; + +const skipS3 = process.env.SKIP_S3_TESTS === 'true'; + +// Build the matrix of backends to test. Local always runs; S3 runs against MinIO +// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so +// every consumer can rely on identical semantics. +function backendCases() { + const cases = [ + { + name: 'LocalFsStorage', + async setup() { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-')); + const storage = new LocalFsStorage({ root }); + await storage.init(); + return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) }; + }, + }, + ]; + + if (!skipS3) { + cases.push({ + name: 'S3StorageBackend (MinIO)', + async setup() { + const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`; + const s3Client = new S3Client({ + endpoint: TEST_S3.endpoint, + region: TEST_S3.region, + credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey }, + forcePathStyle: true, + }); + await s3Client.send(new CreateBucketCommand({ Bucket: bucket })); + const storage = new S3StorageBackend({ + bucket, + region: TEST_S3.region, + endpoint: TEST_S3.endpoint, + accessKeyId: TEST_S3.accessKeyId, + secretAccessKey: TEST_S3.secretAccessKey, + forcePathStyle: true, + sslEnabled: false, + }); + await storage.init(); + return { + storage, + async cleanup() { + // Empty bucket then delete it. + const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket })); + if (list.Contents?.length) { + await s3Client.send(new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) }, + })); + } + await s3Client.send(new DeleteBucketCommand({ Bucket: bucket })); + }, + }; + }, + }); + } + + return cases; +} + +async function readToString(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf-8'); +} + +describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => { + let storage; + let cleanup; + + beforeAll(async () => { + ({ storage, cleanup } = await setup()); + }, 30000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + test('put + get + exists + stat + delete round-trip with a buffer body', async () => { + const key = 'photos/event-a/IMG_0001.jpg'; + const body = Buffer.from('hello picpeak'); + + await storage.put(key, body, { contentType: 'image/jpeg' }); + + expect(await storage.exists(key)).toBe(true); + + const stat = await storage.stat(key); + expect(stat).not.toBeNull(); + expect(stat.size).toBe(body.length); + + const stream = await storage.get(key); + const text = await readToString(stream); + expect(text).toBe('hello picpeak'); + + await storage.delete(key); + expect(await storage.exists(key)).toBe(false); + expect(await storage.stat(key)).toBeNull(); + }); + + test('put accepts a Readable stream body', async () => { + const key = 'photos/event-b/streamed.bin'; + const body = Readable.from(Buffer.from('streamed payload')); + + await storage.put(key, body); + + const got = await readToString(await storage.get(key)); + expect(got).toBe('streamed payload'); + }); + + test('putFromFile + getToFile round-trip', async () => { + const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`); + const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`); + await fsp.writeFile(tmpIn, 'file payload'); + + const key = 'thumbnails/thumb_x.jpg'; + await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' }); + + await storage.getToFile(key, tmpOut); + const text = await fsp.readFile(tmpOut, 'utf-8'); + expect(text).toBe('file payload'); + + await fsp.unlink(tmpIn).catch(() => {}); + await fsp.unlink(tmpOut).catch(() => {}); + }); + + test('list returns entries under a prefix with size + key', async () => { + await storage.put('events/active/a/photo1.jpg', Buffer.from('a1')); + await storage.put('events/active/a/photo2.jpg', Buffer.from('a22')); + await storage.put('events/active/b/photo3.jpg', Buffer.from('b333')); + + const entries = await storage.list('events/active/a'); + const keys = entries.map((e) => e.key).sort(); + expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']); + const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size])); + expect(sizes['events/active/a/photo1.jpg']).toBe(2); + expect(sizes['events/active/a/photo2.jpg']).toBe(3); + }); + + test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => { + await storage.put('uploads/temp.jpg', Buffer.from('rename-me')); + await storage.rename('uploads/temp.jpg', 'uploads/final.jpg'); + + expect(await storage.exists('uploads/temp.jpg')).toBe(false); + expect(await storage.exists('uploads/final.jpg')).toBe(true); + const text = await readToString(await storage.get('uploads/final.jpg')); + expect(text).toBe('rename-me'); + }); + + test('copy duplicates an object without removing the source', async () => { + await storage.put('events/source.jpg', Buffer.from('src')); + await storage.copy('events/source.jpg', 'events/copied.jpg'); + + expect(await storage.exists('events/source.jpg')).toBe(true); + expect(await storage.exists('events/copied.jpg')).toBe(true); + }); + + test('delete on a missing key is a no-op (does not throw)', async () => { + await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined(); + }); + + test('stat on a missing key returns null', async () => { + expect(await storage.stat('still/not/here.jpg')).toBeNull(); + }); + + test('rejects path traversal attempts', async () => { + await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i); + await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i); + }); +}); diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js new file mode 100644 index 00000000..14ed1ab5 --- /dev/null +++ b/backend/__tests__/integration/webhookDelivery.test.js @@ -0,0 +1,239 @@ +// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE +// requiring the worker so the local-stub URLs (127.0.0.1:) pass +// the SSRF check by default. +process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true'; +process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50'; + +const http = require('http'); +const { db } = require('../../src/database/db'); +const webhookService = require('../../src/services/webhookService'); +const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'); + +// Local-only test stub: matches what dev/webhook-receiver/server.js does +// in the docker-compose flow but spun up inside the Jest process so the +// suite is self-contained. +function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) { + const requests = []; + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const body = Buffer.concat(chunks).toString('utf8'); + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + if (delayMs) await new Promise((r) => setTimeout(r, delayMs)); + res.writeHead(status, { 'Content-Type': 'text/plain' }); + res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced')); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) }); + }); + }); +} + +async function insertWebhook(url, events = ['event.published'], extras = {}) { + // Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the + // route layer's allowlist check is bypassed here since we insert + // straight into the DB. + const { plaintext, preview } = webhookService.generateSecret(); + const insert = await db('webhooks').insert({ + name: extras.name || 'test', + url, + secret: plaintext, + secret_preview: preview, + events: JSON.stringify(events), + active: extras.active !== false, + created_by: 1, + }).returning('id'); + const id = insert[0]?.id || insert[0]; + return { id, secret: plaintext }; +} + +async function clearWebhooks() { + await db('webhook_deliveries').del(); + await db('webhooks').del(); +} + +describe('webhook delivery worker (#327)', () => { + beforeAll(async () => { + // Schema is expected to already be applied by `npm run migrate`. We + // just verify the webhooks tables exist; if not, the test harness has + // missed running migration 082. + const ok = await db.schema.hasTable('webhooks'); + if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first'); + }, 30000); + + afterAll(async () => { + stopWebhookDeliveryWorker(); + await db.destroy(); + }); + + beforeEach(async () => { + await clearWebhooks(); + }); + + test('signs the body with HMAC-SHA256 and the receiver can verify', async () => { + const stub = await makeStub({ status: 200 }); + try { + const { id, secret } = await insertWebhook(stub.url); + await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } }); + await __test.tick(); + + expect(stub.requests).toHaveLength(1); + const got = stub.requests[0]; + const sig = got.headers['x-picpeak-signature']; + expect(sig).toBeTruthy(); + // Receiver-side verification using the SAME helper we ship in the README. + expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true); + // Tampering must fail. + expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false); + + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('success'); + expect(row.attempt_count).toBe(1); + expect(row.response_status).toBe(200); + expect(row.latency_ms).toBeGreaterThanOrEqual(0); + } finally { + await stub.close(); + } + }); + + test('headers include event type and a unique delivery id', async () => { + const stub = await makeStub({ status: 200 }); + try { + await insertWebhook(stub.url, ['photo.uploaded']); + await webhookService.fire('photo.uploaded', { photo: { id: 7 } }); + await __test.tick(); + + const got = stub.requests[0]; + expect(got.headers['x-picpeak-event']).toBe('photo.uploaded'); + expect(got.headers['x-picpeak-delivery']).toBeTruthy(); + expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/); + } finally { + await stub.close(); + } + }); + + test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => { + const stub = await makeStub({ status: 500 }); + try { + const { id } = await insertWebhook(stub.url); + await webhookService.fire('event.published', { event: { id: 2 } }); + await __test.tick(); + + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('pending'); + expect(row.attempt_count).toBe(1); + expect(row.response_status).toBe(500); + // BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future. + const dueIn = new Date(row.next_retry_at).getTime() - Date.now(); + expect(dueIn).toBeGreaterThan(50_000); + expect(dueIn).toBeLessThan(70_000); + } finally { + await stub.close(); + } + }); + + test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => { + const stub = await makeStub({ status: 500 }); + try { + const { id } = await insertWebhook(stub.url); + // Pre-seed a delivery already at attempt_count = 4 so a single tick + // takes it to 5 → failed (avoids waiting through backoffs). + await db('webhook_deliveries').insert({ + webhook_id: id, + event_type: 'event.published', + payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), + attempt_count: 4, + status: 'pending', + next_retry_at: new Date(), + created_at: new Date(), + }); + await __test.tick(); + + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('failed'); + expect(row.attempt_count).toBe(5); + expect(row.completed_at).toBeTruthy(); + expect(row.next_retry_at).toBeNull(); + } finally { + await stub.close(); + } + }); + + test('truncates response body to 1KB before storing', async () => { + const big = 'x'.repeat(5000); + const stub = await makeStub({ status: 200, bodyOverride: big }); + try { + const { id } = await insertWebhook(stub.url); + await webhookService.fire('event.published', { event: {} }); + await __test.tick(); + + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('success'); + expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024); + } finally { + await stub.close(); + } + }); + + test('does not deliver to disabled webhooks (post-mortem state captured)', async () => { + const stub = await makeStub({ status: 200 }); + try { + const { id } = await insertWebhook(stub.url, ['event.published'], { active: false }); + // fire enqueues regardless of active state at fire-time, but we + // disabled BEFORE firing so nothing is enqueued. Direct insert to + // exercise the worker's mid-flight disable check: + await db('webhook_deliveries').insert({ + webhook_id: id, + event_type: 'event.published', + payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), + attempt_count: 0, + status: 'pending', + next_retry_at: new Date(), + created_at: new Date(), + }); + await __test.tick(); + + expect(stub.requests).toHaveLength(0); + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('failed'); + expect(row.last_error).toMatch(/disabled/i); + } finally { + await stub.close(); + } + }); + + test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => { + __test.setAllowPrivateUrls(false); + try { + const { id } = await insertWebhook('http://127.0.0.1:9/'); + await db('webhook_deliveries').insert({ + webhook_id: id, + event_type: 'event.published', + payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), + attempt_count: 0, + status: 'pending', + next_retry_at: new Date(), + created_at: new Date(), + }); + await __test.tick(); + + const row = await db('webhook_deliveries').where({ webhook_id: id }).first(); + expect(row.status).toBe('failed'); + expect(row.last_error).toMatch(/private|internal/i); + } finally { + __test.setAllowPrivateUrls(true); + } + }); + + test('worker can be started + stopped without leaking timers', async () => { + startWebhookDeliveryWorker(); + startWebhookDeliveryWorker(); // idempotent + stopWebhookDeliveryWorker(); + stopWebhookDeliveryWorker(); // idempotent + // If timers leaked the test runner would warn after force-exit; assertion + // is just "no throw". + expect(true).toBe(true); + }); +}); diff --git a/backend/__tests__/middleware/sessionTimeout.isSessionExpired.test.js b/backend/__tests__/middleware/sessionTimeout.isSessionExpired.test.js new file mode 100644 index 00000000..0b3180a7 --- /dev/null +++ b/backend/__tests__/middleware/sessionTimeout.isSessionExpired.test.js @@ -0,0 +1,112 @@ +/** + * Unit test for the non-mutating isSessionExpired() helper added to + * middleware/sessionTimeout.js. Used by GET /auth/session to mirror the + * timeout enforcement that sessionTimeoutMiddleware applies to /api/admin + * endpoints — closing the asymmetry that surfaced as the redirect-loop + * recurrence on v3.39.1-beta.0 (issue #350). + * + * The helper has two branches: + * 1. In-memory `lastActivity` exists for this token → expired iff + * now - lastActivity > timeout. + * 2. No in-memory entry (post-restart, or first request) → expired + * iff token's iat is older than the timeout (post-restart guard + * that the existing middleware already implements at line ~101). + * + * Both branches must NOT mutate the in-memory `sessions` Map — the + * middleware is the only place that tracks activity. We assert that. + */ + +jest.mock('../../src/database/db', () => ({ + db: () => ({ + where: () => ({ + first: () => ({ + timeout: () => Promise.resolve(null), + }), + }), + }), +})); + +// Speed up the cached-timeout reads. The module reads +// `security_session_timeout_minutes` from app_settings and falls back to +// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null. +const SIXTY_MINUTES_MS = 60 * 60 * 1000; + +const sessionTimeout = require('../../src/middleware/sessionTimeout'); +const { isSessionExpired } = sessionTimeout; + +function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) { + return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) }; +} + +describe('isSessionExpired (sessionTimeout helper)', () => { + it('returns false for a freshly-issued token with no in-memory record', async () => { + const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 }); + expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false); + }); + + it('returns true when iat is older than the timeout (post-restart guard)', async () => { + const decoded = makeDecodedToken({ + id: 2, + // 90 minutes > 60 minute default timeout + iatSecondsAgo: 90 * 60, + }); + expect(await isSessionExpired('stale-token-2', decoded)).toBe(true); + }); + + it('returns false / true based on lastActivity when one exists', async () => { + // Drive the in-memory map by running the actual middleware once to + // record activity for the token, then check the helper. + const decoded = makeDecodedToken({ id: 3 }); + + // Drive the actual middleware once with a real signed token so it + // records this token in the in-memory `sessions` Map. Then check the + // helper sees that recent activity and reports "not expired". + const res = { status: jest.fn(() => res), json: jest.fn() }; + const jwt = require('jsonwebtoken'); + process.env.JWT_SECRET = 'session-timeout-helper-test-secret'; + const realToken = jwt.sign(decoded, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + }); + const realReq = { + headers: { authorization: `Bearer ${realToken}` }, + cookies: {}, + }; + await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {}); + + const decodedReal = jwt.decode(realToken); + // Just-recorded → not expired + expect(await isSessionExpired(realToken, decodedReal)).toBe(false); + }); + + it('returns false when token / decoded is missing (defensive)', async () => { + expect(await isSessionExpired(null, { id: 1 })).toBe(false); + expect(await isSessionExpired('tok', null)).toBe(false); + expect(await isSessionExpired('tok', {})).toBe(false); + }); + + // Sanity: the helper must not poke the `sessions` Map. Indirectly check + // by counting active sessions before/after a call with a never-seen + // token — should not change. + it('does not mutate the in-memory sessions map', async () => { + const before = sessionTimeout.getActiveSessions(); + await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 })); + const after = sessionTimeout.getActiveSessions(); + expect(after).toBe(before); + }); + + it('uses the default 60-minute timeout when no DB setting exists', async () => { + // 59 minutes → not expired + const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 }); + expect(await isSessionExpired('fresh-4', fresh)).toBe(false); + + // 61 minutes → expired (just past the default) + const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 }); + expect(await isSessionExpired('stale-5', stale)).toBe(true); + }); + + // Document the constant the test relies on so a future timeout change + // makes this assertion explicit rather than mysterious. + it('default timeout is 60 minutes (constant under test)', () => { + expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000); + }); +}); diff --git a/backend/__tests__/routes/authSession.symmetry.test.js b/backend/__tests__/routes/authSession.symmetry.test.js new file mode 100644 index 00000000..50ba848f --- /dev/null +++ b/backend/__tests__/routes/authSession.symmetry.test.js @@ -0,0 +1,395 @@ +/** + * Regression test for the /admin/login → /admin/dashboard → /admin/login + * redirect loop reported on v3.32.4-beta.0. + * + * Cause: GET /auth/session was less strict than the adminAuth middleware. + * The session endpoint accepted tokens that the protected endpoints + * subsequently rejected with 401, which the frontend's interceptor + * translated into a hard redirect to /admin/login. /auth/session then + * said "valid: true" again on the next page load and the cycle closed. + * + * /auth/session must reject the same admin tokens adminAuth would + * reject, specifically: deactivated admin user, deleted admin user, + * password changed since iat. Same for gallery: archived event. + */ + +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); + +process.env.JWT_SECRET = 'session-symmetry-test-secret'; + +const fakeDb = { + adminUsers: [], + events: [], + revokedTokens: [], +}; + +jest.mock('../../src/database/db', () => { + const formatBoolean = (v) => (v ? 1 : 0); + void formatBoolean; + function dbFn(table) { + if (table === 'admin_users') { + let rowFilter = () => true; + return { + where(criteria) { + rowFilter = (row) => { + return Object.entries(criteria).every(([k, v]) => { + if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); + return row[k] === v; + }); + }; + return this; + }, + select(...cols) { + this._cols = cols; + return this; + }, + async first() { + const row = fakeDb.adminUsers.find(rowFilter); + if (!row) return undefined; + if (!this._cols) return row; + const out = {}; + for (const c of this._cols) out[c] = row[c]; + return out; + }, + }; + } + if (table === 'events') { + let rowFilter = () => true; + return { + where(criteria) { + rowFilter = (row) => + Object.entries(criteria).every(([k, v]) => { + if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); + if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v); + return row[k] === v; + }); + return this; + }, + async first() { + return fakeDb.events.find(rowFilter); + }, + }; + } + throw new Error(`Unexpected table: ${table}`); + } + return { db: dbFn, formatBoolean: () => 1 }; +}); + +jest.mock('../../src/utils/dbCompat', () => ({ + formatBoolean: (v) => (v ? 1 : 0), +})); + +jest.mock('../../src/utils/tokenRevocation', () => ({ + isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)), + revokeToken: jest.fn(), +})); + +jest.mock('../../src/utils/tokenUtils', () => ({ + getAdminTokenFromRequest: (req) => { + const auth = req.headers.authorization; + if (auth && auth.startsWith('Bearer ')) return auth.slice(7); + return null; + }, + getGalleryTokenFromRequest: () => null, + setAdminAuthCookie: jest.fn(), + setGalleryAuthCookies: jest.fn(), + clearAdminAuthCookie: jest.fn(), + clearGalleryAuthCookies: jest.fn(), + buildCookieOptionsWithExpiry: () => ({}), +})); + +jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) })); +// Mock sessionTimeout's isSessionExpired so each test controls the return. +// Default: not expired (so existing tests keep passing without setup). +jest.mock('../../src/middleware/sessionTimeout', () => ({ + endSession: jest.fn(), + isSessionExpired: jest.fn(() => Promise.resolve(false)), +})); +jest.mock('../../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const authRouter = require('../../src/routes/auth'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/auth', authRouter); + return app; +} + +function signAdminToken({ id = 1, username = 'admin', iat, exp }) { + const issuedAt = iat ?? Math.floor(Date.now() / 1000); + // Note: do NOT pass noTimestamp:true here — that strips iat from the + // payload entirely, defeating the password-change comparison. Provide + // iat (and exp) via the payload directly instead. + return jwt.sign( + { id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 }, + process.env.JWT_SECRET, + { issuer: 'picpeak-auth' } + ); +} + +function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) { + return jwt.sign( + { eventId, eventSlug, type: 'gallery' }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' } + ); +} + +describe('GET /auth/session — symmetry with protected middleware', () => { + beforeEach(() => { + fakeDb.adminUsers = []; + fakeDb.events = []; + fakeDb.revokedTokens = []; + }); + + it('returns valid:true for an active admin token', async () => { + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + email: 'a@b.com', + is_active: true, + password_changed_at: null, + }); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(res.body.type).toBe('admin'); + }); + + it('returns valid:false when the admin user has been deactivated', async () => { + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + email: 'a@b.com', + is_active: false, + password_changed_at: null, + }); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns valid:false when the admin user no longer exists', async () => { + // adminUsers is empty + const token = signAdminToken({ id: 999 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns valid:false when password was changed after the token was issued', async () => { + // iat must be in the past, exp must be in the future so jwt.verify + // doesn't reject the token before /auth/session even gets to look + // at password_changed_at. + const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago + const tokenExp = tokenIssuedAt + 86400; + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + email: 'a@b.com', + is_active: true, + password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat + }); + const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns valid:true when password was changed BEFORE the token was issued', async () => { + const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; + const tokenExp = tokenIssuedAt + 86400; + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + email: 'a@b.com', + is_active: true, + password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat + }); + const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + + it('returns valid:false for a gallery token whose event is archived', async () => { + fakeDb.events.push({ + id: 100, + slug: 'wedding', + is_active: true, + is_archived: true, + expires_at: null, + }); + const token = signGalleryToken(); + + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns valid:false for a gallery token whose event is expired', async () => { + fakeDb.events.push({ + id: 100, + slug: 'wedding', + is_active: true, + is_archived: false, + expires_at: new Date(Date.now() - 86400_000), + }); + const token = signGalleryToken(); + + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it('returns valid:true for an active gallery token', async () => { + fakeDb.events.push({ + id: 100, + slug: 'wedding', + is_active: true, + is_archived: false, + expires_at: new Date(Date.now() + 86400_000), + }); + const token = signGalleryToken(); + + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + + it('returns valid:false when the token is revoked', async () => { + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + is_active: true, + password_changed_at: null, + }); + fakeDb.revokedTokens.push(1); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(401); + expect(res.body.valid).toBe(false); + }); + + // Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0. + // sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat + // tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't. + // The new isSessionExpired helper closes that asymmetry. + describe('session-timeout symmetry', () => { + const { isSessionExpired } = require('../../src/middleware/sessionTimeout'); + + beforeEach(() => { + isSessionExpired.mockReset(); + // Default to "active session" so the other admin checks above also + // pass when this branch runs. + isSessionExpired.mockResolvedValue(false); + }); + + it('returns valid:false when isSessionExpired reports the token has timed out', async () => { + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + is_active: true, + password_changed_at: null, + }); + isSessionExpired.mockResolvedValue(true); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.error).toBe('Session expired'); + }); + + it('returns valid:true for an active admin token (helper says not expired)', async () => { + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + is_active: true, + password_changed_at: null, + }); + isSessionExpired.mockResolvedValue(false); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(isSessionExpired).toHaveBeenCalledTimes(1); + }); + + it('does not call isSessionExpired for gallery tokens', async () => { + fakeDb.events.push({ + id: 100, + slug: 'wedding', + is_active: true, + is_archived: false, + expires_at: new Date(Date.now() + 86400_000), + }); + const token = signGalleryToken(); + + const res = await request(makeApp()) + .get('/auth/session?slug=wedding') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(isSessionExpired).not.toHaveBeenCalled(); + }); + + it('falls through (treats as valid) if the helper itself throws', async () => { + // Defensive: the require() in auth.js is wrapped in try/catch so a + // missing/broken helper doesn't fail-closed during early bootstrap. + fakeDb.adminUsers.push({ + id: 1, + username: 'admin', + is_active: true, + password_changed_at: null, + }); + isSessionExpired.mockRejectedValue(new Error('boom')); + const token = signAdminToken({ id: 1 }); + + const res = await request(makeApp()) + .get('/auth/session') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + }); +}); diff --git a/backend/__tests__/services/backgroundProcessor.test.js b/backend/__tests__/services/backgroundProcessor.test.js new file mode 100644 index 00000000..2819dfd7 --- /dev/null +++ b/backend/__tests__/services/backgroundProcessor.test.js @@ -0,0 +1,109 @@ +/** + * Unit tests for backgroundProcessor.claimNextPhoto. + * + * Mocks the db so we don't need a live postgres/sqlite — focuses on + * the claim contract: returns null when no rows, returns row + flips + * status to 'processing' when one is available, returns null when a + * race loses the UPDATE-with-guard. + */ + +jest.mock('../../src/services/photoProcessor', () => ({ + processPhoto: jest.fn(), + processUploadedPhotos: jest.fn(), + queueFilesForProcessing: jest.fn(), +})); + +// Build a fake knex instance whose .transaction() takes a callback we can +// drive from the test, and whose query-builder records calls. +function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) { + const queries = []; + + const builder = () => { + const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false }; + queries.push(recorded); + const chain = { + where: jest.fn(function (...args) { + recorded.wheres.push(args); + return chain; + }), + orderBy: jest.fn(function () { + recorded.ordered = true; + return chain; + }), + forUpdate: jest.fn(function () { + recorded.locked = true; + return chain; + }), + skipLocked: jest.fn(function () { + recorded.skipped = true; + return chain; + }), + first: jest.fn(async function () { + // Only the SELECT chain returns the pending row; the UPDATE chain + // never calls .first(). + return pendingRow ? { ...pendingRow } : null; + }), + update: jest.fn(async function (data) { + recorded.updates = data; + return updateResult; + }), + }; + return chain; + }; + + const trxFn = (table) => builder(table); + trxFn.client = { config: { client: clientName } }; + trxFn.transaction = async (cb) => cb(trxFn); + + // Top-level db('photos') returns same builder for the janitor test path. + const db = trxFn; + return { db, queries }; +} + +describe('backgroundProcessor.claimNextPhoto', () => { + function loadProcessor(db) { + jest.resetModules(); + jest.doMock('../../src/database/db', () => ({ db })); + return require('../../src/services/backgroundProcessor'); + } + + it('returns null when there are no pending photos (postgres path)', async () => { + const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toBeNull(); + }); + + it('returns the claimed row and flips status (postgres path)', async () => { + const pendingRow = { id: 42, processing_status: 'pending' }; + const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toEqual(pendingRow); + // The first query is the SELECT FOR UPDATE SKIP LOCKED. + expect(queries[0].locked).toBe(true); + expect(queries[0].skipped).toBe(true); + // The second query is the status update. + expect(queries[1].updates.processing_status).toBe('processing'); + expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date); + }); + + it('returns null when the SQLite UPDATE-with-guard loses the race', async () => { + const pendingRow = { id: 7 }; + const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toBeNull(); + }); + + it('returns the row when SQLite UPDATE-with-guard wins', async () => { + const pendingRow = { id: 7 }; + const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 }); + const bg = loadProcessor(db); + const result = await bg.claimNextPhoto(); + expect(result).toEqual(pendingRow); + // SQLite path: no FOR UPDATE / SKIP LOCKED. + expect(queries[0].locked).toBe(false); + expect(queries[0].skipped).toBe(false); + }); +}); diff --git a/backend/__tests__/services/emailProcessor.htmlToText.test.js b/backend/__tests__/services/emailProcessor.htmlToText.test.js new file mode 100644 index 00000000..f490da36 --- /dev/null +++ b/backend/__tests__/services/emailProcessor.htmlToText.test.js @@ -0,0 +1,75 @@ +/** + * Unit tests for emailProcessor.htmlToText. + * + * Regression: when a template ships without a body_text, sendTemplateEmail + * used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback. + * That regex strips angle-bracket tags but leaves the *contents* of Hello'; + const out = htmlToText(html); + expect(out).toBe('Hello'); + expect(out).not.toMatch(/margin/); + expect(out).not.toMatch(/color/); + }); + + it('strips Hi'; + expect(htmlToText(html)).toBe('Hi'); + }); + + it('converts
tags to newlines', () => { + expect(htmlToText('a
b
c
d')).toBe('a\nb\nc\nd'); + }); + + it('keeps a paragraph break between adjacent

tags', () => { + expect(htmlToText('

one

two

')).toBe('one\n\ntwo'); + }); + + it('decodes the common HTML entities', () => { + expect(htmlToText('Tom & Jerry <3 "hi"')) + .toBe('Tom & Jerry <3 "hi"'); + }); + + it('handles a fully-wrapped email body without leaking CSS rules', () => { + // Shape mirrors what wrapEmailHtml() produces: a + + +

Galerie erfolgreich erstellt

+

Liebe(r) Natalie,

+ +`; + const out = htmlToText(wrapped); + expect(out).toContain('Galerie erfolgreich erstellt'); + expect(out).toContain('Liebe(r) Natalie'); + expect(out).not.toMatch(/margin/); + expect(out).not.toMatch(/font-family/); + expect(out).not.toMatch(/background-color/); + expect(out).not.toMatch(/\.button/); + }); +}); diff --git a/backend/__tests__/services/emailProcessor.safeTemplateReplace.test.js b/backend/__tests__/services/emailProcessor.safeTemplateReplace.test.js new file mode 100644 index 00000000..7e3ef16c --- /dev/null +++ b/backend/__tests__/services/emailProcessor.safeTemplateReplace.test.js @@ -0,0 +1,138 @@ +/** + * Unit tests for emailProcessor.safeTemplateReplace. + * + * Covers the two regressions that hit picpeak.nothaft.cloud on the + * 3.32.x betas: + * - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email + * because the renderer only handled {{var}} substitution and the + * shipped templates use Handlebars-style conditionals. + * - {{var}} substitution inside a kept conditional block. + * + * The publish-from-draft password localisation lives inside the wider + * processTemplate() pipeline (DB-backed), so it isn't covered here — the + * sentinel string '(set at creation)' is asserted only at the i18n-map + * level by integration in adminEvents.js. + */ + +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); + +const { safeTemplateReplace } = require('../../src/services/emailProcessor'); + +describe('safeTemplateReplace', () => { + describe('flat variable substitution', () => { + it('replaces {{var}} with the variable value', () => { + expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' })) + .toBe('Hello Paul!'); + }); + + it('leaves unknown variables untouched', () => { + expect(safeTemplateReplace('Hello {{name}}!', {})) + .toBe('Hello {{name}}!'); + }); + + it('coerces non-string values to string', () => { + expect(safeTemplateReplace('Count: {{n}}', { n: 42 })) + .toBe('Count: 42'); + }); + + it('handles empty templates and missing variables map', () => { + expect(safeTemplateReplace('', { x: 1 })).toBe(''); + expect(safeTemplateReplace('plain text', undefined)).toBe('plain text'); + expect(safeTemplateReplace(null, {})).toBe(null); + }); + }); + + describe('{{#if VAR}}…{{/if}} blocks', () => { + it('strips the block when the variable is missing', () => { + const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after'; + expect(safeTemplateReplace(tpl, {})).toBe('before after'); + }); + + it('strips the block when the variable is an empty string', () => { + const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after'; + expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after'); + }); + + it('strips the block when the variable is null', () => { + const tpl = '{{#if x}}kept{{/if}}'; + expect(safeTemplateReplace(tpl, { x: null })).toBe(''); + }); + + it('keeps the block and substitutes inside it when truthy', () => { + const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after'; + expect(safeTemplateReplace(tpl, { welcome: 'world' })) + .toBe('before HELLO world after'); + }); + + it('handles multi-line conditional blocks', () => { + const tpl = [ + 'Liebe(r) {{host_name}},', + '', + '{{#if welcome_message}}', + 'Persönliche Nachricht:', + '{{welcome_message}}', + '{{/if}}', + 'Galerie-Details:', + ].join('\n'); + + const withMsg = safeTemplateReplace(tpl, { + host_name: 'Natalie', + welcome_message: 'Schön, dass ihr da seid!', + }); + expect(withMsg).toContain('Persönliche Nachricht:'); + expect(withMsg).toContain('Schön, dass ihr da seid!'); + expect(withMsg).not.toContain('{{#if'); + expect(withMsg).not.toContain('{{/if'); + + const withoutMsg = safeTemplateReplace(tpl, { + host_name: 'Natalie', + welcome_message: '', + }); + expect(withoutMsg).not.toContain('Persönliche Nachricht'); + expect(withoutMsg).not.toContain('{{#if'); + expect(withoutMsg).not.toContain('{{/if'); + expect(withoutMsg).toContain('Liebe(r) Natalie,'); + expect(withoutMsg).toContain('Galerie-Details:'); + }); + + it('handles multiple sibling conditionals independently', () => { + const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}'; + expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C'); + }); + + it('treats numeric 0 as falsy', () => { + expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe(''); + }); + }); + + describe('HTML escaping (escapeHtml: true)', () => { + it('does not escape by default', () => { + const tpl = 'Welcome to {{event_name}}'; + expect(safeTemplateReplace(tpl, { event_name: 'Test ' }, { escapeHtml: true })) + .toBe('Welcome to Test <script>alert(1)</script>'); + }); + + it('escapes both the < > and & characters and quotes', () => { + expect(safeTemplateReplace('{{x}}', { x: 'A & B\'s' }, { escapeHtml: true })) + .toBe('<a href="evil">A & B's</a>'); + }); + + it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => { + const tpl = '

{{welcome_message}}

'; + expect(safeTemplateReplace(tpl, { welcome_message: 'Hi
there' }, { escapeHtml: true })) + .toBe('

Hi
there

'); + }); + + it('passes server-generated URLs through unescaped', () => { + const tpl = 'link'; + expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true })) + .toBe('link'); + }); + }); +}); diff --git a/backend/__tests__/services/fontsService.test.js b/backend/__tests__/services/fontsService.test.js new file mode 100644 index 00000000..c85d79c7 --- /dev/null +++ b/backend/__tests__/services/fontsService.test.js @@ -0,0 +1,320 @@ +const fs = require('fs'); +const fsPromises = fs.promises; +const os = require('os'); +const path = require('path'); + +// Silence the logger so test output stays clean. Capture calls so the +// "warning logged" assertions can still verify behaviour. +jest.mock('../../src/utils/logger', () => ({ + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + debug: jest.fn() +})); + +const logger = require('../../src/utils/logger'); +// Required ONCE at module top so the jest.mock factory above applies to +// the logger reference that fontsService captures. A previous version +// re-required it inside beforeEach() with jest.resetModules() — that +// silently bypassed the mock (logger calls went to the real logger), +// so the "warning logged" assertions would resolve as 0 calls and +// silently pass-as-noop. Module-level state in fontsService is just +// the cache, which clearFontsCache() resets between tests. +const fontsService = require('../../src/services/fontsService'); + +// Probe at load time: is the host filesystem case-sensitive? +// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same +// directory entry, which means the "two folders, same lowercase key" +// dedup test below can't be set up via real folders on those platforms — +// the second mkdir is a no-op. Skip that one test conditionally. +const FS_IS_CASE_SENSITIVE = (() => { + const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-')); + fs.writeFileSync(path.join(probeDir, 'casetest'), ''); + let sensitive = true; + try { + fs.accessSync(path.join(probeDir, 'CASETEST')); + sensitive = false; + } catch { /* file not found → case-sensitive FS */ } + fs.rmSync(probeDir, { recursive: true, force: true }); + return sensitive; +})(); +const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip; + +let bundledRoot; +let userRoot; + +/** + * Create a font family folder with the given weights (and optional meta.json). + * @param {string} root absolute path to the bundled or user root + * @param {string} folderName e.g. "Inter" or "Playfair-Display" + * @param {Array|Array} weights numeric weights (creates `.woff2`) + * or filenames to create directly + * @param {Object|null} meta optional meta.json contents (object) or null + */ +async function makeFamily(root, folderName, weights, meta = null) { + const dir = path.join(root, folderName); + await fsPromises.mkdir(dir, { recursive: true }); + for (const w of weights) { + const fname = typeof w === 'number' ? `${w}.woff2` : w; + await fsPromises.writeFile(path.join(dir, fname), Buffer.from([])); + } + if (meta !== null) { + await fsPromises.writeFile( + path.join(dir, 'meta.json'), + typeof meta === 'string' ? meta : JSON.stringify(meta) + ); + } + return dir; +} + +beforeEach(async () => { + jest.clearAllMocks(); + + bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-')); + userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-')); + + process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot; + // The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must + // point at the parent of userRoot — we name the leaf "fonts" ourselves. + const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-')); + await fsPromises.rename(userRoot, path.join(storageParent, 'fonts')); + userRoot = path.join(storageParent, 'fonts'); + process.env.STORAGE_PATH = storageParent; + + // Reset the module-level cache so each test sees a fresh scan. + // (Both getBundledFontsRoot and getUserFontsRoot read process.env at + // call-time, so the env vars set above are picked up without needing + // to re-require the module — see fontsService.js getBundledFontsRoot / + // getUserFontsRoot.) + fontsService.clearFontsCache(); +}); + +afterEach(async () => { + fontsService.clearFontsCache(); + await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {}); + // userRoot's parent is the actual mkdtemp; remove it. + await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {}); + delete process.env.PICPEAK_BUNDLED_FONTS_ROOT; + delete process.env.STORAGE_PATH; +}); + +describe('fontsService.listFonts', () => { + describe('roots', () => { + test('empty bundled root + missing user root → []', async () => { + // delete user root so it triggers ENOENT + await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }); + const fonts = await fontsService.listFonts(); + expect(fonts).toEqual([]); + }); + + test('missing bundled root (ENOENT) → [], does not throw', async () => { + await fsPromises.rm(bundledRoot, { recursive: true, force: true }); + const fonts = await fontsService.listFonts(); + expect(fonts).toEqual([]); + }); + + test('non-directory entries at the root are skipped', async () => { + await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi'); + await makeFamily(bundledRoot, 'Inter', [400, 700]); + const fonts = await fontsService.listFonts(); + expect(fonts.map((f) => f.family)).toEqual(['Inter']); + }); + + test('hidden folders are skipped', async () => { + await makeFamily(bundledRoot, '.git', [400]); + await makeFamily(bundledRoot, '.DS_Store', [400]); + await makeFamily(bundledRoot, 'Inter', [400]); + const fonts = await fontsService.listFonts(); + expect(fonts.map((f) => f.family)).toEqual(['Inter']); + }); + }); + + describe('weight parsing', () => { + test('three weight files → sorted ascending', async () => { + await makeFamily(bundledRoot, 'Inter', [700, 400, 600]); + const [inter] = await fontsService.listFonts(); + expect(inter.weights).toEqual([400, 600, 700]); + }); + + test('non-numeric filenames are ignored', async () => { + await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']); + const [inter] = await fontsService.listFonts(); + expect(inter.weights).toEqual([400, 700]); + }); + + test('non-.woff2 files are ignored', async () => { + await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']); + const [inter] = await fontsService.listFonts(); + expect(inter.weights).toEqual([400]); + }); + + test('weight values out of range (sub-1 / over-1000) are ignored', async () => { + await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]); + const [inter] = await fontsService.listFonts(); + expect(inter.weights).toEqual([400, 700]); + }); + + test('family folder with no usable .woff2 files is silently skipped', async () => { + await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']); + await makeFamily(bundledRoot, 'Inter', [400]); + const fonts = await fontsService.listFonts(); + expect(fonts.map((f) => f.family)).toEqual(['Inter']); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping NoWeights') + ); + }); + }); + + describe('folder name → display family', () => { + test('hyphens become spaces', async () => { + await makeFamily(bundledRoot, 'Playfair-Display', [400]); + const [pd] = await fontsService.listFonts(); + expect(pd.family).toBe('Playfair Display'); + }); + + test('case is preserved', async () => { + await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]); + const [ibm] = await fontsService.listFonts(); + expect(ibm.family).toBe('IBM Plex Sans'); + }); + }); + + describe('user-overrides-bundled', () => { + test('user folder of the same family wins; weights come from user', async () => { + await makeFamily(bundledRoot, 'Inter', [400, 600, 700]); + await makeFamily(userRoot, 'Inter', [400, 900]); // different weights + const [inter] = await fontsService.listFonts(); + expect(inter.weights).toEqual([400, 900]); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('overrides bundled default') + ); + }); + + test('user-only family is included', async () => { + await makeFamily(userRoot, 'Lobster', [400]); + const fonts = await fontsService.listFonts(); + expect(fonts.map((f) => f.family)).toEqual(['Lobster']); + }); + + testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => { + // Two folder names whose lowercase keys collide. On a case-sensitive + // FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a + // case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir + // resolves to the same directory as the first and the dedup branch + // is unreachable from this test setup — see testCaseSensitiveFS above. + await makeFamily(bundledRoot, 'Inter', [400]); + await makeFamily(bundledRoot, 'INTER', [700]); + const fonts = await fontsService.listFonts(); + expect(fonts).toHaveLength(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Duplicate family') + ); + }); + }); + + describe('meta.json — generic fallback', () => { + test('valid generic="serif"', async () => { + await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' }); + const [pd] = await fontsService.listFonts(); + expect(pd.generic).toBe('serif'); + }); + + test('valid generic="cursive"', async () => { + await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' }); + const [cn] = await fontsService.listFonts(); + expect(cn.generic).toBe('cursive'); + }); + + test('valid generic="monospace"', async () => { + await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' }); + const [fm] = await fontsService.listFonts(); + expect(fm.generic).toBe('monospace'); + }); + + test('missing meta.json → defaults to sans-serif (no warning)', async () => { + await makeFamily(bundledRoot, 'Inter', [400]); + const [inter] = await fontsService.listFonts(); + expect(inter.generic).toBe('sans-serif'); + // No warning for the missing-file case (it's the normal path). + const noisy = (logger.warn.mock.calls || []).filter((c) => + String(c[0]).includes('meta.json') + ); + expect(noisy).toEqual([]); + }); + + test('invalid generic value → defaults to sans-serif, warning logged', async () => { + await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' }); + const [inter] = await fontsService.listFonts(); + expect(inter.generic).toBe('sans-serif'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('invalid generic "bogus"') + ); + }); + + test('malformed JSON → defaults to sans-serif, warning logged', async () => { + await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json'); + const [inter] = await fontsService.listFonts(); + expect(inter.generic).toBe('sans-serif'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not valid JSON') + ); + }); + }); + + describe('result shape', () => { + test('every family is { family, weights, generic }', async () => { + await makeFamily(bundledRoot, 'Inter', [400, 700]); + await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' }); + const fonts = await fontsService.listFonts(); + for (const f of fonts) { + expect(f).toEqual({ + family: expect.any(String), + weights: expect.any(Array), + generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/) + }); + expect(f.weights.length).toBeGreaterThan(0); + } + }); + + test('output sorted alphabetically by family', async () => { + await makeFamily(bundledRoot, 'Zilla-Slab', [400]); + await makeFamily(bundledRoot, 'Alpha-Sans', [400]); + await makeFamily(bundledRoot, 'Mid-Pack', [400]); + const fonts = await fontsService.listFonts(); + expect(fonts.map((f) => f.family)).toEqual([ + 'Alpha Sans', + 'Mid Pack', + 'Zilla Slab' + ]); + }); + }); + + describe('cache', () => { + test('cache hit: second call within TTL does not re-readdir', async () => { + await makeFamily(bundledRoot, 'Inter', [400]); + const spy = jest.spyOn(fsPromises, 'readdir'); + await fontsService.listFonts(); + const callsAfterFirst = spy.mock.calls.length; + await fontsService.listFonts(); + expect(spy.mock.calls.length).toBe(callsAfterFirst); + spy.mockRestore(); + }); + + test('clearFontsCache forces a fresh scan on the next call', async () => { + await makeFamily(bundledRoot, 'Inter', [400]); + await fontsService.listFonts(); + + // Add a new family AFTER the cache was populated. + await makeFamily(bundledRoot, 'Roboto', [400]); + + // Without clearing, listFonts returns the stale cache. + const stale = await fontsService.listFonts(); + expect(stale.map((f) => f.family)).toEqual(['Inter']); + + // After clear, the new family appears. + fontsService.clearFontsCache(); + const fresh = await fontsService.listFonts(); + expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']); + }); + }); +}); diff --git a/backend/__tests__/services/photoProcessor.processPhoto.test.js b/backend/__tests__/services/photoProcessor.processPhoto.test.js new file mode 100644 index 00000000..763b213e --- /dev/null +++ b/backend/__tests__/services/photoProcessor.processPhoto.test.js @@ -0,0 +1,216 @@ +/** + * Unit tests for photoProcessor.processPhoto — the worker-mode entry + * point that runs after a row has been claimed by the background + * processor. Mocks every external dependency and validates the + * happy-path DB updates and side-effect ordering. + * + * jest.mock factories are evaluated before any local variables exist, + * so collaborators are kept inside the mock factories themselves and + * the test reaches into them via require() once they're set up. + */ + +const path = require('path'); + +jest.mock('../../src/database/db', () => { + const recorded = { whereCalls: [], updateCalls: [] }; + let pendingWhere = null; + const photosState = { row: null }; + const eventsState = { row: null }; + + function makePhotoQuery() { + return { + where(args) { + pendingWhere = args; + recorded.whereCalls.push(args); + return this; + }, + async first() { + return photosState.row; + }, + async update(data) { + recorded.updateCalls.push({ where: pendingWhere, data }); + return 1; + }, + }; + } + + function makeEventsQuery() { + return { + where() { + return this; + }, + async first() { + return eventsState.row; + }, + }; + } + + function dbFn(table) { + if (table === 'photos') return makePhotoQuery(); + if (table === 'events') return makeEventsQuery(); + throw new Error(`Unexpected table: ${table}`); + } + dbFn.client = { config: { client: 'pg' } }; + + return { + db: dbFn, + __setPhoto: (row) => { photosState.row = row; }, + __setEvent: (row) => { eventsState.row = row; }, + __reset: () => { + recorded.whereCalls = []; + recorded.updateCalls = []; + photosState.row = null; + eventsState.row = null; + }, + __recorded: () => recorded, + }; +}); + +jest.mock('../../src/services/imageProcessor', () => { + const mockGenerateThumbnail = jest.fn(); + const mockExtractCaptureDate = jest.fn(); + return { + generateThumbnail: mockGenerateThumbnail, + extractCaptureDate: mockExtractCaptureDate, + withLocalCopy: jest.fn(async (key, fn) => + fn(`/tmp/local-copy-${require('path').basename(key)}`) + ), + }; +}); + +jest.mock('../../src/services/videoProcessor', () => ({ + processUploadedVideo: jest.fn(), + isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'), +})); + +jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() })); + +jest.mock('../../src/services/photoResolver', () => ({ + resolvePhotoStorageKey: jest.fn( + (event, photo) => `events/active/${event.slug}/${photo.filename}` + ), +})); + +jest.mock('../../src/utils/filenameSanitizer', () => ({ + generatePhotoFilename: jest.fn(() => 'whatever.jpg'), +})); + +jest.mock('../../src/services/watermarkGeneratorService', () => ({ + generateForPhoto: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../src/services/webhookService', () => ({ + fire: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../src/utils/logger', () => ({ + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +// Stub sharp so we don't actually read any image off disk. +jest.mock('sharp', () => { + const mock = jest.fn(() => ({ + metadata: jest.fn(async () => ({ width: 1920, height: 1080 })), + })); + return mock; +}); + +const dbModule = require('../../src/database/db'); +const imageProcessor = require('../../src/services/imageProcessor'); +const videoProcessor = require('../../src/services/videoProcessor'); +const watermarkService = require('../../src/services/watermarkGeneratorService'); +const webhookService = require('../../src/services/webhookService'); + +beforeEach(() => { + dbModule.__reset(); + jest.clearAllMocks(); +}); + +describe('photoProcessor.processPhoto', () => { + it('marks an image complete with thumbnail and dimensions', async () => { + dbModule.__setPhoto({ + id: 101, + event_id: 5, + filename: 'wedding-001.jpg', + original_filename: 'IMG_0001.jpg', + mime_type: 'image/jpeg', + media_type: 'image', + size_bytes: 12345, + captured_at: null, + processing_status: 'processing', + }); + dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' }); + + imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z'); + imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg'); + + const { processPhoto } = require('../../src/services/photoProcessor'); + await processPhoto(101); + + const finalUpdate = dbModule.__recorded().updateCalls.pop(); + expect(finalUpdate.data.processing_status).toBe('complete'); + expect(finalUpdate.data.processing_error).toBeNull(); + expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg'); + expect(finalUpdate.data.width).toBe(1920); + expect(finalUpdate.data.height).toBe(1080); + expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z'); + + expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101); + expect(webhookService.fire).toHaveBeenCalledWith( + 'photo.uploaded', + expect.objectContaining({ + event: expect.objectContaining({ slug: 'wedding' }), + photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }), + }) + ); + }); + + it('handles videos with ffmpeg metadata path', async () => { + dbModule.__setPhoto({ + id: 202, + event_id: 9, + filename: 'wedding-video-001.mp4', + original_filename: 'movie.mp4', + mime_type: 'video/mp4', + media_type: 'video', + size_bytes: 99999, + captured_at: null, + }); + dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' }); + + videoProcessor.processUploadedVideo.mockResolvedValueOnce({ + thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg', + metadata: { + duration: 12.5, + videoCodec: 'h264', + audioCodec: 'aac', + width: 1280, + height: 720, + }, + }); + + const { processPhoto } = require('../../src/services/photoProcessor'); + await processPhoto(202); + + const finalUpdate = dbModule.__recorded().updateCalls.pop(); + expect(finalUpdate.data.processing_status).toBe('complete'); + expect(finalUpdate.data.duration).toBe(12.5); + expect(finalUpdate.data.video_codec).toBe('h264'); + expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg'); + + // Watermark queue is image-only. + expect(watermarkService.generateForPhoto).not.toHaveBeenCalled(); + }); + + it('throws when the photo row no longer exists', async () => { + dbModule.__setPhoto(null); + dbModule.__setEvent({ id: 1 }); + const { processPhoto } = require('../../src/services/photoProcessor'); + await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/); + }); +}); + +void path; // referenced indirectly via mocks diff --git a/backend/__tests__/utils/formatters.test.js b/backend/__tests__/utils/formatters.test.js new file mode 100644 index 00000000..c5f8f9ac --- /dev/null +++ b/backend/__tests__/utils/formatters.test.js @@ -0,0 +1,66 @@ +/** + * Unit tests for formatters.js — focused on the HTML-escape behaviour added + * so admin-supplied welcome messages can't inject markup into customer mail. + */ + +const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters'); + +describe('escapeHtml', () => { + it('escapes the five HTML metacharacters', () => { + expect(escapeHtml('& < > " \'')).toBe('& < > " ''); + }); + + it('returns empty string for null/undefined', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); + + it('coerces non-string values', () => { + expect(escapeHtml(42)).toBe('42'); + }); + + it('escapes & before introducing new entities', () => { + expect(escapeHtml('<&>')).toBe('<&>'); + }); +}); + +describe('nl2br', () => { + it('joins non-empty lines with
', () => { + expect(nl2br('a\nb\nc')).toBe('a
b
c'); + }); + + it('normalises CRLF and CR', () => { + expect(nl2br('a\r\nb\rc')).toBe('a
b
c'); + }); + + it('drops empty lines', () => { + expect(nl2br('a\n\n\nb')).toBe('a
b'); + }); + + it('returns empty for empty input', () => { + expect(nl2br('')).toBe(''); + expect(nl2br(null)).toBe(''); + }); +}); + +describe('formatWelcomeMessage', () => { + it('returns empty string for empty input', () => { + expect(formatWelcomeMessage('')).toBe(''); + expect(formatWelcomeMessage(' ')).toBe(''); + }); + + it('escapes HTML metacharacters before nl2br', () => { + expect(formatWelcomeMessage('Hello world')) + .toBe('Hello <b>world</b>'); + }); + + it('renders newlines as
while keeping content escaped', () => { + expect(formatWelcomeMessage('line 1\n\nline 3')) + .toBe('line 1
<script>x</script>
line 3'); + }); + + it('escapes ampersands and quotes that would otherwise break HTML', () => { + expect(formatWelcomeMessage('Tom & Jerry\'s "show"')) + .toBe('Tom & Jerry's "show"'); + }); +}); diff --git a/backend/assets/fonts/Comic-Neue/400.woff2 b/backend/assets/fonts/Comic-Neue/400.woff2 new file mode 100644 index 00000000..4f73b721 Binary files /dev/null and b/backend/assets/fonts/Comic-Neue/400.woff2 differ diff --git a/backend/assets/fonts/Comic-Neue/700.woff2 b/backend/assets/fonts/Comic-Neue/700.woff2 new file mode 100644 index 00000000..e25f252f Binary files /dev/null and b/backend/assets/fonts/Comic-Neue/700.woff2 differ diff --git a/backend/assets/fonts/Comic-Neue/meta.json b/backend/assets/fonts/Comic-Neue/meta.json new file mode 100644 index 00000000..9ff729a5 --- /dev/null +++ b/backend/assets/fonts/Comic-Neue/meta.json @@ -0,0 +1,3 @@ +{ + "generic": "cursive" +} diff --git a/backend/assets/fonts/IBM-Plex-Sans/400.woff2 b/backend/assets/fonts/IBM-Plex-Sans/400.woff2 new file mode 100644 index 00000000..f0ee65dd Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/400.woff2 differ diff --git a/backend/assets/fonts/IBM-Plex-Sans/600.woff2 b/backend/assets/fonts/IBM-Plex-Sans/600.woff2 new file mode 100644 index 00000000..08c0d5ad Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/600.woff2 differ diff --git a/backend/assets/fonts/IBM-Plex-Sans/700.woff2 b/backend/assets/fonts/IBM-Plex-Sans/700.woff2 new file mode 100644 index 00000000..953f94fa Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/700.woff2 differ diff --git a/backend/assets/fonts/Inter/400.woff2 b/backend/assets/fonts/Inter/400.woff2 new file mode 100644 index 00000000..f15b025d Binary files /dev/null and b/backend/assets/fonts/Inter/400.woff2 differ diff --git a/backend/assets/fonts/Inter/600.woff2 b/backend/assets/fonts/Inter/600.woff2 new file mode 100644 index 00000000..d1897949 Binary files /dev/null and b/backend/assets/fonts/Inter/600.woff2 differ diff --git a/backend/assets/fonts/Inter/700.woff2 b/backend/assets/fonts/Inter/700.woff2 new file mode 100644 index 00000000..a68fb101 Binary files /dev/null and b/backend/assets/fonts/Inter/700.woff2 differ diff --git a/backend/assets/fonts/Jost/400.woff2 b/backend/assets/fonts/Jost/400.woff2 new file mode 100644 index 00000000..a93b79dd Binary files /dev/null and b/backend/assets/fonts/Jost/400.woff2 differ diff --git a/backend/assets/fonts/Jost/600.woff2 b/backend/assets/fonts/Jost/600.woff2 new file mode 100644 index 00000000..d083772e Binary files /dev/null and b/backend/assets/fonts/Jost/600.woff2 differ diff --git a/backend/assets/fonts/Jost/700.woff2 b/backend/assets/fonts/Jost/700.woff2 new file mode 100644 index 00000000..3d3e6830 Binary files /dev/null and b/backend/assets/fonts/Jost/700.woff2 differ diff --git a/backend/assets/fonts/LICENSE-OFL.txt b/backend/assets/fonts/LICENSE-OFL.txt new file mode 100644 index 00000000..22be8368 --- /dev/null +++ b/backend/assets/fonts/LICENSE-OFL.txt @@ -0,0 +1,114 @@ +This directory bundles the following typefaces, each licensed under the +SIL Open Font License v1.1. +------------------------------------------------------------ +Per-font copyright notices +------------------------------------------------------------ +Inter +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +Noto Sans +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Poppins +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) +Jost +Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type/Jost) +Montserrat +Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) +Playfair Display +Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair) +IBM Plex Sans +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" +Comic Neue +Copyright (c) 2014 by Craig Rozynski. All rights reserved. + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/backend/assets/fonts/Montserrat/400.woff2 b/backend/assets/fonts/Montserrat/400.woff2 new file mode 100644 index 00000000..6fbeafa9 Binary files /dev/null and b/backend/assets/fonts/Montserrat/400.woff2 differ diff --git a/backend/assets/fonts/Montserrat/600.woff2 b/backend/assets/fonts/Montserrat/600.woff2 new file mode 100644 index 00000000..453f0633 Binary files /dev/null and b/backend/assets/fonts/Montserrat/600.woff2 differ diff --git a/backend/assets/fonts/Montserrat/700.woff2 b/backend/assets/fonts/Montserrat/700.woff2 new file mode 100644 index 00000000..ff9d6a7f Binary files /dev/null and b/backend/assets/fonts/Montserrat/700.woff2 differ diff --git a/backend/assets/fonts/Noto-Sans/400.woff2 b/backend/assets/fonts/Noto-Sans/400.woff2 new file mode 100644 index 00000000..f9e0a65f Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/400.woff2 differ diff --git a/backend/assets/fonts/Noto-Sans/600.woff2 b/backend/assets/fonts/Noto-Sans/600.woff2 new file mode 100644 index 00000000..456247bd Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/600.woff2 differ diff --git a/backend/assets/fonts/Noto-Sans/700.woff2 b/backend/assets/fonts/Noto-Sans/700.woff2 new file mode 100644 index 00000000..feff1bf1 Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/700.woff2 differ diff --git a/backend/assets/fonts/Playfair-Display/400.woff2 b/backend/assets/fonts/Playfair-Display/400.woff2 new file mode 100644 index 00000000..141ea84a Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/400.woff2 differ diff --git a/backend/assets/fonts/Playfair-Display/600.woff2 b/backend/assets/fonts/Playfair-Display/600.woff2 new file mode 100644 index 00000000..c19fbc1d Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/600.woff2 differ diff --git a/backend/assets/fonts/Playfair-Display/700.woff2 b/backend/assets/fonts/Playfair-Display/700.woff2 new file mode 100644 index 00000000..39eefe03 Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/700.woff2 differ diff --git a/backend/assets/fonts/Playfair-Display/meta.json b/backend/assets/fonts/Playfair-Display/meta.json new file mode 100644 index 00000000..0b837dd7 --- /dev/null +++ b/backend/assets/fonts/Playfair-Display/meta.json @@ -0,0 +1,3 @@ +{ + "generic": "serif" +} diff --git a/backend/assets/fonts/Poppins/400.woff2 b/backend/assets/fonts/Poppins/400.woff2 new file mode 100644 index 00000000..b69e0091 Binary files /dev/null and b/backend/assets/fonts/Poppins/400.woff2 differ diff --git a/backend/assets/fonts/Poppins/600.woff2 b/backend/assets/fonts/Poppins/600.woff2 new file mode 100644 index 00000000..921e962b Binary files /dev/null and b/backend/assets/fonts/Poppins/600.woff2 differ diff --git a/backend/assets/fonts/Poppins/700.woff2 b/backend/assets/fonts/Poppins/700.woff2 new file mode 100644 index 00000000..bf022fc7 Binary files /dev/null and b/backend/assets/fonts/Poppins/700.woff2 differ diff --git a/backend/migrations/core/074_add_photo_cap.js b/backend/migrations/core/074_add_photo_cap.js new file mode 100644 index 00000000..d992fe66 --- /dev/null +++ b/backend/migrations/core/074_add_photo_cap.js @@ -0,0 +1,13 @@ +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + console.log('Running migration: 074_add_photo_cap'); + await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => { + table.integer('photo_cap').nullable().defaultTo(null); + }); + console.log('Migration 074_add_photo_cap completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 074_add_photo_cap'); +}; diff --git a/backend/migrations/core/074_photo_visibility_client_access.js b/backend/migrations/core/074_photo_visibility_client_access.js new file mode 100644 index 00000000..b4c42f1e --- /dev/null +++ b/backend/migrations/core/074_photo_visibility_client_access.js @@ -0,0 +1,28 @@ +const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + // Add visibility column to photos table + await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => { + table.string('visibility', 20).defaultTo('visible').notNullable(); + }); + + // Add client access columns to events table + await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => { + table.boolean('client_access_enabled').defaultTo(false); + }); + + await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => { + table.string('client_password_hash', 255).nullable(); + }); + + await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => { + table.string('client_share_token', 64).nullable().unique(); + }); + + // Index for filtering photos by visibility + await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility'); +}; + +exports.down = async function(knex) { + // Safe rollback - intentionally no-op to avoid data loss +}; diff --git a/backend/migrations/core/075_email_template_translations.js b/backend/migrations/core/075_email_template_translations.js new file mode 100644 index 00000000..0c30d926 --- /dev/null +++ b/backend/migrations/core/075_email_template_translations.js @@ -0,0 +1,281 @@ +/** + * Migration to create email_template_translations table + * Moves from per-column language support (subject_en, subject_de) to a + * normalized translations table where each language is a row. + * This allows adding new languages without schema changes. + */ +exports.up = async function(knex) { + // 1. Create the email_template_translations table + await knex.schema.createTable('email_template_translations', (table) => { + table.increments('id').primary(); + table.integer('template_id').unsigned().notNullable() + .references('id').inTable('email_templates').onDelete('CASCADE'); + table.string('language', 10).notNullable(); + table.text('subject'); + table.text('body_html'); + table.text('body_text'); + table.datetime('created_at').defaultTo(knex.fn.now()); + table.datetime('updated_at').defaultTo(knex.fn.now()); + table.unique(['template_id', 'language']); + }); + + console.log('Created email_template_translations table'); + + // 2. Migrate existing data from email_templates columns into rows + const templates = await knex('email_templates').select('*'); + const columnInfo = await knex('email_templates').columnInfo(); + const hasLangColumns = !!columnInfo.subject_en; + + for (const template of templates) { + // Extract EN translation + const enSubject = hasLangColumns + ? (template.subject_en || template.subject || '') + : (template.subject || ''); + const enHtml = hasLangColumns + ? (template.body_html_en || template.body_html || '') + : (template.body_html || ''); + const enText = hasLangColumns + ? (template.body_text_en || template.body_text || '') + : (template.body_text || ''); + + // Insert EN translation + if (enSubject || enHtml) { + await knex('email_template_translations').insert({ + template_id: template.id, + language: 'en', + subject: enSubject, + body_html: enHtml, + body_text: enText, + created_at: new Date(), + updated_at: new Date(), + }); + } + + // Extract DE translation (only if lang columns exist) + if (hasLangColumns) { + const deSubject = template.subject_de || ''; + const deHtml = template.body_html_de || ''; + const deText = template.body_text_de || ''; + + // Only insert if DE content differs from EN or has content + if (deSubject || deHtml) { + await knex('email_template_translations').insert({ + template_id: template.id, + language: 'de', + subject: deSubject, + body_html: deHtml, + body_text: deText, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + } + + console.log(`Migrated ${templates.length} templates to translations table`); + + // 3. Seed NL, PT, RU translations for customer-facing templates + // Look up template IDs + const customerTemplates = await knex('email_templates') + .whereIn('template_key', [ + 'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete' + ]) + .select('id', 'template_key'); + + const templateMap = {}; + customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; }); + + const seedTranslations = []; + + // --- gallery_created --- + if (templateMap.gallery_created) { + const id = templateMap.gallery_created; + seedTranslations.push( + { + template_id: id, language: 'nl', + subject: 'Uw fotogalerij is klaar!', + body_html: `

Galerij succesvol aangemaakt

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!

+

Galerij details:

+
    +
  • Evenementdatum: {{event_date}}
  • +
  • Galerij link: {{gallery_link}}
  • +
  • Wachtwoord: {{gallery_password}}
  • +
  • Verloopt op: {{expiry_date}}
  • +
+

Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`, + }, + { + template_id: id, language: 'pt', + subject: 'Sua galeria de fotos está pronta!', + body_html: `

Galeria criada com sucesso

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" foi criada com sucesso!

+

Detalhes da galeria:

+
    +
  • Data do evento: {{event_date}}
  • +
  • Link da galeria: {{gallery_link}}
  • +
  • Senha: {{gallery_password}}
  • +
  • Expira em: {{expiry_date}}
  • +
+

Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`, + }, + { + template_id: id, language: 'ru', + subject: 'Ваша фотогалерея готова!', + body_html: `

Галерея успешно создана

+

Уважаемый(ая) {{host_name}},

+

Ваша фотогалерея "{{event_name}}" была успешно создана!

+

Детали галереи:

+
    +
  • Дата события: {{event_date}}
  • +
  • Ссылка на галерею: {{gallery_link}}
  • +
  • Пароль: {{gallery_password}}
  • +
  • Срок действия: {{expiry_date}}
  • +
+

Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`, + }, + ); + } + + // --- expiration_warning --- + if (templateMap.expiration_warning) { + const id = templateMap.expiration_warning; + seedTranslations.push( + { + template_id: id, language: 'nl', + subject: 'Uw fotogalerij verloopt binnenkort', + body_html: `

Galerij verloopt binnenkort

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.

+

Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.

+

Galerij bezoeken

`, + body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`, + }, + { + template_id: id, language: 'pt', + subject: 'Sua galeria de fotos expira em breve', + body_html: `

Galeria expirando em breve

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.

+

Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.

+

Visitar galeria

`, + body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`, + }, + { + template_id: id, language: 'ru', + subject: 'Срок действия вашей фотогалереи скоро истекает', + body_html: `

Срок действия галереи истекает

+

Уважаемый(ая) {{host_name}},

+

Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.

+

После истечения срока галерея будет архивирована и станет недоступна для гостей.

+

Перейти в галерею

`, + body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`, + }, + ); + } + + // --- gallery_expired --- + if (templateMap.gallery_expired) { + const id = templateMap.gallery_expired; + seedTranslations.push( + { + template_id: id, language: 'nl', + subject: 'Uw fotogalerij {{event_name}} is verlopen', + body_html: `

Galerij verlopen

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.

+

De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.

`, + body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`, + }, + { + template_id: id, language: 'pt', + subject: 'Sua galeria de fotos {{event_name}} expirou', + body_html: `

Galeria expirada

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.

+

As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.

`, + body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`, + }, + { + template_id: id, language: 'ru', + subject: 'Срок действия фотогалереи {{event_name}} истёк', + body_html: `

Срок действия галереи истёк

+

Уважаемый(ая) {{host_name}},

+

Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.

+

Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.

`, + body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`, + }, + ); + } + + // --- archive_complete --- + if (templateMap.archive_complete) { + const id = templateMap.archive_complete; + seedTranslations.push( + { + template_id: id, language: 'nl', + subject: 'Archivering voltooid: {{event_name}}', + body_html: `

Archivering voltooid

+

Beste {{host_name}},

+

De fotogalerij "{{event_name}}" is succesvol gearchiveerd.

+

Archief details:

+
    +
  • Aantal foto's: {{photo_count}}
  • +
  • Archiefgrootte: {{archive_size}}
  • +
  • Archiefdatum: {{archive_date}}
  • +
`, + body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`, + }, + { + template_id: id, language: 'pt', + subject: 'Arquivamento concluído: {{event_name}}', + body_html: `

Arquivamento concluído

+

Prezado(a) {{host_name}},

+

A galeria de fotos "{{event_name}}" foi arquivada com sucesso.

+

Detalhes do arquivo:

+
    +
  • Número de fotos: {{photo_count}}
  • +
  • Tamanho do arquivo: {{archive_size}}
  • +
  • Data do arquivamento: {{archive_date}}
  • +
`, + body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`, + }, + { + template_id: id, language: 'ru', + subject: 'Архивация завершена: {{event_name}}', + body_html: `

Архивация завершена

+

Уважаемый(ая) {{host_name}},

+

Фотогалерея "{{event_name}}" была успешно архивирована.

+

Детали архива:

+
    +
  • Количество фото: {{photo_count}}
  • +
  • Размер архива: {{archive_size}}
  • +
  • Дата архивации: {{archive_date}}
  • +
`, + body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`, + }, + ); + } + + // Insert all seed translations + const now = new Date(); + for (const trans of seedTranslations) { + trans.created_at = now; + trans.updated_at = now; + await knex('email_template_translations').insert(trans); + } + + console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`); +}; + +exports.down = async function(knex) { + await knex.schema.dropTableIfExists('email_template_translations'); +}; diff --git a/backend/migrations/core/076_add_is_draft_column.js b/backend/migrations/core/076_add_is_draft_column.js new file mode 100644 index 00000000..a7581652 --- /dev/null +++ b/backend/migrations/core/076_add_is_draft_column.js @@ -0,0 +1,21 @@ +/** + * Migration to add is_draft column to events table. + * Draft events are not visible to gallery visitors until published. + */ +exports.up = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'is_draft'); + if (!hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.boolean('is_draft').defaultTo(false); + }); + } +}; + +exports.down = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'is_draft'); + if (hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('is_draft'); + }); + } +}; diff --git a/backend/migrations/core/077_add_default_photo_sort.js b/backend/migrations/core/077_add_default_photo_sort.js new file mode 100644 index 00000000..c6468903 --- /dev/null +++ b/backend/migrations/core/077_add_default_photo_sort.js @@ -0,0 +1,17 @@ +exports.up = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort'); + if (!hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.string('default_photo_sort', 50).defaultTo('upload_date_desc'); + }); + } +}; + +exports.down = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort'); + if (hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('default_photo_sort'); + }); + } +}; diff --git a/backend/migrations/core/078_add_guest_identity.js b/backend/migrations/core/078_add_guest_identity.js new file mode 100644 index 00000000..ef01f93d --- /dev/null +++ b/backend/migrations/core/078_add_guest_identity.js @@ -0,0 +1,120 @@ +/** + * Add guest identity layer for per-person photo selections (issue #292). + * + * Adds: + * - gallery_guests — persistent guest profiles per event + * - guest_invites — pre-minted invite tokens (Phase 3.3) + * - guest_verification_codes — email-based identity recovery (Phase 3.2) + * - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple') + * - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable) + * + * All changes are additive. Existing events default to 'simple' mode so behavior + * is unchanged. Legacy photo_feedback rows keep NULL guest_id. + */ + +exports.up = async function(knex) { + // 1. gallery_guests — persistent per-person identity within an event. + const hasGalleryGuests = await knex.schema.hasTable('gallery_guests'); + if (!hasGalleryGuests) { + await knex.schema.createTable('gallery_guests', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('name', 100).notNullable(); + table.string('email', 255); + table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side + table.string('ip_address_last', 45); + table.text('user_agent_last'); + table.timestamp('email_verified_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('last_seen_at').defaultTo(knex.fn.now()); + table.boolean('is_deleted').defaultTo(false); + + table.unique(['event_id', 'identifier']); + table.index(['event_id']); + table.index(['event_id', 'email']); + }); + } + + // 2. guest_invites — pre-minted one-time-use tokens for invited guests. + const hasGuestInvites = await knex.schema.hasTable('guest_invites'); + if (!hasGuestInvites) { + await knex.schema.createTable('guest_invites', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE'); + table.string('token', 64).notNullable().unique(); + table.integer('created_by_admin_id').references('id').inTable('admin_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('redeemed_at'); + table.timestamp('revoked_at'); + + table.index(['event_id']); + table.index(['guest_id']); + }); + } + + // 3. guest_verification_codes — short-lived codes for email-based recovery. + const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes'); + if (!hasGuestVerificationCodes) { + await knex.schema.createTable('guest_verification_codes', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('email', 255).notNullable(); + table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code + table.integer('attempts').defaultTo(0); + table.timestamp('expires_at').notNullable(); + table.timestamp('consumed_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['event_id', 'email']); + table.index(['expires_at']); + }); + } + + // 4. event_feedback_settings.identity_mode + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (!hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.string('identity_mode', 16).notNullable().defaultTo('simple'); + }); + if (knex.client.config.client === 'pg') { + await knex.raw(` + ALTER TABLE event_feedback_settings + ADD CONSTRAINT event_feedback_settings_identity_mode_check + CHECK (identity_mode IN ('simple','guest')) + `); + } + } + + // 5. photo_feedback.guest_id FK + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (!hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL'); + table.index(['guest_id']); + }); + } +}; + +exports.down = async function(knex) { + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.dropColumn('guest_id'); + }); + } + + if (knex.client.config.client === 'pg') { + await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check'); + } + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.dropColumn('identity_mode'); + }); + } + + await knex.schema.dropTableIfExists('guest_verification_codes'); + await knex.schema.dropTableIfExists('guest_invites'); + await knex.schema.dropTableIfExists('gallery_guests'); +}; diff --git a/backend/migrations/core/079_add_download_zip_cache.js b/backend/migrations/core/079_add_download_zip_cache.js new file mode 100644 index 00000000..4c9b4b49 --- /dev/null +++ b/backend/migrations/core/079_add_download_zip_cache.js @@ -0,0 +1,26 @@ +/** + * Add download ZIP cache columns to events table. + * + * Enables pre-generated ZIP files for "Download All" so guests get + * instant downloads with Content-Length instead of on-the-fly streaming. + */ + +exports.up = async function(knex) { + const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path'); + if (!hasZipPath) { + await knex.schema.alterTable('events', (table) => { + table.text('download_zip_path').nullable().defaultTo(null); + table.datetime('download_zip_generated_at').nullable().defaultTo(null); + }); + } +}; + +exports.down = async function(knex) { + const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path'); + if (hasZipPath) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('download_zip_path'); + table.dropColumn('download_zip_generated_at'); + }); + } +}; diff --git a/backend/migrations/core/080_add_customer_phone.js b/backend/migrations/core/080_add_customer_phone.js new file mode 100644 index 00000000..db2c765e --- /dev/null +++ b/backend/migrations/core/080_add_customer_phone.js @@ -0,0 +1,33 @@ +const { addColumnIfNotExists } = require('../helpers'); + +/** + * #322 — optional phone-number field on events. Off by default; surfaced + * only when the global `event_phone_field_enabled` app setting is true, + * so existing deployments see no UI change unless the admin opts in. + */ +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => { + table.string('customer_phone', 32).nullable(); + }); + + // Seed the global enable flag (default false). + const exists = await knex('app_settings') + .where('setting_key', 'event_phone_field_enabled') + .first(); + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'event_phone_field_enabled', + setting_value: JSON.stringify(false), + setting_type: 'boolean' + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasColumn('events', 'customer_phone')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_phone'); + }); + } + await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete(); +}; diff --git a/backend/migrations/core/081_add_api_tokens.js b/backend/migrations/core/081_add_api_tokens.js new file mode 100644 index 00000000..000dbfb9 --- /dev/null +++ b/backend/migrations/core/081_add_api_tokens.js @@ -0,0 +1,41 @@ +/** + * #322 — long-lived API tokens for programmatic access (n8n, custom + * integrations, external apps). Each token belongs to an admin user; the + * token's effective permissions are the *intersection* of the user's + * role permissions and the token's own scope flags. That way revoking + * the user revokes the token, and scope flags let an admin issue a + * read-only token even if their account is super_admin. + */ + +exports.up = async function up(knex) { + if (!(await knex.schema.hasTable('api_tokens'))) { + await knex.schema.createTable('api_tokens', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + // SHA-256 of the full token string (`pp_live_`). Lookup + // hashes the incoming Authorization header and queries by this. + table.string('hashed_token', 64).notNullable().unique(); + // Scope flags — comma-separated subset of: read, write, admin. + // 'read' allows GETs; 'write' adds POST/PATCH/DELETE on + // event/photo data; 'admin' allows creating/deleting events and + // anything else gated by admin.* permissions. + table.string('scopes', 64).notNullable().defaultTo('read'); + table.integer('created_by').notNullable() + .references('id').inTable('admin_users').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('expires_at').nullable(); + table.timestamp('last_used_at').nullable(); + table.timestamp('revoked_at').nullable(); + // Cosmetic for the admin UI: first 8 chars of the plaintext + // token (after the prefix) so admins can identify which token is + // which without seeing the secret half. + table.string('preview', 16).nullable(); + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasTable('api_tokens')) { + await knex.schema.dropTable('api_tokens'); + } +}; diff --git a/backend/migrations/core/082_add_webhooks.js b/backend/migrations/core/082_add_webhooks.js new file mode 100644 index 00000000..8e63f289 --- /dev/null +++ b/backend/migrations/core/082_add_webhooks.js @@ -0,0 +1,80 @@ +/** + * #327 — outbound webhooks (push API) for the event/photo lifecycle. + * + * Two tables: + * webhooks — admin-managed subscriptions (URL + events + secret) + * webhook_deliveries — single source of truth for the delivery worker + * (audit log + retry queue in one). + */ + +exports.up = async function up(knex) { + if (!(await knex.schema.hasTable('webhooks'))) { + await knex.schema.createTable('webhooks', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + // Validated via networkValidation.validateExternalUrl on create + per + // delivery (DNS-rebinding mitigation). + table.string('url', 2048).notNullable(); + // Plaintext signing secret (`whsec_`). Stored unencrypted + // because we need to recompute HMAC-SHA256 over every outbound body + // — a hash would make the secret unrecoverable. Same posture as + // SMTP passwords stored in app_settings; protect the DB. The + // plaintext is also returned to the admin once on create so they can + // configure the receiver to verify signatures. + table.string('secret', 100).notNullable(); + // First 8 chars of the secret for the admin UI so operators can + // tell which webhook is which without revealing the full secret. + table.string('secret_preview', 16).nullable(); + // JSON array of subscribed event types + // (e.g. ["event.published","photo.uploaded"]). + table.jsonb('events').notNullable().defaultTo('[]'); + table.boolean('active').notNullable().defaultTo(true); + table.integer('created_by').notNullable() + .references('id').inTable('admin_users').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.timestamp('last_success_at').nullable(); + table.timestamp('last_failure_at').nullable(); + // Index for the delivery worker's "find subscriptions for this event" + // query — small set, but keeps the lookup constant-time as it grows. + table.index('active', 'webhooks_active_idx'); + }); + } + + if (!(await knex.schema.hasTable('webhook_deliveries'))) { + await knex.schema.createTable('webhook_deliveries', (table) => { + table.increments('id').primary(); + table.integer('webhook_id').notNullable() + .references('id').inTable('webhooks').onDelete('CASCADE'); + table.string('event_type', 64).notNullable(); + // Full signed payload (the JSON body that was POSTed). + table.jsonb('payload').notNullable(); + table.integer('attempt_count').notNullable().defaultTo(0); + // pending → success | failed. pending rows with next_retry_at <= NOW() + // are picked up by the worker. + table.string('status', 16).notNullable().defaultTo('pending'); + table.integer('response_status').nullable(); + // Truncated to 1KB before storage so a verbose receiver can't blow + // up the row size. + table.text('response_body').nullable(); + table.text('last_error').nullable(); + table.integer('latency_ms').nullable(); + table.timestamp('next_retry_at').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('completed_at').nullable(); + // Worker hot-path query: WHERE status='pending' AND next_retry_at <= NOW() + // ORDER BY next_retry_at LIMIT N. This composite index serves it directly. + table.index(['status', 'next_retry_at'], 'webhook_deliveries_status_retry_idx'); + table.index('webhook_id', 'webhook_deliveries_webhook_idx'); + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasTable('webhook_deliveries')) { + await knex.schema.dropTable('webhook_deliveries'); + } + if (await knex.schema.hasTable('webhooks')) { + await knex.schema.dropTable('webhooks'); + } +}; diff --git a/backend/migrations/core/083_add_presigned_and_webhook_extras.js b/backend/migrations/core/083_add_presigned_and_webhook_extras.js new file mode 100644 index 00000000..af8cb6ec --- /dev/null +++ b/backend/migrations/core/083_add_presigned_and_webhook_extras.js @@ -0,0 +1,49 @@ +/** + * Adds: + * - events.allow_presigned_download — per-event opt-in for the + * presigned-URL "Download All" path (#328 follow-up). Off by default + * because it bypasses watermarks; admins flip it knowingly. + * - webhooks.filter — JSONB predicate evaluated against the payload at + * fire time (#327 follow-up). Empty object = no filter, fire always. + * - webhooks.template — optional ${dot.path} string template applied + * to the request body before signing. NULL = use the default JSON + * envelope (back-compat). + */ + +exports.up = async function up(knex) { + if (await knex.schema.hasTable('events')) { + const hasCol = await knex.schema.hasColumn('events', 'allow_presigned_download'); + if (!hasCol) { + await knex.schema.alterTable('events', (table) => { + table.boolean('allow_presigned_download').notNullable().defaultTo(false); + }); + } + } + + if (await knex.schema.hasTable('webhooks')) { + const hasFilter = await knex.schema.hasColumn('webhooks', 'filter'); + if (!hasFilter) { + await knex.schema.alterTable('webhooks', (table) => { + table.jsonb('filter').notNullable().defaultTo('{}'); + }); + } + const hasTemplate = await knex.schema.hasColumn('webhooks', 'template'); + if (!hasTemplate) { + await knex.schema.alterTable('webhooks', (table) => { + table.text('template').nullable(); + }); + } + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasColumn('webhooks', 'template')) { + await knex.schema.alterTable('webhooks', (t) => t.dropColumn('template')); + } + if (await knex.schema.hasColumn('webhooks', 'filter')) { + await knex.schema.alterTable('webhooks', (t) => t.dropColumn('filter')); + } + if (await knex.schema.hasColumn('events', 'allow_presigned_download')) { + await knex.schema.alterTable('events', (t) => t.dropColumn('allow_presigned_download')); + } +}; diff --git a/backend/migrations/core/084_fix_hero_logo_position.js b/backend/migrations/core/084_fix_hero_logo_position.js new file mode 100644 index 00000000..d687ee64 --- /dev/null +++ b/backend/migrations/core/084_fix_hero_logo_position.js @@ -0,0 +1,21 @@ +/** + * Heal events whose hero_logo_position contains a branding-style value + * (left/right) caused by a prior bug in adminEvents.js getBrandingDefaults + * that mapped branding_logo_position (left/center/right) onto + * hero_logo_position (top/center/bottom). Any non-canonical value is + * reset to 'top' so subsequent PUTs no longer fail validation. + */ + +exports.up = async function up(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'hero_logo_position'); + if (!hasColumn) return; + + await knex('events') + .whereNotIn('hero_logo_position', ['top', 'center', 'bottom']) + .update({ hero_logo_position: 'top' }); +}; + +exports.down = async function down() { + // Data correction is not reversible — the original (incorrect) values + // are not preserved. +}; diff --git a/backend/migrations/core/085_async_photo_processing.js b/backend/migrations/core/085_async_photo_processing.js new file mode 100644 index 00000000..55d2d75f --- /dev/null +++ b/backend/migrations/core/085_async_photo_processing.js @@ -0,0 +1,72 @@ +/** + * Async photo-processing infrastructure. + * + * Adds: + * - photos.processing_status — enum: pending | processing | complete | failed + * - photos.processing_error — text, populated on 'failed' + * - photos.processing_started_at — claim timestamp for janitor recovery + * - photos.upload_id — groups all photos from one upload request + * so the frontend can poll/stream by group + * + * All existing rows default to 'complete' (they were processed synchronously + * before this migration and there's nothing pending). New uploads insert + * with 'pending' and a background worker (services/backgroundProcessor.js) + * picks them up. + * + * Partial-style indexes keep lookups fast as the queue drains. We use plain + * indexes here instead of postgres-specific WHERE clauses so the migration + * works on SQLite too; the workload (only-pending rows) keeps the index small. + */ + +exports.up = async function up(knex) { + if (!(await knex.schema.hasTable('photos'))) return; + + const hasStatus = await knex.schema.hasColumn('photos', 'processing_status'); + if (!hasStatus) { + await knex.schema.alterTable('photos', (table) => { + table.string('processing_status', 16).notNullable().defaultTo('complete'); + table.text('processing_error').nullable(); + table.timestamp('processing_started_at').nullable(); + table.string('upload_id', 64).nullable(); + }); + } + + // Indexes — wrap in try/catch so re-running the migration on a partially + // applied schema is a no-op rather than an error. + try { + await knex.schema.alterTable('photos', (table) => { + table.index(['processing_status'], 'idx_photos_processing_status'); + }); + } catch (_) { /* already exists */ } + + try { + await knex.schema.alterTable('photos', (table) => { + table.index(['upload_id'], 'idx_photos_upload_id'); + }); + } catch (_) { /* already exists */ } +}; + +exports.down = async function down(knex) { + if (!(await knex.schema.hasTable('photos'))) return; + + // Drop indexes first (best-effort) + try { + await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_upload_id')); + } catch (_) { /* not present */ } + try { + await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_processing_status')); + } catch (_) { /* not present */ } + + if (await knex.schema.hasColumn('photos', 'upload_id')) { + await knex.schema.alterTable('photos', (t) => t.dropColumn('upload_id')); + } + if (await knex.schema.hasColumn('photos', 'processing_started_at')) { + await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_started_at')); + } + if (await knex.schema.hasColumn('photos', 'processing_error')) { + await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_error')); + } + if (await knex.schema.hasColumn('photos', 'processing_status')) { + await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_status')); + } +}; diff --git a/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js b/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js new file mode 100644 index 00000000..2d3340b6 --- /dev/null +++ b/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js @@ -0,0 +1,125 @@ +/** + * Migration: Move existing non-grid + 'standard' events to the new 'banner' + * header style so their visual appearance is preserved. + * + * Until now, GalleryLayout.tsx coupled the colored hero banner to non-grid + * layouts whenever headerStyle was 'standard'. The 'standard' look has been + * decoupled from layout (it now means: compact inline header, no banner) and + * a new 'banner' option has been added that adds the colored banner above + * the standard header. + * + * In the same release, GalleryView.tsx stopped deriving controlsStyle from + * the layout — non-grid events used to default to the sidebar drawer via + * `theme.galleryLayout !== 'grid' || isHeroHeader`, and now require an + * explicit `theme.controlsStyle === 'sidebar'`. To keep affected events + * pixel-identical post-upgrade, this migration also sets controlsStyle to + * 'sidebar' when it was previously unset on every event we flip to banner. + * + * To keep current galleries looking the same, every event whose effective + * config was non-grid + standard gets migrated to non-grid + banner, and + * its controlsStyle is pinned to whatever the runtime would have used + * before the decoupling. + */ +const NON_GRID_LAYOUTS = ['masonry', 'carousel', 'timeline', 'mosaic']; + +exports.up = async function(knex) { + console.log('[Migration 086] Migrating non-grid standard headers to banner'); + + const events = await knex('events') + .where('header_style', 'standard') + .whereNotNull('color_theme') + .select('id', 'color_theme'); + + let migratedCount = 0; + let controlsPinnedCount = 0; + + for (const event of events) { + try { + if (!event.color_theme || !event.color_theme.startsWith('{')) { + continue; + } + + const theme = JSON.parse(event.color_theme); + + if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) { + continue; + } + + const updatedTheme = { ...theme, headerStyle: 'banner' }; + + // Preserve previous filter placement: before this release, non-grid + // layouts implicitly rendered the sidebar drawer when controlsStyle + // was unset. Pin it to 'sidebar' so the visual stays identical. Don't + // overwrite explicit values the user may have set deliberately. + if (!theme.controlsStyle) { + updatedTheme.controlsStyle = 'sidebar'; + controlsPinnedCount++; + } + + await knex('events') + .where('id', event.id) + .update({ + color_theme: JSON.stringify(updatedTheme), + header_style: 'banner' + }); + + migratedCount++; + } catch (err) { + console.warn(`[Migration 086] Could not parse color_theme for event ${event.id}: ${err.message}`); + } + } + + console.log(`[Migration 086] Migrated ${migratedCount} events from standard to banner`); + console.log(`[Migration 086] Pinned controlsStyle='sidebar' on ${controlsPinnedCount} events`); +}; + +exports.down = async function(knex) { + console.log('[Migration 086] Reverting non-grid banner headers to standard'); + + const events = await knex('events') + .where('header_style', 'banner') + .whereNotNull('color_theme') + .select('id', 'color_theme'); + + let revertedCount = 0; + + for (const event of events) { + try { + if (!event.color_theme || !event.color_theme.startsWith('{')) { + continue; + } + + const theme = JSON.parse(event.color_theme); + + // Only revert rows we would have migrated (non-grid + banner). Leaves + // any banner events that were intentionally created on grid alone. + if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) { + continue; + } + + const revertedTheme = { ...theme, headerStyle: 'standard' }; + + // Symmetric with up: if controlsStyle is currently 'sidebar', drop it + // so the runtime falls back to whatever the older code would compute. + // Cannot perfectly distinguish "we set this" from "user agreed", but + // the scope is narrow (non-grid + banner) and rolling back is + // intentionally restoring pre-migration state. + if (revertedTheme.controlsStyle === 'sidebar') { + delete revertedTheme.controlsStyle; + } + + await knex('events') + .where('id', event.id) + .update({ + color_theme: JSON.stringify(revertedTheme), + header_style: 'standard' + }); + + revertedCount++; + } catch (err) { + console.warn(`[Migration 086] Could not revert color_theme for event ${event.id}: ${err.message}`); + } + } + + console.log(`[Migration 086] Reverted ${revertedCount} events from banner to standard`); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 9c0616f2..8b15dc83 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,17 +1,16 @@ { "name": "picpeak-backend", - "version": "2.6.3", + "version": "3.42.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "2.6.3", + "version": "3.42.1", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", - "@ffmpeg-installer/ffmpeg": "^1.1.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "1.14.0", @@ -45,6 +44,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" @@ -57,6 +58,50 @@ "supertest": "^6.3.3" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1561,132 +1606,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@ffmpeg-installer/darwin-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", - "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", - "cpu": [ - "arm64" - ], - "hasInstallScript": true, - "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@ffmpeg-installer/darwin-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", - "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", - "cpu": [ - "x64" - ], - "hasInstallScript": true, - "license": "LGPL-2.1", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@ffmpeg-installer/ffmpeg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", - "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", - "license": "LGPL-2.1", - "optionalDependencies": { - "@ffmpeg-installer/darwin-arm64": "4.1.5", - "@ffmpeg-installer/darwin-x64": "4.1.0", - "@ffmpeg-installer/linux-arm": "4.1.3", - "@ffmpeg-installer/linux-arm64": "4.1.4", - "@ffmpeg-installer/linux-ia32": "4.1.0", - "@ffmpeg-installer/linux-x64": "4.1.0", - "@ffmpeg-installer/win32-ia32": "4.1.0", - "@ffmpeg-installer/win32-x64": "4.1.0" - } - }, - "node_modules/@ffmpeg-installer/linux-arm": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", - "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", - "cpu": [ - "arm" - ], - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-arm64": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", - "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", - "cpu": [ - "arm64" - ], - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-ia32": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", - "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", - "cpu": [ - "ia32" - ], - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/linux-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", - "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", - "cpu": [ - "x64" - ], - "hasInstallScript": true, - "license": "GPLv3", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ffmpeg-installer/win32-ia32": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", - "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", - "cpu": [ - "ia32" - ], - "license": "GPLv3", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@ffmpeg-installer/win32-x64": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", - "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", - "cpu": [ - "x64" - ], - "license": "GPLv3", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -2656,6 +2575,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2769,6 +2694,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3697,6 +3629,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", @@ -4515,6 +4453,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5143,7 +5087,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -5570,7 +5513,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5671,6 +5613,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5788,6 +5731,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/express/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -5990,9 +5939,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -8017,6 +7966,13 @@ "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8029,6 +7985,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -8060,6 +8023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -8575,9 +8544,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -9023,6 +8992,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9217,12 +9193,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -10794,6 +10764,71 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz", + "integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -11507,6 +11542,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -11571,6 +11615,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index d6a4a31b..f64bcf2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "2.6.5", + "version": "3.42.1", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { @@ -10,13 +10,13 @@ "migrate:safe": "node migrations/run-migrations-safe.js", "generate:watermarks": "node scripts/generate-watermarks.js", "test": "jest", + "test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3", "lint": "eslint src/" }, "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", - "@ffmpeg-installer/ffmpeg": "^1.1.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "1.14.0", @@ -50,6 +50,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" diff --git a/backend/scripts/generate-openapi.js b/backend/scripts/generate-openapi.js new file mode 100644 index 00000000..a4a775b9 --- /dev/null +++ b/backend/scripts/generate-openapi.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Generate the OpenAPI spec from JSDoc annotations in src/routes/v1/* and + * write it as YAML + JSON to ../docs/. Used by scripts/sync-api-docs.sh + * to keep the picpeak-docs site in lockstep with the running API. + */ + +const fs = require('fs'); +const path = require('path'); + +// Need yaml — runtime require so the script fails clearly with an +// install hint instead of an opaque MODULE_NOT_FOUND. +let yaml; +try { + yaml = require('js-yaml'); +} catch { + console.error('generate-openapi: missing dependency `js-yaml`. Run `npm install --save-dev js-yaml` in /backend.'); + process.exit(2); +} + +const { getOpenApiSpec } = require('../src/openapi/spec'); + +const outDir = path.resolve(__dirname, '../../docs'); +fs.mkdirSync(outDir, { recursive: true }); + +const spec = getOpenApiSpec(); +fs.writeFileSync(path.join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2)); +fs.writeFileSync(path.join(outDir, 'openapi.yaml'), yaml.dump(spec, { lineWidth: 100 })); + +console.log(`Wrote openapi.json + openapi.yaml to ${outDir}`); diff --git a/backend/scripts/migrate-storage.js b/backend/scripts/migrate-storage.js new file mode 100644 index 00000000..1c255169 --- /dev/null +++ b/backend/scripts/migrate-storage.js @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * migrate-storage.js + * + * One-shot migration tool to copy every PicPeak content file from the local + * filesystem (the legacy STORAGE_PATH) to a configured S3-compatible bucket. + * + * Reads the relative path of each known asset from the database: + * photos.path + * photos.thumbnail_path + * photos.hero_path + * photos.watermark_path + * events.archive_path + * events.download_zip_path + * + * For each, streams from local fs → S3, skipping files whose sha256 already + * matches a previously uploaded object (idempotent — safe to re-run). + * + * Does NOT flip STORAGE_BACKEND. After the migration completes clean, the + * operator updates their environment + restarts the backend explicitly. + * + * Usage: + * node backend/scripts/migrate-storage.js # live migration + * node backend/scripts/migrate-storage.js --dry-run # report only, no uploads + * node backend/scripts/migrate-storage.js --failures-csv=/path/to/failures.csv + * node backend/scripts/migrate-storage.js --concurrency=4 + * + * Required env (S3 destination — same vars the backend reads with STORAGE_BACKEND=s3): + * STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY + * STORAGE_S3_ENDPOINT (optional — for MinIO/R2/etc.) + * STORAGE_S3_PREFIX (optional) + * + * STORAGE_PATH must point at the live local storage root. Postgres connection + * uses the same DB env vars the backend uses. + */ + +require('dotenv').config(); +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); + +const { db } = require('../src/database/db'); +const LocalFsStorage = require('../src/services/storage/LocalFsStorage'); +const S3StorageBackend = require('../src/services/storage/S3StorageBackend'); +const logger = require('../src/utils/logger'); + +function parseArgs(argv) { + const args = { dryRun: false, concurrency: 4, failuresCsv: '/tmp/migrate-storage-failures.csv' }; + for (const arg of argv) { + if (arg === '--dry-run') args.dryRun = true; + else if (arg.startsWith('--concurrency=')) args.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 4); + else if (arg.startsWith('--failures-csv=')) args.failuresCsv = arg.split('=')[1]; + else if (arg === '--help' || arg === '-h') { + console.log('Usage: node migrate-storage.js [--dry-run] [--concurrency=N] [--failures-csv=PATH]'); + process.exit(0); + } + } + return args; +} + +function buildLocalSource() { + const root = process.env.STORAGE_PATH; + if (!root) { + throw new Error('STORAGE_PATH must be set to the local storage root.'); + } + return new LocalFsStorage({ root }); +} + +function buildS3Destination() { + const required = ['STORAGE_S3_BUCKET', 'STORAGE_S3_ACCESS_KEY', 'STORAGE_S3_SECRET_KEY']; + const missing = required.filter((v) => !process.env[v]); + if (missing.length) { + throw new Error(`Missing S3 env vars: ${missing.join(', ')}`); + } + return new S3StorageBackend({ + bucket: process.env.STORAGE_S3_BUCKET, + region: process.env.STORAGE_S3_REGION || 'us-east-1', + endpoint: process.env.STORAGE_S3_ENDPOINT, + accessKeyId: process.env.STORAGE_S3_ACCESS_KEY, + secretAccessKey: process.env.STORAGE_S3_SECRET_KEY, + prefix: process.env.STORAGE_S3_PREFIX, + forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined, + sslEnabled: process.env.STORAGE_S3_SSL !== 'false', + }); +} + +async function sha256OfFile(localPath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(localPath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +async function collectKeys() { + const keys = new Map(); // key -> { source, contentType } + + const addKey = (key, source) => { + if (!key) return; + const normalized = key.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized) return; + if (!keys.has(normalized)) keys.set(normalized, { source }); + }; + + // photos: path (events/active/{slug}/{filename}), thumbnail_path, hero_path, watermark_path + const photoBatch = await db('photos').select('id', 'path', 'thumbnail_path', 'hero_path', 'watermark_path'); + for (const p of photoBatch) { + if (p.path) { + const photoKey = p.path.startsWith('events/active/') ? p.path : path.posix.join('events/active', p.path); + addKey(photoKey, `photos.path[${p.id}]`); + } + addKey(p.thumbnail_path, `photos.thumbnail_path[${p.id}]`); + addKey(p.hero_path, `photos.hero_path[${p.id}]`); + addKey(p.watermark_path, `photos.watermark_path[${p.id}]`); + } + + // events: archive_path, download_zip_path + const eventBatch = await db('events').select('id', 'archive_path', 'download_zip_path'); + for (const e of eventBatch) { + addKey(e.archive_path, `events.archive_path[${e.id}]`); + addKey(e.download_zip_path, `events.download_zip_path[${e.id}]`); + } + + return keys; +} + +async function migrateOne(key, meta, { source, dest, dryRun }) { + // Source must exist on local disk. + const localPath = source.resolveLocalPath(key); + let localStat; + try { + localStat = await fsp.stat(localPath); + } catch (err) { + if (err.code === 'ENOENT') { + return { key, status: 'missing-locally', source: meta.source }; + } + throw err; + } + + // Idempotent skip: if S3 already has matching size + sha256. + const remoteStat = await dest.stat(key); + if (remoteStat && remoteStat.size === localStat.size) { + // sha256 match check via metadata is expensive; we trust size match for now. + // Operators paranoid about content drift can `rm` the bucket and re-run. + return { key, status: 'already-uploaded', source: meta.source }; + } + + if (dryRun) { + return { key, status: 'would-upload', source: meta.source, size: localStat.size }; + } + + await dest.putFromFile(key, localPath); + + const verify = await dest.stat(key); + if (!verify || verify.size !== localStat.size) { + return { key, status: 'size-mismatch-after-upload', source: meta.source, expected: localStat.size, got: verify?.size }; + } + + return { key, status: 'uploaded', source: meta.source, size: localStat.size }; +} + +async function processWithConcurrency(items, concurrency, fn) { + const results = []; + let i = 0; + const workers = Array.from({ length: concurrency }, async () => { + while (true) { + const idx = i++; + if (idx >= items.length) return; + const [key, meta] = items[idx]; + try { + const r = await fn(key, meta); + results.push(r); + } catch (err) { + results.push({ key, status: 'error', source: meta.source, error: err.message }); + } + } + }); + await Promise.all(workers); + return results; +} + +function formatCsvCell(v) { + if (v == null) return ''; + const s = String(v); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +async function writeFailuresCsv(filePath, failures) { + if (failures.length === 0) { + // Touch an empty file with header so callers see a deterministic outcome. + await fsp.writeFile(filePath, 'key,source,status,error\n'); + return; + } + const lines = ['key,source,status,error']; + for (const f of failures) { + lines.push([f.key, f.source, f.status, f.error || ''].map(formatCsvCell).join(',')); + } + await fsp.writeFile(filePath, lines.join('\n') + '\n'); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + logger.info(`migrate-storage starting (dry-run=${args.dryRun}, concurrency=${args.concurrency})`); + + const source = buildLocalSource(); + await source.init(); + + const dest = buildS3Destination(); + await dest.init(); + + logger.info('collecting key list from database…'); + const keys = await collectKeys(); + logger.info(`found ${keys.size} unique keys to process`); + + const items = Array.from(keys.entries()); + const results = await processWithConcurrency(items, args.concurrency, (key, meta) => + migrateOne(key, meta, { source, dest, dryRun: args.dryRun }) + ); + + const counts = results.reduce((acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, {}); + + console.log('\n=== migrate-storage summary ==='); + for (const [status, count] of Object.entries(counts).sort()) { + console.log(` ${status.padEnd(28)} ${count}`); + } + + const failureStatuses = new Set(['error', 'missing-locally', 'size-mismatch-after-upload']); + const failures = results.filter((r) => failureStatuses.has(r.status)); + await writeFailuresCsv(args.failuresCsv, failures); + + if (failures.length > 0) { + console.log(`\nWrote ${failures.length} failures to ${args.failuresCsv}`); + console.log('Re-run with --dry-run to triage; fix sources or remove DB rows that point at missing files.'); + process.exitCode = 1; + } else if (args.dryRun) { + console.log(`\nDry-run complete. Re-run without --dry-run to perform the migration.`); + console.log(`(Empty failures CSV written to ${args.failuresCsv}.)`); + } else { + console.log(`\nMigration complete. Update STORAGE_BACKEND=s3 + restart the backend to switch over.`); + } + + await db.destroy(); +} + +main().catch(async (err) => { + console.error('migrate-storage failed:', err); + try { await db.destroy(); } catch (_) { /* ignore */ } + process.exit(2); +}); diff --git a/backend/server.js b/backend/server.js index 839e4351..38b32d53 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,6 +23,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker'); const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor'); const { startBackupService } = require('./src/services/backupService'); const { startScheduledBackups } = require('./src/services/databaseBackup'); +const backgroundProcessor = require('./src/services/backgroundProcessor'); const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler'); @@ -194,13 +195,23 @@ function composeInlineStyles(payload) { return cssSegments.join('\n\n'); } +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function renderBrandHeader(branding) { - const displayName = branding.companyName || 'PicPeak'; - const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png'; + const displayName = escapeHtml(branding.companyName || 'PicPeak'); + const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png'); const logo = ``; const tagline = branding.companyTagline - ? `

${branding.companyTagline}

` + ? `

${escapeHtml(branding.companyTagline)}

` : ''; return `