diff --git a/.dockerignore b/.dockerignore index 9f7be110..bfe07bc0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,10 @@ .env.* docker-compose*.yml .DS_Store -node_modules +# **/ so backend/node_modules and frontend/node_modules are excluded too — +# the root-context Dockerfile.aio COPYs those directories and must get its +# deps from its builder stages, never from the host checkout. +**/node_modules npm-debug.log coverage .nyc_output @@ -18,3 +21,31 @@ storage/events/archived/* storage/thumbnails/* data/*.db logs/* + +# Dockerfile.aio builds from the REPOSITORY ROOT and Docker reads only this +# file — backend/.dockerignore is never consulted — so the unprefixed rules +# above miss backend/data, backend/logs and backend/storage. A checkout that has +# been used to run PicPeak would otherwise bake its database, photos, logs and +# SETUP_TOKEN into a published image layer. +# backend/data wholesale, not a suffix list. It holds only runtime state and is +# gitignored in full (.gitignore: `data/`), while suffix rules kept letting real +# secrets through: a used checkout here carries ADMIN_CREDENTIALS.txt alongside +# the database, plus -journal files and any DATABASE_PATH that does not end in +# .db. Any of those in a published layer is a credential leak. +backend/data +backend/logs +backend/storage + +# Same root-context trap, one level deeper: the `.env`, `.env.*` and `data/*.db` +# rules above are unanchored only in appearance — Docker matches them against the +# path from the build context, so they catch `./.env` and never `backend/.env`. +# A checkout that has been used to run PicPeak locally keeps its JWT_SECRET, +# DB_PASSWORD and SMTP credentials there, and `COPY backend/ .` puts the file at +# /app/.env in the published layer. Match at any depth instead, the way +# **/node_modules above already does. +**/.env +**/.env.* +**/*.db +**/*.db-journal +**/*.sqlite* +frontend/dist diff --git a/.github/workflows/README-DOCKER.md b/.github/workflows/README-DOCKER.md index c04f9a8c..615a2836 100644 --- a/.github/workflows/README-DOCKER.md +++ b/.github/workflows/README-DOCKER.md @@ -1,6 +1,8 @@ # Docker Build and Push Workflow -This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io). +This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, and the all-in-one image to GitHub Container Registry (ghcr.io). + +The **all-in-one image** (`/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is currently GHCR-only (the Docker Hub mirror gets wired later), and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution. ## Features diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ea3ef590..ea8657ff 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -599,8 +599,388 @@ jobs: run: | docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }} + # ----------------------------------------------------------------------------- + # All-in-one (#1042): backend + built frontend in one container, SQLite default. + # Same per-arch build → digest merge pattern as backend/frontend. Context is + # the repo root (Dockerfile.aio needs backend/ AND frontend/). + # ----------------------------------------------------------------------------- + build-aio: + 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 + # Per-arch Trivy scan by digest, same rationale as build-backend (#476). + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Compute image name (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV" + + - name: Prepare platform pair + run: | + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + id: login-ghcr + continue-on-error: true + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 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 AIO (labels only) + id: meta-aio + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }} + labels: | + org.opencontainers.image.title=PicPeak All-in-one + org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default) + org.opencontainers.image.vendor=PicPeak + maintainer=${{ github.repository_owner }} + + - name: Build AIO image (push by digest) + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.aio + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta-aio.outputs.labels }} + cache-from: type=gha,scope=aio-${{ env.PLATFORM_PAIR }} + # ignore-error: a flaky GitHub Actions cache write must not fail an + # otherwise-successful build that already pushed the image. + cache-to: type=gha,mode=max,scope=aio-${{ env.PLATFORM_PAIR }},ignore-error=true + 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.AIO_IMAGE_NAME) || 'type=cacheonly' }} + build-args: | + CACHEBUST=${{ github.run_number }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta-aio.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-aio-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Run Trivy vulnerability scanner (per-arch, by digest) + if: steps.push-decision.outputs.push == 'true' + uses: aquasecurity/trivy-action@v0.36.0 + env: + # See build-backend — pin Trivy's platform to the matrix arch so its + # remote-index resolver picks the right child. + TRIVY_PLATFORM: ${{ matrix.platform }} + with: + image-ref: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: 'sarif' + output: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif' + severity: 'CRITICAL,HIGH' + timeout: '10m' + + - name: Upload Trivy scan results to GitHub Security tab + if: steps.push-decision.outputs.push == 'true' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif' + category: 'aio-vulnerabilities-${{ env.PLATFORM_PAIR }}' + + merge-aio: + needs: build-aio + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + + steps: + - name: Compute image name (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV" + + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-aio-* + 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/main ]]; 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 + + # Same per-version tag scheme as backend/frontend: every Release Please + # version publishes a matching aio image. GHCR-only for now — the Docker + # Hub mirror (docker.io/picpeak/aio) is wired later once the Hub repo + # exists: add the images line + Docker Hub login exactly like + # merge-backend (#1042). + - name: Extract metadata for AIO + id: meta-aio + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }} + labels: | + org.opencontainers.image.title=PicPeak All-in-one + org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default) + org.opencontainers.image.vendor=PicPeak + maintainer=${{ github.repository_owner }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + # #668/#783: publish the git-tag name verbatim, same as backend/frontend. + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + + - 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.AIO_IMAGE_NAME }}@sha256:%s " *) + + - name: Inspect manifest (GHCR) + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}:${{ steps.meta-aio.outputs.version }} + + # Boot-level verification of the AIO image on every PR: build for the + # runner's arch, run it with no DB env (SQLite default), and assert the + # things nginx used to guarantee — SPA shell with the brand title rendered, + # immutable asset caching, /health green, and the resolver landing on + # SQLite. Mirrors the install-smoke workflow's build pattern. + smoke-aio: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build AIO image (single arch) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.aio + load: true + tags: picpeak-aio:smoke + cache-from: type=gha,scope=aio-linux-amd64 + cache-to: type=gha,mode=max,scope=aio-linux-amd64,ignore-error=true + + - name: Boot container (SQLite default, no volumes) + run: | + docker run -d --name aio -p 3000:3000 \ + -e JWT_SECRET=smoke-test-secret-at-least-32-characters-long \ + -e BRAND_TITLE="AIO Smoke" \ + picpeak-aio:smoke + + - name: Wait for /health + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:3000/health > /dev/null 2>&1; then + echo "healthy after ~$((i*2))s"; exit 0 + fi + sleep 2 + done + echo "::error::/health never came up"; docker logs aio | tail -100; exit 1 + + - name: Assert engine resolved to SQLite + run: | + docker exec aio ls -la /data/db/picpeak.db + docker logs aio 2>&1 | grep -i "sqlite" | head -5 + + - name: Assert SPA shell served with rendered brand title + run: | + body=$(curl -fsS http://localhost:3000/admin) + echo "$body" | grep -q '
' || { echo "::error::/admin did not serve the SPA shell"; exit 1; } + echo "$body" | grep -q 'AIO Smoke' || { echo "::error::BRAND_TITLE was not rendered into index.html"; exit 1; } + # -F on the literal token: index.html's explanatory comment mentions + # BRAND_TITLE in prose and Vite keeps that comment in the built shell, + # so a bare `grep BRAND_TITLE` always matches. Only an unsubstituted + # ${BRAND_TITLE}/${BRAND_DESCRIPTION} is a real leak. + for tok in '${BRAND_TITLE}' '${BRAND_DESCRIPTION}'; do + echo "$body" | grep -qF "$tok" && { echo "::error::unrendered placeholder $tok leaked"; exit 1; } || true + done + + - name: Assert hashed assets are cached immutably + run: | + asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1) + test -n "$asset" || { echo "::error::no asset reference found in SPA shell"; exit 1; } + headers=$(curl -fsSI "http://localhost:3000${asset}") + echo "$headers" | grep -qi 'cache-control:.*immutable' || { echo "::error::asset served without immutable cache header"; echo "$headers"; exit 1; } + + - name: Assert API and root respond + run: | + curl -fsS http://localhost:3000/api/public/settings > /dev/null + # `/` goes to handlePublicSiteRequest, which 302s to /admin/login while + # the public landing site is disabled — the state of a fresh install, + # which is exactly what this container is. Assert the redirect target + # rather than a 200, so the check still proves express.static's index + # option isn't shadowing the handler. + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/) + loc=$(curl -s -o /dev/null -w '%{redirect_url}' http://localhost:3000/) + [[ "$code" == "302" && "$loc" == *"/admin/login" ]] \ + || { echo "::error::/ returned $code (Location: ${loc:-none}); expected 302 -> /admin/login"; exit 1; } + + - name: Assert every client route survives a direct hit + run: | + # nginx did `try_files $uri $uri/ /index.html`, so behind compose these + # always worked and nothing caught their absence here. /setup is the + # first URL a new install visits. + for r in /setup /customer /impressum /datenschutz /payment-check \ + /quote/x /contract/x /invite/x /transfer/x /transfer-upload/x; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}") + [[ "$code" == "200" ]] || { echo "::error::${r} returned $code, expected the SPA shell"; exit 1; } + done + + - name: Assert the catch-all did not swallow the API or the short-URL resolver + run: | + # The SPA catch-all is registered after the /api 404 handler, so an + # unknown API route must still answer JSON rather than the HTML shell. + body=$(curl -s "http://localhost:3000/api/nope") + grep -q '
' <<< "$body" && { echo "::error::unknown /api route served the SPA shell"; exit 1; } || true + grep -q '"error"' <<< "$body" || { echo "::error::unknown /api route did not answer JSON: $body"; exit 1; } + # A typo'd short URL must still 404 rather than render the shell (#699). + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/s/nonexistent) + [[ "$code" == "404" ]] || { echo "::error::/s/ returned $code, expected 404"; exit 1; } + + - name: Assert the SPA bundle is gzipped + run: | + asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1) + # GET, not HEAD: the compression middleware skips bodyless responses, + # so a HEAD probe reports no Content-Encoding even when gzip is active. + enc=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "http://localhost:3000${asset}" | grep -i '^content-encoding:') + grep -qi gzip <<< "$enc" || { echo "::error::asset served uncompressed (compression middleware inactive?)"; exit 1; } + + - name: Assert the one-volume layout and backup destinations + run: | + # #1042 asks for a single mountable root. Everything that must survive a + # container replacement lives under /data, and /backup — where migrations + # 029/030 seed the backup destinations — symlinks into it rather than + # dangling inside the container. + docker exec aio sh -c 'test -L /backup' || { echo "::error::/backup is not a symlink into the volume"; exit 1; } + for d in /data/db /data/storage /data/logs /data/backup/picpeak /data/backup/database; do + docker exec aio sh -c "test -d $d" || { echo "::error::$d missing from the volume layout"; exit 1; } + done + docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \ + || { echo "::error::/backup/database is not writable by the app user"; exit 1; } + + - name: Assert the sqlite3 CLI the backup service shells out to + run: | + # DatabaseBackupService spawns `sqlite3` for .backup and integrity_check; + # the npm module does not ship the binary. + docker exec aio sqlite3 --version > /dev/null \ + || { echo "::error::sqlite3 CLI missing — database backups would fail with ENOENT"; exit 1; } + + - name: Assert logs land on the volume + run: | + docker exec aio sh -c 'ls /data/logs/*.log > /dev/null 2>&1' \ + || { echo "::error::logs are not being written under /data (LOG_DIR ignored?)"; exit 1; } + + - name: Assert backend static routes still 404 instead of the SPA shell + run: | + # /photos, /thumbnails, /uploads and /fonts are backend-owned mounts whose + # middleware calls next() on a miss. nginx gave them their own location + # blocks so try_files never applied; without an explicit exclusion the + # catch-all answers 200 text/html under an image or font URL. + for r in /photos/missing.jpg /thumbnails/missing.jpg /fonts/missing.woff2; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}") + [[ "$code" != "200" ]] || { echo "::error::${r} returned 200 — the SPA catch-all swallowed a backend 404"; exit 1; } + done + + - name: Assert the image carries no runtime data from the build context + run: | + # Dockerfile.aio builds from the repo root; a checkout used to run + # PicPeak must never bake its database, photos, logs or secrets into a + # layer. /app/storage is deliberately a symlink into the volume, so it + # is checked by shape rather than by listing it — following the link + # would only find the empty tree the image creates at /data/storage. + for leak in '/app/data/*.db' '/app/logs/*' '/app/.env' '/app/*.db' '/app/*.sqlite*'; do + if docker exec aio sh -c "ls $leak > /dev/null 2>&1"; then + echo "::error::build context leaked $leak into the image"; exit 1 + fi + done + docker exec aio sh -c 'test -L /app/storage' \ + || { echo "::error::/app/storage is a real directory — the build context leaked it in"; exit 1; } + test "$(docker exec aio sh -c 'readlink /app/storage')" = /data/storage \ + || { echo "::error::/app/storage does not point into the mounted volume"; exit 1; } + # The volume's photo tree must start empty on a fresh install. + found=$(docker exec aio sh -c 'find /data/storage/events -type f | head -1') + test -z "$found" || { echo "::error::build context leaked photos into /data/storage/events: $found"; exit 1; } + + - name: Dump logs on failure + if: failure() + run: docker logs aio 2>&1 | tail -200 + summary: - needs: [build-backend, merge-backend, build-frontend, merge-frontend] + needs: [build-backend, merge-backend, build-frontend, merge-frontend, build-aio, merge-aio, smoke-aio] if: always() runs-on: ubuntu-latest permissions: @@ -612,6 +992,7 @@ jobs: repo_lc="${GITHUB_REPOSITORY,,}" echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV" # Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the # canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any # other owner) fall back to GHCR-only — the Docker Hub image line and login @@ -655,10 +1036,31 @@ jobs: echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY fi + if [[ "${{ needs.build-aio.result }}" == "success" ]]; then + echo "✅ **AIO build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **AIO build (per-arch)**: ${{ needs.build-aio.result }}" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "${{ needs.merge-aio.result }}" == "success" ]]; then + echo "✅ **AIO manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ needs.merge-aio.result }}" == "skipped" ]]; then + echo "ℹ️ **AIO manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **AIO manifest merge**: ${{ needs.merge-aio.result }}" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "${{ needs.smoke-aio.result }}" == "success" ]]; then + echo "✅ **AIO boot smoke**: SQLite boot + SPA + caching verified" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **AIO boot smoke**: ${{ needs.smoke-aio.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 "- All-in-one: \`${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}\` (GHCR only — Docker Hub mirror pending)" >> $GITHUB_STEP_SUMMARY if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile.aio b/Dockerfile.aio new file mode 100644 index 00000000..d97f7120 --- /dev/null +++ b/Dockerfile.aio @@ -0,0 +1,178 @@ +# All-in-one image (#1042): one container, one Node process. +# +# The backend serves the built frontend itself via server.js's SERVE_FRONTEND +# block (SPA fallback, OG crawler intercept, brand-title render, immutable +# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite +# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+ +# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend +# image. Build context is the REPO ROOT (both backend/ and frontend/ are +# needed): docker build -f Dockerfile.aio . +# +# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's +# production stage (base image, apk set, npm removal, nodejs user, fontconfig +# registration, directory layout, healthcheck, entrypoint). When +# backend/Dockerfile changes, change this file too — the aio smoke job in +# docker-build.yml catches boot-level drift, not package-level drift. + +# --------------------------------------------------------------------------- +# Frontend build — mirrors frontend/Dockerfile's builder stage +# --------------------------------------------------------------------------- +FROM node:22-alpine AS frontend-builder + +ARG CACHEBUST=1 + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm ci --legacy-peer-deps + +COPY frontend/ . +RUN npm run build + +# --------------------------------------------------------------------------- +# Backend deps — mirrors backend/Dockerfile's builder stage +# --------------------------------------------------------------------------- +FROM node:22-alpine AS backend-builder + +ARG CACHEBUST=1 + +WORKDIR /app + +COPY backend/package*.json ./ +RUN npm ci --omit=dev + +# --------------------------------------------------------------------------- +# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist +# --------------------------------------------------------------------------- +FROM node:22-alpine + +ARG CACHEBUST=1 +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak" +LABEL org.opencontainers.image.description="PicPeak all-in-one (backend + frontend, single container)" +LABEL org.opencontainers.image.licenses="MIT" + +WORKDIR /app + +# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT +# engine — set explicitly, never inferred, and wait-for-db.sh skips its +# Postgres readiness wait for it. Point the container at an external Postgres +# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD, +# exactly like the backend image. The boot resolver still logs the engine and +# refuses the populated-both conflict. +# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname, +# '../../../storage') — which resolves to the container-root `/storage` here, +# writable by root but EACCES for the nodejs user after the su-exec drop. +# Compose masks this by setting STORAGE_PATH=/app/storage; this image must +# pin the same path (it is the directory the Dockerfile creates and chowns). +ENV NODE_ENV=production \ + DATABASE_CLIENT=sqlite3 + +# See backend/Dockerfile for the rationale of each of the following blocks. +RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache + +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3` +# CLI for `.backup` and PRAGMA integrity_check; the npm module does not +# ship that binary. backend/Dockerfile omits it because compose always runs +# Postgres — this image defaults to SQLite, so without it every database +# backup fails with ENOENT. +RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \ + fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \ + fc-cache -f + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +COPY --from=backend-builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --chown=nodejs:nodejs backend/ . + +RUN chmod -R a+r /app && chmod +x wait-for-db.sh + +RUN printf '\n\n\n /app/assets/fonts\n\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \ + fc-cache -f /app/assets/fonts + +# --------------------------------------------------------------------------- +# One volume, one layout (#1042 scope: "single data layout on one volume") +# --------------------------------------------------------------------------- +# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN +# /data/storage originals, thumbnails, archives +# /data/logs application logs +# /data/backup built-in backup output; /backup symlinks here +# +# `-v picpeak:/data` and nothing else to remember — back up /data and you have +# backed up the install. /backup is where migrations 029 + 030 seed the backup +# destinations, so it is symlinked in rather than left dangling. +ENV DATA_ROOT=/data \ + DATA_DIR=/data/db \ + DATABASE_PATH=/data/db/picpeak.db \ + STORAGE_PATH=/data/storage \ + LOG_DIR=/data/logs \ + BACKUP_DIR=/data/backup + +# Share links are absolute only when a base URL is known: getFrontendBaseUrl() +# reads FRONTEND_URL, falls back to the general_site_url setting, and otherwise +# returns "" — which makes share_url come out as a bare "/gallery//" +# in API responses, QR codes and emails. docker-compose.yml defaults this to +# http://localhost:3000, but the documented one-liner for this image passes only +# JWT_SECRET, so without a default here every fresh single-container install +# would hand out unusable links. Same default as compose; override with +# -e FRONTEND_URL=https://photos.example.com, or set the site URL in Settings. +ENV FRONTEND_URL=http://localhost:3000 + +# /app/storage is a second entrance to the same volume. The business-document +# writers (quoteService, invoice sending/reminders, contract signatures) build +# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult +# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage +# with WORKDIR /app, so the two happen to be the same directory; here they are +# not, and /app is root-owned, so a quote or invoice PDF would fail to write as +# UID 1001 — and be lost with the container even if it succeeded. Teaching +# those services STORAGE_PATH is the real fix and belongs in its own change; +# the symlink restores the coincidence compose already relies on. +RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \ + /data/storage/thumbnails /data/logs \ + /data/backup/picpeak /data/backup/database && \ + ln -s /data/backup /backup && \ + ln -s /data/storage /app/storage && \ + chown -R nodejs:nodejs /data + +VOLUME ["/data"] + +# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit +# opt-in rather than the dist-exists autodetect, so the behavior is pinned +# even if the autodetect heuristic ever changes. +COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist +ENV SERVE_FRONTEND=true \ + FRONTEND_DIR=/app/frontend/dist + +# Marks this as the single-container build. The backend refuses to enable face +# recognition (#1074) when it sees this, on performance grounds: that feature +# needs a separate ML container this image does not contain, and it would add +# a second image-processing pipeline competing with Sharp for the CPU and +# memory of a container sized for one photographer plus guests browsing. The +# failure would not be loud — just a slow install that looks broken. +# +# An explicit marker rather than inferring it from SERVE_FRONTEND or the +# SQLite path: legitimate multi-container deployments do both of those, and +# none of them should lose the feature by accident. +ENV PICPEAK_SINGLE_CONTAINER=true + +# No USER directive — same as backend/Dockerfile: the container starts as root +# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops +# privileges via su-exec (#484). + +EXPOSE 3000 + +# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy +# container unhealthy forever the moment anyone overrides the port. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1 + +ENTRYPOINT ["dumb-init", "--"] +# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`. +# Requests reach Node directly here, and its 16 KiB default would reject a guest +# carrying several per-gallery JWT cookies before Express ever saw them. +CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"] diff --git a/README.md b/README.md index 441b6feb..c121aacc 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,21 @@ On first start, open **http://localhost:3000/admin** and follow the in-browser s > **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence. +### Or: one container, no compose file + +For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up: + +```bash +docker run -d --name picpeak -p 3000:3000 \ + -v picpeak:/data \ + -e JWT_SECRET="$(openssl rand -base64 48)" \ + ghcr.io/picpeak/picpeak/aio:stable +``` + +Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`. + +The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits. + ## 🌟 Why PicPeak? Unlike expensive SaaS solutions, PicPeak gives you: @@ -107,6 +122,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — | Topic | Link | |---|---| | 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) | +| 📦 Single-container install (one `docker run`, SQLite) | [docs.picpeak.app/deployment/single-container](https://docs.picpeak.app/deployment/single-container) | | ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) | | 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) | | 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) | diff --git a/backend/__tests__/middleware/maintenance.spaShell.test.js b/backend/__tests__/middleware/maintenance.spaShell.test.js new file mode 100644 index 00000000..87d2462b --- /dev/null +++ b/backend/__tests__/middleware/maintenance.spaShell.test.js @@ -0,0 +1,123 @@ +/** + * Regression test for the maintenance-mode lockout in single-container mode. + * + * In the compose stack nginx serves the frontend, so a request for /admin/login + * or /gallery/ never reaches Express. The all-in-one image (#1042) has no + * nginx: server.js serves the SPA itself, and maintenanceMiddleware is mounted + * far ahead of that static block. Gating those paths therefore answered the + * HTML document with 503 JSON, which broke two things at once — + * + * 1. an admin who enabled maintenance mode could never disable it, because + * /admin/login and its /assets/ bundle would not load (the login *API* was + * already exempt, but nothing could call it), and + * 2. a guest saw raw JSON instead of the branded maintenance screen the + * frontend already ships. + * + * The shell is inert HTML: it boots, calls /api/public/settings (exempt) and + * renders MaintenanceMode itself, so letting it through costs nothing. + * + * The dividing line is taken from frontend/nginx.conf rather than invented: + * paths nginx answers from the frontend container are exempt, paths it + * proxy_passes to the backend stay gated. That makes the all-in-one image + * behave exactly like compose in both directions. The gated half is where the + * risk lives — a negative "everything that is not an API is a shell" rule + * looks right and quietly un-gates /og/ (event names, cover images) and the + * public CMS at the site root — so most of the cases below assert it. + */ + +const { maintenanceMiddleware } = require('../../src/middleware/maintenance'); + +jest.mock('../../src/database/db', () => { + const settings = { setting_key: 'general_maintenance_mode', setting_value: 'true' }; + const db = jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(settings), + })); + return { db }; +}); +jest.mock('../../src/utils/logger', () => ({ + error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), +})); + +// Maintenance state is cached for a minute; each case starts from a clean read. +const { clearMaintenanceCache } = require('../../src/middleware/maintenance'); + +async function run(path, { method = 'GET', authorization } = {}) { + clearMaintenanceCache(); + const req = { path, method, headers: authorization ? { authorization } : {} }; + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; }, + }; + const next = jest.fn(); + await maintenanceMiddleware(req, res, next); + return { passed: next.mock.calls.length === 1, status: res.statusCode, body: res.body }; +} + +describe('maintenanceMiddleware — SPA shell vs API split', () => { + describe('passes the frontend shell through so the branded screen can render', () => { + it.each([ + ['/admin', 'admin shell entry'], + ['/admin/login', 'the page that calls the exempt login API'], + ['/assets/index-abc123.js', 'hashed bundle the shell loads'], + ['/gallery/some-event', 'guest gallery route'], + ['/customer/portal', 'customer portal route'], + ])('%s (%s)', async (path) => { + const { passed } = await run(path); + expect(passed).toBe(true); + }); + }); + + describe('still gates everything that is not a shell', () => { + it.each([ + ['/api/gallery/some-event/verify', 'public gallery API'], + ['/api/photos/1', 'photo API'], + ['/photos/anything.jpg', 'backend-owned photo mount'], + ['/thumbnails/anything.jpg', 'backend-owned thumbnail mount'], + ['/fonts/anything.woff2', 'backend-owned font mount'], + // nginx proxy_passes these to the backend, so compose gates them today + // and the all-in-one image must not be the one deployment that does not. + ['/', 'site root — nginx `location = /` hands this to the public CMS'], + ['/og/gallery/some-event', 'OG renderer: leaks the event name'], + ['/og/gallery/some-event/cover', 'OG cover: leaks the hero thumbnail'], + ['/s/abc123', 'short-link renderer'], + ['/robots.txt', 'proxied one-to-one by nginx'], + ['/favicon.ico', 'proxied one-to-one by nginx'], + ])('%s (%s) returns 503', async (path) => { + const { passed, status, body } = await run(path); + expect(passed).toBe(false); + expect(status).toBe(503); + expect(body).toMatchObject({ maintenance: true }); + }); + + it('does not let a non-GET request masquerade as a shell load', async () => { + const { passed, status } = await run('/api/gallery/some-event/verify', { method: 'POST' }); + expect(passed).toBe(false); + expect(status).toBe(503); + }); + }); + + describe('keeps the pre-existing admin exemptions', () => { + it('admin login API stays reachable', async () => { + expect((await run('/api/auth/admin/login', { method: 'POST' })).passed).toBe(true); + }); + + it('/api/public/settings stays reachable so the shell can read the flag', async () => { + expect((await run('/api/public/settings')).passed).toBe(true); + }); + + it('an authenticated admin still reaches /api/admin', async () => { + const { passed } = await run('/api/admin/events', { authorization: 'Bearer token' }); + expect(passed).toBe(true); + }); + + it('an unauthenticated /api/admin request is not served by this middleware', async () => { + // isAdminRoute suppresses the 503 so the auth layer can answer 401. + const { passed, status } = await run('/api/admin/events'); + expect(passed).toBe(true); + expect(status).toBeNull(); + }); + }); +}); diff --git a/backend/package-lock.json b/backend/package-lock.json index 5b5da754..91e18660 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.101.3-beta.0", + "version": "3.103.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.101.3-beta.0", + "version": "3.103.1-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -15,6 +15,7 @@ "axios": "1.18.1", "bcrypt": "6.0.0", "chokidar": "4.0.3", + "compression": "^1.7.5", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cron-parser": "^4.9.0", @@ -323,7 +324,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz", "integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -1052,7 +1052,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -3947,7 +3946,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4562,7 +4560,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -5036,6 +5033,60 @@ "node": ">= 10" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -5702,7 +5753,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5950,7 +6000,6 @@ "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", @@ -6918,7 +6967,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9509,6 +9557,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9827,7 +9884,6 @@ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", "license": "MIT", - "peer": true, "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", @@ -10922,7 +10978,6 @@ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", "license": "MIT", - "peer": true, "dependencies": { "parseley": "~0.13.1" }, diff --git a/backend/package.json b/backend/package.json index 0133106e..c6a0a043 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "axios": "1.18.1", "bcrypt": "6.0.0", "chokidar": "4.0.3", + "compression": "^1.7.5", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cron-parser": "^4.9.0", diff --git a/backend/server.js b/backend/server.js index 9bdafa8a..8675a7f1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -65,6 +65,7 @@ logger.info('Server starting up', { const fs = require('fs'); const express = require('express'); const helmet = require('helmet'); +const compression = require('compression'); const cors = require('cors'); const path = require('path'); const { initializeDatabase, db } = require('./src/database/db'); @@ -875,7 +876,11 @@ app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/images', require('./src/routes/protectedImages')); app.use('/api/secure-images', secureImagesRoutes); -// Optional: Serve built frontend (native installs) +// Optional: Serve built frontend (native installs and the all-in-one image, #1042) +// Set when the SPA is being served, and registered as a catch-all AFTER the +// /api 404 handler further down — see the registration site for why it cannot +// live inside this block. +let spaCatchAll = null; try { const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist'); @@ -884,12 +889,53 @@ try { const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath)); if (shouldServe) { logger.info(`Serving frontend from ${frontendDir}`); - // Serve pre-built assets - app.use(express.static(frontendDir)); + + // The built index.html carries ${BRAND_TITLE} / ${BRAND_DESCRIPTION} + // placeholders (#521) that the nginx image renders via envsubst in its + // entrypoint. Here the render happens once at boot, in memory — same + // semantics: locked to exactly these two vars (never the JS bundle's own + // template literals), defaults applied when unset, re-rendered on every + // process start so changing the env + restarting is enough. + const spaHtml = fs + .readFileSync(indexPath, 'utf8') + .split('${BRAND_TITLE}').join(process.env.BRAND_TITLE || 'PicPeak') + .split('${BRAND_DESCRIPTION}').join(process.env.BRAND_DESCRIPTION || 'Photo gallery shared with PicPeak.'); + // Mirrors nginx's `location = /index.html` cache rule: the SPA shell must + // revalidate so a redeploy is picked up, while the hashed assets below + // cache immutably. + const sendSpa = (res) => { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.type('html').send(spaHtml); + }; + + // gzip for the SPA bundle (nginx parity — its server block gzips js/css/ + // json). Mounted here, after every /api router, so API responses keep + // their exact current behavior; only the statics and SPA shell below + // pass through it. + app.use(compression()); + + // /index.html must serve the RENDERED shell, and express.static would + // otherwise answer first with the raw template straight off disk. + app.get('/index.html', (req, res) => sendSpa(res)); + + // Serve pre-built assets. index:false keeps `/` flowing to the landing-page + // handler below (nginx parity: `location = /` goes to the backend, it never + // serves index.html off disk) — express.static's default index option was + // shadowing handlePublicSiteRequest in native installs. Vite's hashed + // /assets/* get nginx's 1y-immutable rule; everything else keeps etag + // revalidation. + app.use(express.static(frontendDir, { + index: false, + setHeaders: (res, filePath) => { + if (/[/\\]assets[/\\]/.test(filePath)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, + })); // Landing page handler or SPA fallback app.get('/', handlePublicSiteRequest, (req, res) => { - res.sendFile(indexPath); + sendSpa(res); }); // SPA fallback for admin + gallery routes. For gallery URLs we intercept @@ -908,12 +954,22 @@ try { } return next(); }; - app.get('/gallery/:slug/:token?', ogIntercept, (req, res) => res.sendFile(indexPath)); - app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => res.sendFile(indexPath)); + app.get('/gallery/:slug/:token?', ogIntercept, (req, res) => sendSpa(res)); + app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => sendSpa(res)); app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => { - res.sendFile(indexPath); + sendSpa(res); }); + + // Everything else the router owns client-side. nginx did `try_files $uri + // $uri/ /index.html`, so behind compose every client route survived a + // reload and the short route list above was never exercised. Without + // nginx it is the whole contract: /setup, /customer, /impressum, + // /datenschutz, /payment-check, /quote/:token, /contract/:token, + // /invite/:token, /transfer/:token, /transfer-upload/:token and the + // branded short URLs all 404'd on a direct hit or a refresh. /setup is + // the first URL a new install visits. + spaCatchAll = (req, res) => sendSpa(res); } else { logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir }); app.get('/', handlePublicSiteRequest, (req, res) => { @@ -927,6 +983,30 @@ try { // 404 handler for undefined API routes app.use('/api', notFoundHandler); +// SPA history fallback, deliberately registered here — AFTER the /api 404 +// handler, so an unknown /api/* route still answers JSON instead of being +// handed the HTML shell, and after the short-URL resolver so a real short +// code still redirects. GET-only: a stray POST/PUT keeps 404ing rather than +// getting a 200 page back. +if (spaCatchAll) { + // nginx gives these their own `location` blocks, so `try_files` never applied + // to them. The fallback has to mirror that: /photos, /thumbnails, /uploads + // and /fonts are backend-owned static mounts whose middleware calls next() + // when the file is missing, and swallowing that would answer 200 text/html + // under an image or font URL instead of a 404. + // /assets/ belongs on this list for the same reason even though it is the + // frontend's own bundle: after an upgrade a still-open tab requests the old + // hashed chunk, which no longer exists. Answering index.html would hand a + // JavaScript URL a 200 text/html body, so the module load fails with a MIME + // error instead of the plain 404 nginx returns — and the 200 hides it from + // any monitoring watching status codes. + const BACKEND_OWNED = ['/photos/', '/thumbnails/', '/uploads/', '/fonts/', '/assets/', '/health']; + app.get('*', (req, res, next) => { + if (BACKEND_OWNED.some((prefix) => req.path.startsWith(prefix))) return next(); + return spaCatchAll(req, res); + }); +} + // Global error handler (must be last) app.use(errorHandler); diff --git a/backend/src/middleware/maintenance.js b/backend/src/middleware/maintenance.js index 0a328b5f..9db25269 100644 --- a/backend/src/middleware/maintenance.js +++ b/backend/src/middleware/maintenance.js @@ -91,12 +91,53 @@ async function maintenanceMiddleware(req, res, next) { const isStaticAsset = req.path.startsWith('/uploads/') || req.path.startsWith('/favicons/') || req.path.startsWith('/logos/'); + + // The SPA shell — the HTML document and its bundle, as opposed to an API or a + // backend-owned static mount. When the backend serves the frontend itself + // (SERVE_FRONTEND / the all-in-one image, #1042) these requests reach this + // middleware long before the static block; in the compose stack nginx answers + // them and they never arrive here at all, which is why neither problem below + // ever surfaced there. + // + // Gating them broke two things. An admin who switched maintenance mode on + // could not switch it back off: the login endpoints above are exempt, but + // /admin/login and /assets/* returned 503 JSON, so the page that calls them + // never loaded. And a guest hitting /gallery/... got that same raw JSON + // instead of the branded maintenance screen the frontend already ships. + // + // Letting the shell through costs nothing: it is inert HTML that boots, calls + // /api/public/settings (exempt just above) and renders MaintenanceMode on its + // own. Anything that carries real data stays gated. + // + // The split below is not a guess — it mirrors frontend/nginx.conf exactly. + // Whatever nginx answers from the frontend container never reaches this + // middleware in a compose deployment, and whatever it proxy_passes does; so + // exempting precisely the former gives the all-in-one image the same + // behaviour compose already has, in both directions. The proxied set is + // small and explicit: /api, /photos, /thumbnails, /fonts, the OG renderer, + // the /s/ short-link renderer, and the exact paths nginx maps one-to-one — + // `location = /` hands the site root to the public-CMS handler, and the + // robots/favicon/apple-touch entries are single `location =` proxies too. + // Note /og/ and /s/ in particular: those render event names and cover + // images, so leaving them open would publish gallery metadata from a site + // that is supposed to be down. + const BACKEND_RENDERED_PREFIXES = ['/api/', '/photos/', '/thumbnails/', '/fonts/', '/og/', '/s/']; + const BACKEND_RENDERED_EXACT = [ + '/', + '/robots.txt', + '/favicon.ico', + '/apple-touch-icon.png', + '/apple-touch-icon-precomposed.png' + ]; + const isBackendRendered = BACKEND_RENDERED_EXACT.includes(req.path) + || BACKEND_RENDERED_PREFIXES.some((prefix) => req.path.startsWith(prefix)); + const isSpaShell = req.method === 'GET' && !isBackendRendered; // Allow admin routes if admin is authenticated const isAdminRoute = req.path.startsWith('/api/admin'); const hasAdminAuth = req.headers.authorization?.startsWith('Bearer '); - if (skipPaths.includes(req.path) || isStaticAsset || (isAdminRoute && hasAdminAuth)) { + if (skipPaths.includes(req.path) || isStaticAsset || isSpaShell || (isAdminRoute && hasAdminAuth)) { return next(); } diff --git a/backend/src/services/__tests__/databaseBackup.test.js b/backend/src/services/__tests__/databaseBackup.test.js index 12e73972..0609659a 100644 --- a/backend/src/services/__tests__/databaseBackup.test.js +++ b/backend/src/services/__tests__/databaseBackup.test.js @@ -64,6 +64,12 @@ describe('DatabaseBackupService', () => { // Mock getTables service.getTables = jest.fn().mockResolvedValue(['events', 'photos']); + // SQLite has no row-to-text cast, so the query builds its length sum from + // the column list — the service asks the query builder for it per table. + db.mockReturnValue({ + columnInfo: jest.fn().mockResolvedValue({ id: {}, name: {} }) + }); + // Mock SQLite response db.raw = jest.fn() .mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }]) diff --git a/backend/src/services/databaseBackup.js b/backend/src/services/databaseBackup.js index db9832fd..649bb2a2 100644 --- a/backend/src/services/databaseBackup.js +++ b/backend/src/services/databaseBackup.js @@ -90,12 +90,24 @@ class DatabaseBackupService { for (const table of tables) { if (this.dbType === 'sqlite') { - // SQLite: Use aggregate of all row data + // SQLite has no row-to-text cast: `CAST(t.* AS TEXT)` is a syntax error + // (near "*"), so this threw for every table and took the whole backup + // with it — the .backup call further down never ran. Dormant while + // Postgres was the only supported engine; guaranteed on every + // all-in-one install, where SQLite is the default (#1042). + // + // Same 'aggregate of all row data' fingerprint, built from the actual + // columns. COALESCE keeps a NULL from nulling the whole sum, which + // would let unrelated rows collide. + const columns = Object.keys(await db(table).columnInfo()); + const lengthExpr = columns.length + ? columns.map((c) => `LENGTH(COALESCE(CAST("${c}" AS TEXT), ''))`).join(' + ') + : '0'; const result = await db.raw(` - SELECT + SELECT COUNT(*) as row_count, - COALESCE(SUM(LENGTH(CAST(t.* AS TEXT))), 0) as data_sum - FROM "${table}" t + COALESCE(SUM(${lengthExpr}), 0) as data_sum + FROM "${table}" `); checksums[table] = { diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js index 5f24bdb9..019cfe30 100644 --- a/backend/src/utils/logger.js +++ b/backend/src/utils/logger.js @@ -2,8 +2,13 @@ const winston = require('winston'); const path = require('path'); const fs = require('fs'); -// Ensure logs directory exists -const logDir = path.join(__dirname, '../../logs'); +// Ensure logs directory exists. +// +// LOG_DIR lets a deployment put logs somewhere other than /logs. The +// all-in-one image (#1042) mounts one volume at /data and points this at +// /data/logs, so logs survive a container replacement like everything else. +// Unset — every existing compose and native install — keeps the old path. +const logDir = process.env.LOG_DIR || path.join(__dirname, '../../logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 581b692f..53083463 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -25,9 +25,50 @@ unset _pair _var _file _cur # hard-coded nodejs user. Compose deployments that pin `user:` to something # other than root skip this branch — they own permissions themselves and hit # the preflight check below instead. +# The writable roots. Defaults are the compose layout; the all-in-one image +# (#1042) points all of them under one mounted volume, so these must follow the +# same env vars the app itself reads rather than hard-coding /app. +DATA_DIRS="${STORAGE_PATH:-/app/storage} ${DATA_DIR:-/app/data} ${LOG_DIR:-/app/logs}" + +# The backup root is adopted when explicitly configured, but never gates boot: +# docker-compose.production.yml does not mount /backup, so a hardened non-root +# deployment would fail `mkdir -p /backup` against a root-owned / and refuse to +# start over a directory it never needed. +if [ -n "${BACKUP_DIR:-}" ]; then + DATA_DIRS="$DATA_DIRS $BACKUP_DIR" +fi + +# When every root lives under ONE mounted volume (the all-in-one image, #1042), +# the mount point itself must be adopted too. Chowning only the children leaves +# a host directory created with 0700/0750 and a foreign owner untraversable by +# UID 1001 after the su-exec drop, so the preflight below rejects children the +# script just created. Docker Desktop's permissive bind mounts hide this; a NAS +# share does not. +# +# It is deliberately kept out of DATA_DIRS: everything below it is already +# chowned recursively, so adding it there would walk the whole photo library a +# second time on every restart — minutes of startup delay on exactly the large +# NAS libraries this image targets. The mount point needs its own ownership +# fixed, nothing more, so it gets a shallow chown of its own below. +DATA_ROOT_DIR="${DATA_ROOT:-}" + +# Create the roots before touching them. With the compose layout each is its own +# mount point so they always exist — but the AIO image mounts ONE volume at +# /data, and a bind-mounted host directory hides the tree baked into the image. +# chown would then fail on paths that do not exist and report "the filesystem +# rejects chown", which is both wrong and a dead end for NAS users. +# shellcheck disable=SC2086 — intentional word-splitting over the roots +mkdir -p $DATA_ROOT_DIR $DATA_DIRS 2>/dev/null || true + if [ "$(id -u)" = "0" ]; then - if ! chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null; then - echo "ERROR: failed to chown /app/storage, /app/data, /app/logs to nodejs (UID 1001)." >&2 + if [ -n "$DATA_ROOT_DIR" ] && ! chown nodejs:nodejs "$DATA_ROOT_DIR" 2>/dev/null; then + echo "ERROR: failed to chown $DATA_ROOT_DIR to nodejs (UID 1001)." >&2 + echo " The mounted volume root must be traversable by UID 1001 after the privilege drop." >&2 + echo " Workaround: chown 1001:1001 the host directory you mounted at $DATA_ROOT_DIR." >&2 + exit 1 + fi + if ! chown -R nodejs:nodejs $DATA_DIRS 2>/dev/null; then + echo "ERROR: failed to chown $DATA_DIRS to nodejs (UID 1001)." >&2 echo " This usually means the host filesystem rejects chown (e.g. NFS without root squash" >&2 echo " disabled, or a SELinux/AppArmor policy blocking the operation)." >&2 echo " Workaround: pre-chown the host directories to 1001:1001 and pin 'user: \"1001:1001\"'" >&2 @@ -44,7 +85,7 @@ fi # followed by a confusing migration error and a restart loop. _uid="$(id -u)" _gid="$(id -g)" -for _dir in /app/storage /app/data /app/logs; do +for _dir in $DATA_ROOT_DIR $DATA_DIRS; do if [ ! -w "$_dir" ]; then echo "ERROR: $_dir is not writable by UID $_uid." >&2 echo " Either drop the 'user:' override from your compose file so the container starts as" >&2 @@ -55,6 +96,14 @@ for _dir in /app/storage /app/data /app/logs; do fi done +# Explicit SQLite boots (DATABASE_CLIENT=sqlite3 — the all-in-one image's +# default, #1042) have no Postgres to wait for: skip the whole readiness/ +# create/verify section below. The engine resolver further down still runs, +# still logs the resolved engine, and still refuses the populated-both +# conflict (#1038). Compose deployments pin DATABASE_CLIENT=pg and knexfile's +# production block defaults to pg when unset, so nothing changes for them. +if [ "${DATABASE_CLIENT:-}" != "sqlite3" ]; then + host="${DB_HOST:-postgres}" port="${DB_PORT:-5432}" user="${DB_USER:-picpeak}" @@ -123,6 +172,10 @@ done >&2 echo "Target database \"$target_db\" is ready." +else + >&2 echo "DATABASE_CLIENT=sqlite3 — skipping the PostgreSQL readiness wait." +fi # end Postgres wait (skipped for explicit sqlite3 boots) + # Ensure storage directories exist with proper permissions (Issue #67 fix) # When host directories are bind-mounted, the container's built-in directories are overridden # This ensures the required directory structure exists before the application starts @@ -130,6 +183,15 @@ echo "Ensuring storage directories exist..." STORAGE_BASE="${STORAGE_PATH:-/app/storage}" mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true +# Backup destinations seeded by migrations 029 + 030 (/backup/picpeak and +# /backup/database). Creating the root alone is not enough: on a bind mount the +# subdirectories baked into the image are hidden and the backup services do not +# create them, so a backup would fail with ENOENT. +BACKUP_BASE="${BACKUP_DIR:-/backup}" +if [ -d "$BACKUP_BASE" ]; then + mkdir -p "$BACKUP_BASE/picpeak" "$BACKUP_BASE/database" 2>/dev/null || true +fi + # Resolve which database engine this boot should use (#1038) BEFORE migrations # run, while the Postgres target is still untouched. An install that has been # unknowingly running on SQLite (the image used to leave NODE_ENV unset, so diff --git a/docs/single-container.md b/docs/single-container.md new file mode 100644 index 00000000..02672f7f --- /dev/null +++ b/docs/single-container.md @@ -0,0 +1,150 @@ +# PicPeak all-in-one (single container) + +One `docker run`, one volume, working PicPeak. Built for NAS boxes (Synology, +UGREEN, QNAP) and small VPSes where standing up a four-service compose stack is +the thing that makes people give up and go back to a SaaS. + +If you already run PostgreSQL, or you expect several photographers hitting the +admin UI at once, use the [compose stack](../README.md) instead. This image is +the small end of the range, not a replacement for it. + +## Run it + +```bash +docker run -d \ + --name picpeak \ + -p 3000:3000 \ + -v picpeak:/data \ + -e JWT_SECRET="$(openssl rand -base64 48)" \ + ghcr.io/picpeak/picpeak/aio:stable +``` + +Open `http://:3000`. The first visit lands on the setup wizard, which +asks for a one-time token: + +```bash +docker exec picpeak cat /data/db/SETUP_TOKEN +``` + +The token is also printed to the container log on first start. + +`JWT_SECRET` is the only variable you must set. Generate it once and keep it — +changing it invalidates every existing session and gallery link. + +## What is inside + +One Node process. No supervisor, no nginx, no PostgreSQL, no Redis. + +The backend serves the built frontend directly and applies the same security +headers the nginx container applies in the compose stack — same CSP, same +`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and +`Permissions-Policy`, plus the same cache tiers (hashed assets immutable, +`index.html` never cached). + +**SQLite is the default**, deliberately. It is a library inside the process +writing one file on your volume: no second daemon, no credentials, no startup +ordering, and no `pg_upgrade` dance when you pull a newer image. The engine is +resolved and logged at boot, so `docker logs` always tells you which database +you are actually on: + +``` +Database engine: sqlite (/data/db/picpeak.db) +``` + +Redis is absent — nothing in the backend needs it at runtime. + +## The volume + +Everything that must survive a container replacement lives under `/data`: + +| Path | Contents | +|---|---| +| `/data/db` | `picpeak.db` (+ `-wal`/`-shm`) and `SETUP_TOKEN` | +| `/data/storage` | originals, thumbnails, archives | +| `/data/logs` | application logs | +| `/data/backup` | built-in backup output (`/backup` is symlinked here) | + +One mount point is the whole point. Back up `/data` and you have backed up the +install. + +Upgrades are `docker pull` + recreate the container; migrations run at start. +The volume is what carries your data across, so never bind-mount a directory +you are about to delete. + +## Environment + +Only `JWT_SECRET` is required. Everything else has a working default. + +| Variable | Default | Notes | +|---|---|---| +| `JWT_SECRET` | — | **Required.** Long random string. | +| `PORT` | `3000` | Listen port inside the container. | +| `FRONTEND_URL` | — | Public URL. Set it once you are behind a domain, so emails and share links point at the right host. | +| `SMTP_*` | — | Outbound email. Without it, PicPeak runs fine but sends nothing. | +| `DATABASE_CLIENT` | `sqlite3` | Set to `pg` to use an external PostgreSQL. Required — the image declares `sqlite3`, and the boot resolver treats a declared client as an explicit instruction, so `DB_*` alone will **not** switch engines. | +| `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Connection details, used when `DATABASE_CLIENT=pg`. | + +### Using an external PostgreSQL + +```bash +docker run -d --name picpeak -p 3000:3000 -v picpeak:/data \ + -e JWT_SECRET="…" \ + -e DATABASE_CLIENT=pg \ + -e DB_HOST=10.0.0.5 -e DB_USER=picpeak -e DB_PASSWORD=… -e DB_NAME=picpeak \ + ghcr.io/picpeak/picpeak/aio:stable +``` + +The image waits for the database to accept connections before running +migrations, exactly as the compose backend does. + +## TLS + +None is included. Terminate TLS in front of it — your NAS's reverse proxy, +Caddy, nginx, or a Cloudflare Tunnel. Set `FRONTEND_URL` to the public +`https://…` address so generated links match. + +## NAS notes + +**Synology (Container Manager)** and **QNAP (Container Station)** can both run +this from the registry UI: pull `ghcr.io/picpeak/picpeak/aio:stable`, map a +host port to container port `3000`, and add one volume mapping to `/data`. +Set `JWT_SECRET` under Environment. + +Point the volume at a folder on your data pool, not the system partition, and +prefer a folder you own — the container starts as root only long enough to +adopt the directory, then drops to UID 1001. + +## Outgrowing it + +A single-container install is never a dead end. When you need the full stack: + +1. **Settings → Backup → Export `.picpeak`** (include photos). +2. Stand up the compose stack with PostgreSQL and run its setup wizard. +3. **Settings → Backup → Restore** the `.picpeak` file. + +SQLite → PostgreSQL restore is supported (#1041); the reverse is not. Your +galleries, settings, customers and photos come across. + +## Health + +`/health` returns 200 when the database is reachable and 503 when it is not, +and the image's `HEALTHCHECK` uses it — so `docker ps` showing `healthy` +means the app can actually serve, not merely that a socket is open. + +```bash +docker inspect --format='{{.State.Health.Status}}' picpeak +``` + +## Limits + +- **SQLite means one writer.** Fine for one photographer plus guests + browsing; if several admins upload simultaneously all day, move to + PostgreSQL. +- **No built-in TLS or reverse proxy.** +- **No Redis**, so nothing here scales horizontally — run one container. +- **No face recognition.** "People in this gallery" needs a separate ML + container this image does not include, and running detection alongside + thumbnail and preview generation in one container would slow everything + down rather than fail cleanly. The toggle in Settings → Features is + disabled here and says so. Use the multi-container deployment if you want + it.