feat(docker): all-in-one image (#1042) — my version of #1067 (#1068)

* feat(docker): add all-in-one image — backend + frontend in one container (#1042)

One container, one Node process, SQLite by default: `docker run` with no
compose file, no nginx, no supervisor, no bundled Postgres/Redis.

- Dockerfile.aio (repo-root context): frontend build stage + backend deps
  stage + a runtime stage mirroring backend/Dockerfile's production stage,
  with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true.
  DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned
  explicitly — the storage fallback resolves to container-root /storage,
  which EACCESes after the su-exec drop.
- server.js: the SERVE_FRONTEND block now does what the nginx image did —
  renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot,
  serves that rendered shell on /index.html and every SPA route, caches
  hashed /assets/* immutably while the shell revalidates, and gzips the
  bundle via compression() mounted after all /api routers. express.static
  now runs with index:false so `/` keeps flowing to handlePublicSiteRequest
  — its default index option was shadowing the landing page on native
  installs.
- wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is
  sqlite3. The engine resolver still runs, still logs, and still refuses
  the populated-both conflict (#1038).
- .dockerignore: **/node_modules, so the root-context build can't pick up
  host deps from backend/ or frontend/.
- docker-build.yml: build-aio / merge-aio follow the same per-arch build →
  digest-merge → per-version tag scheme as backend/frontend (GHCR only for
  now; the Docker Hub mirror is wired once the Hub repo exists), plus a
  smoke-aio job that boots the image on every PR and asserts /health, the
  SPA shell, the rendered brand title, immutable asset caching and the
  SQLite engine resolution.

Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external
Postgres works exactly like the backend image.

* fix(ci): correct three smoke-aio assertions that would fail a green image (#1042)

Found by running the smoke job locally against a real build — the image
passed every behavioral check, but three assertions were wrong:

- `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while
  the public landing site is disabled, which is the state of the fresh
  install the smoke container always is. Assert the redirect target
  instead — that still proves express.static's index option is not
  shadowing the handler, which is the thing the check exists for.
- The placeholder-leak grep matched index.html's explanatory comment,
  which mentions BRAND_TITLE in prose and survives into the built shell.
  Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F,
  and cover the description token too.
- Add a gzip assertion, probing with GET: the compression middleware
  skips bodyless responses, so a HEAD probe reports no Content-Encoding
  even when compression is active.

Verified locally on linux/arm64: image builds clean, boots to healthy in
~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered
brand title, immutable+gzipped assets, no-store shell, SPA fallbacks,
npm removed, su-exec drop to nodejs, no errors in the boot log). The
DATABASE_CLIENT=pg override was exercised against a real Postgres too —
the readiness wait still runs and the engine resolves to postgres.

* fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042)

nginx did `try_files $uri $uri/ /index.html`, so behind compose every
client-side route survived a direct hit or a refresh and the short
`['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without
nginx that list is the whole contract, and everything outside it 404'd:

  /setup  /customer  /impressum  /datenschutz  /payment-check
  /quote/:token  /contract/:token  /invite/:token
  /transfer/:token  /transfer-upload/:token

/setup is the first URL a new install visits, so the all-in-one image was
unusable from a cold start.

The catch-all is registered after `app.use('/api', notFoundHandler)`, so
an unknown /api route still answers JSON instead of being handed the HTML
shell, and after the /s/:shortSlug resolver, so a typo'd short URL still
404s (#699). It is GET-only — a stray POST keeps 404ing rather than
getting a 200 page back. The handler is hoisted out of the
SERVE_FRONTEND block via `spaCatchAll` because that block runs before the
API 404 handler is registered.

Verified on the built image: all ten routes above now 200, /api/nope still
returns JSON 404, /s/nonexistent still returns 404, / still 302s to
/admin/login, and the smoke suite is 25/25. Both boundaries are now
asserted in the smoke-aio job.

* docs(readme): document the single-container install (#1042)

The README had no mention of the all-in-one image, so the only way to
discover it was reading the workflow file. Adds a Quick Start subsection
with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN`
step, plus a row in the documentation table.

Deliberately does not sell it as the default: the note says the compose
stack is still the right choice for anything busier, gives the reason
(SQLite takes one writer at a time), and points at the `.picpeak`
restore as the way out, so nobody picks it and then finds themselves
stuck. Full details live at docs.picpeak.app/deployment/single-container
(PicPeak/docs#8).

* feat(docker): fold #1067's items into the all-in-one image (#1042)

Consolidating the two parallel AIO branches into this one. This PR's approach
is kept wherever the two differed on design — in particular the in-process
brand render, `index: false` (which fixes express.static shadowing
handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the
smoke-aio job. What follows is what #1067 had that this branch did not.

Layout — the issue asks for a single mountable root, and this moves to one:

  /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. README and the smoke job's
database-path assertion follow the new layout.

Correctness items:

- sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` 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 every database backup failed with ENOENT.
- /backup wired in. Migrations 029 + 030 seed /backup/picpeak and
  /backup/database as the backup destinations; nothing created or mounted them,
  so backups had nowhere to write and anything written would die with the
  container. Symlinked into the volume, subdirectories created at startup
  (a bind mount hides the tree baked into the image), and adopted only when
  BACKUP_DIR is set so it never gates boot for compose deployments that do not
  mount it.
- logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not
  leave the container. Unset keeps the old path for every existing install.
- wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR /
  LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown —
  a bind-mounted /data hides the image's tree, and chown against a missing path
  reports "the filesystem rejects chown", which is both wrong and a dead end.
- .dockerignore excludes backend/-prefixed runtime data. Docker reads only the
  root file, so the unprefixed data/*.db, logs/* and storage/* rules missed
  backend/data, backend/logs and backend/storage entirely; a checkout used to
  run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a
  published layer.
- HEALTHCHECK follows $PORT rather than a hard-coded 3000.
- --max-http-header-size=32768 matches nginx's large_client_header_buffers
  4 32k; Node's 16 KiB default would reject a guest carrying several
  per-gallery JWT cookies.

docs/single-container.md is added as the in-repo reference the README links to.

The smoke job gains four assertions for the above: the one-volume layout and
writable backup destinations, the sqlite3 CLI, logs landing on the volume, and
the image carrying no runtime data from the build context.

Verified on a built image — named volume, bind mount and PORT=8080 all healthy;
every existing smoke assertion still passes, including / -> 302 /admin/login,
the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404.

Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com>

* fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042)

Both found by external review of the consolidated branch.

- The SPA catch-all had no backend-owned exclusions. This was a regression I
  introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and
  taking this branch's server.js wholesale (correctly — its index:false and
  in-process brand render are the better design) dropped it. /photos,
  /thumbnails, /uploads and /fonts are static mounts whose middleware calls
  next() on a miss, so the catch-all was answering 200 text/html under image
  and font URLs instead of 404. nginx gave each of those its own location
  block, so try_files never applied to them.

- backend/data is now excluded wholesale rather than by suffix. The suffix list
  (*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used
  checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal
  files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds
  from the repository root and COPYs backend/ wholesale, any of those would be
  baked into a published layer. The directory holds only runtime state and is
  already gitignored in full.

smoke-aio gains an assertion that the backend static routes still 404, so the
exclusion cannot be dropped again silently.

Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all
404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to
/admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the
image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context.

* fix(aio): three failures that only surface outside a dev laptop (#1042)

Backups aborted on SQLite. getTableChecksums() built its digest with
`CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses
`*` there as a syntax error, so every backup threw before reaching the
.backup call. Since the all-in-one image ships SQLite by default, that is
every AIO install. Enumerate the columns via columnInfo() and sum their
lengths instead.

The shared /data mount root was never adopted. wait-for-db.sh chowned the
children it creates but not the mount point itself, so a host directory
arriving as 0700 with a foreign owner stayed untraversable by UID 1001
after the su-exec drop. Docker Desktop's permissive bind mounts hide this
completely, which is why local testing passed; a NAS share does not.
DATA_ROOT is now adopted first.

Maintenance mode locked the admin out of the box. The middleware runs at
server.js:493, long before the static block at 891, and exempted the auth
endpoints but not the page that calls them. With the backend serving the
frontend, /admin/login and /assets/* returned 503 JSON, so an admin who
enabled maintenance mode could never load the UI to turn it off. nginx
serves those paths in the compose stack, which is why it never surfaced
there. Guest and API surfaces stay gated.

Verified on a built image: checksums compute across all 95 tables; a bind
mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with
general_maintenance_mode=true, /admin/login, /admin and /assets/* return
200 while /gallery/* and /api/gallery/* return 503 — and 503 across all
three once the exemption is removed again.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042)

The Jest suite was red: mocking db.raw is no longer enough now that the
SQLite checksum branch asks the query builder for its column list, so
db(table) came back undefined and getTableChecksums failed on every PR.
The production code is right; the fixture needed to know about the call.

backend/.env was landing in the published layer. The root ignore file's
`.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker
matches them from the context root, so they catch ./.env and never
backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at
/app/.env. Matched at any depth instead, the way **/node_modules in the
same file already is. Confirmed by building from a checkout carrying a
planted secret: before, `cat /app/.env` printed it back.

Business documents wrote outside the volume. quoteService, invoice
sending/reminders and contract signatures build paths from
process.cwd()/storage and never read STORAGE_PATH; compose hides it by
setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the
same directory. Here they are not, and /app is root-owned, so a quote or
invoice PDF failed to write as UID 1001 — and would not survive the
container if it had. Symlinked /app/storage into the volume, matching
the /backup symlink beside it. Teaching those services STORAGE_PATH is
the real fix and wants its own change.

Two smaller ones: the mount root is now chowned shallow rather than
recursively, since every child below it is already walked recursively
and a NAS-sized photo library should not be traversed twice on each
restart; and /assets/ joins the backend-owned prefixes, so a stale
hashed chunk requested by a tab left open across an upgrade gets a 404
instead of index.html served with 200 under a .js URL.

Verified on a built image: planted backend/.env and backend/probe.db are
absent; /app/storage resolves to /data/storage and a business-doc write
as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000
boots healthy; a missing /assets chunk 404s while the real bundle still
serves 200 as application/javascript. The databaseBackup suite is green
again, and the branch adds no failing suite that origin/main does not
already fail on the same machine.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(aio): teach the leak assertion about the storage symlink (#1042)

The previous check listed /app/storage/events and treated a hit as a
leak. That was true while /app/storage was either absent or a copied
directory; now it is a symlink into the volume, so the check followed it
and found the empty tree the image itself creates — a false positive on
its own design.

Check the shape instead: /app/storage must be a symlink pointing at
/data/storage, and the volume's photo tree must contain no files on a
fresh install. A real directory there now fails loudly, which is the
condition the assertion was always trying to catch. Also extended the
path list to /app/.env and loose database files, matching the
.dockerignore rules added alongside.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): show the maintenance screen instead of raw JSON to guests (#1042)

The previous commit exempted the admin shell so an admin could still
reach the switch they had just flipped. Guests had the same problem for
the same reason: with no nginx in front, /gallery/<slug> reaches this
middleware long before the static block, so a visitor during maintenance
got a 503 JSON body where every other deployment shows the branded
maintenance screen the frontend already ships.

Replaced the two path-specific exemptions with the rule they were both
special cases of: a GET that is not an API call and not a backend-owned
content mount is the SPA shell, and the shell is inert HTML — it boots,
reads /api/public/settings (already exempt) and renders MaintenanceMode
on its own. Everything that carries real data stays gated: /api/*,
/photos/, /thumbnails/, /fonts/, and any non-GET.

Compose is untouched by construction, since nginx answers those paths
and they never arrive here.

Verified on a built image with the flag on: /gallery/x, /customer/x,
/admin and /admin/login return 200 text/html while /api/gallery/x/verify,
/photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API
still returns 503; with the flag off the same paths go back to 404. Added
a middleware test over that exemption matrix — over-exemption is the real
risk in this change, so it asserts the gated half too. It fails on five
cases without the fix.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042)

The previous commit exempted "any GET that is not an API call". That
negative rule reads as safe and is not: /og/gallery/<slug> and its
/cover render the event name and the hero thumbnail, /s/<code> renders
short-link previews, and `/` is handed to the public CMS. All four are
proxy_passed to the backend by nginx, so they were gated before this PR
in every deployment — the rule un-gated them, and for compose too, not
just the new image. A site switched to maintenance would have kept
publishing gallery metadata.

Replaced the guess with the split nginx already defines: exempt what the
frontend container answers itself, gate what it proxies. That is the
same rule the all-in-one image needs by definition, since its whole job
is to be both halves of that stack, and it now matches compose in both
directions rather than only in the direction the last commit tested.

Verified on a built image with the flag on: /admin/login,
/gallery/<slug> and /customer/* return 200, while /, /og/gallery/x,
/og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return
503; with the flag off all of them behave normally again. The middleware
test grew the gated cases — it now covers 21, most of them asserting
what must NOT be exempt.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042)

getFrontendBaseUrl() reads FRONTEND_URL, falls back to the
general_site_url setting, and otherwise returns an empty string — which
makes share_url come back as a bare "/gallery/<slug>/<token>". Compose
defaults the variable to http://localhost:3000, but the documented
one-liner for this image passes only JWT_SECRET, so every fresh
single-container install handed out relative links in API responses, QR
codes and emails.

Defaulted to the same value compose uses; -e FRONTEND_URL=https://...
overrides it, as does the site URL field in Settings.

Found by pointing tests/e2e/local at a running AIO container:
auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was
the one spec that failed for a product reason rather than a harness one.
It passes now, and the suite is 19/20 against the image — the remaining
failure is smoke/02-auth-flow, whose seed helper shells out to a
hard-coded `docker exec picpeak-backend`, so it cannot arrange its
precondition against any other container.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(aio): mark the image so face recognition stays off (#1042, #1074)

Face recognition needs a separate ML container this image does not contain,
and enabling it here 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 mode would not be a clear error — just a
slow install that looks broken.

The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER.
Without this line the guard never triggers on an actual all-in-one build, so
the two changes have to arrive together: whichever merges second completes
the pair. Verified against this file's exact value — isFeatureEnabled()
returns false with it set.

An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite
path, because legitimate multi-container deployments do both of those and
should keep the feature.

Also adds it to the Limits section of docs/single-container.md, next to the
SQLite and Redis constraints, since that is where someone will look before
choosing this image.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: the-luap <paul-nothaft@hotmail.de>
This commit is contained in:
Luca
2026-08-18 23:15:49 +03:00
committed by GitHub
parent f22999aba6
commit 0874a30ac9
15 changed files with 1195 additions and 31 deletions
+32 -1
View File
@@ -5,7 +5,10 @@
.env.* .env.*
docker-compose*.yml docker-compose*.yml
.DS_Store .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 npm-debug.log
coverage coverage
.nyc_output .nyc_output
@@ -18,3 +21,31 @@ storage/events/archived/*
storage/thumbnails/* storage/thumbnails/*
data/*.db data/*.db
logs/* 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
+3 -1
View File
@@ -1,6 +1,8 @@
# Docker Build and Push Workflow # 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** (`<repo>/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 ## Features
+403 -1
View File
@@ -599,8 +599,388 @@ jobs:
run: | run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }} 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 '<div id="root">' || { echo "::error::/admin did not serve the SPA shell"; exit 1; }
echo "$body" | grep -q '<title>AIO Smoke</title>' || { 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 '<div id="root">' <<< "$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/<unknown> 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: 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() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -612,6 +992,7 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}" repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV" echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$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 # Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any # 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 # 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 echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi 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 "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $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 "- 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 if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
+178
View File
@@ -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 '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\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/<slug>/<token>"
# 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"]
+16
View File
@@ -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. > **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? ## 🌟 Why PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you: Unlike expensive SaaS solutions, PicPeak gives you:
@@ -107,6 +122,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
| Topic | Link | | Topic | Link |
|---|---| |---|---|
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) | | 🚀 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) | | ⚙️ 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) | | 🎯 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) | | 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
@@ -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/<slug> 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();
});
});
});
+66 -11
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.101.3-beta.0", "version": "3.103.1-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.101.3-beta.0", "version": "3.103.1-beta.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -15,6 +15,7 @@
"axios": "1.18.1", "axios": "1.18.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
@@ -323,7 +324,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==", "integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
@@ -1052,7 +1052,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -3947,7 +3946,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4562,7 +4560,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.38", "baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
@@ -5036,6 +5033,60 @@
"node": ">= 10" "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": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "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.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -5950,7 +6000,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -6918,7 +6967,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.27.6" "@babel/runtime": "^7.27.6"
}, },
@@ -9509,6 +9557,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "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", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
@@ -10922,7 +10978,6 @@
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"parseley": "~0.13.1" "parseley": "~0.13.1"
}, },
+1
View File
@@ -25,6 +25,7 @@
"axios": "1.18.1", "axios": "1.18.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
+87 -7
View File
@@ -65,6 +65,7 @@ logger.info('Server starting up', {
const fs = require('fs'); const fs = require('fs');
const express = require('express'); const express = require('express');
const helmet = require('helmet'); const helmet = require('helmet');
const compression = require('compression');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const { initializeDatabase, db } = require('./src/database/db'); 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/images', require('./src/routes/protectedImages'));
app.use('/api/secure-images', secureImagesRoutes); 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 { try {
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist'); 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)); const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
if (shouldServe) { if (shouldServe) {
logger.info(`Serving frontend from ${frontendDir}`); 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 // Landing page handler or SPA fallback
app.get('/', handlePublicSiteRequest, (req, res) => { app.get('/', handlePublicSiteRequest, (req, res) => {
res.sendFile(indexPath); sendSpa(res);
}); });
// SPA fallback for admin + gallery routes. For gallery URLs we intercept // SPA fallback for admin + gallery routes. For gallery URLs we intercept
@@ -908,12 +954,22 @@ try {
} }
return next(); return next();
}; };
app.get('/gallery/:slug/: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) => res.sendFile(indexPath)); app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => sendSpa(res));
app.get(['/admin', '/admin/*', '/gallery/*'], (req, 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 { } else {
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir }); logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
app.get('/', handlePublicSiteRequest, (req, res) => { app.get('/', handlePublicSiteRequest, (req, res) => {
@@ -927,6 +983,30 @@ try {
// 404 handler for undefined API routes // 404 handler for undefined API routes
app.use('/api', notFoundHandler); 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) // Global error handler (must be last)
app.use(errorHandler); app.use(errorHandler);
+42 -1
View File
@@ -92,11 +92,52 @@ async function maintenanceMiddleware(req, res, next) {
req.path.startsWith('/favicons/') || req.path.startsWith('/favicons/') ||
req.path.startsWith('/logos/'); 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 // Allow admin routes if admin is authenticated
const isAdminRoute = req.path.startsWith('/api/admin'); const isAdminRoute = req.path.startsWith('/api/admin');
const hasAdminAuth = req.headers.authorization?.startsWith('Bearer '); 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(); return next();
} }
@@ -64,6 +64,12 @@ describe('DatabaseBackupService', () => {
// Mock getTables // Mock getTables
service.getTables = jest.fn().mockResolvedValue(['events', 'photos']); 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 // Mock SQLite response
db.raw = jest.fn() db.raw = jest.fn()
.mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }]) .mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }])
+15 -3
View File
@@ -90,12 +90,24 @@ class DatabaseBackupService {
for (const table of tables) { for (const table of tables) {
if (this.dbType === 'sqlite') { 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(` const result = await db.raw(`
SELECT SELECT
COUNT(*) as row_count, COUNT(*) as row_count,
COALESCE(SUM(LENGTH(CAST(t.* AS TEXT))), 0) as data_sum COALESCE(SUM(${lengthExpr}), 0) as data_sum
FROM "${table}" t FROM "${table}"
`); `);
checksums[table] = { checksums[table] = {
+7 -2
View File
@@ -2,8 +2,13 @@ const winston = require('winston');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Ensure logs directory exists // Ensure logs directory exists.
const logDir = path.join(__dirname, '../../logs'); //
// LOG_DIR lets a deployment put logs somewhere other than <backend>/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)) { if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(logDir, { recursive: true });
} }
+65 -3
View File
@@ -25,9 +25,50 @@ unset _pair _var _file _cur
# hard-coded nodejs user. Compose deployments that pin `user:` to something # hard-coded nodejs user. Compose deployments that pin `user:` to something
# other than root skip this branch — they own permissions themselves and hit # other than root skip this branch — they own permissions themselves and hit
# the preflight check below instead. # 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 [ "$(id -u)" = "0" ]; then
if ! chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null; then if [ -n "$DATA_ROOT_DIR" ] && ! chown nodejs:nodejs "$DATA_ROOT_DIR" 2>/dev/null; then
echo "ERROR: failed to chown /app/storage, /app/data, /app/logs to nodejs (UID 1001)." >&2 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 " 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 " 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 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. # followed by a confusing migration error and a restart loop.
_uid="$(id -u)" _uid="$(id -u)"
_gid="$(id -g)" _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 if [ ! -w "$_dir" ]; then
echo "ERROR: $_dir is not writable by UID $_uid." >&2 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 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 fi
done 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}" host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}" port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}" user="${DB_USER:-picpeak}"
@@ -123,6 +172,10 @@ done
>&2 echo "Target database \"$target_db\" is ready." >&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) # Ensure storage directories exist with proper permissions (Issue #67 fix)
# When host directories are bind-mounted, the container's built-in directories are overridden # 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 # 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}" STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true 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 # Resolve which database engine this boot should use (#1038) BEFORE migrations
# run, while the Postgres target is still untouched. An install that has been # 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 # unknowingly running on SQLite (the image used to leave NODE_ENV unset, so
+150
View File
@@ -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://<host>: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.