diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 070328ad..01bd4428 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1005,6 +1005,63 @@ jobs: 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 a no-JWT_SECRET install boots and keeps its generated secret + run: | + # The boot above passes JWT_SECRET explicitly, so it cannot catch the case + # this image is actually deployed in: a NAS Container Manager / Container + # Station form has no shell to run `openssl rand`, and validateEnv treats a + # missing JWT_SECRET as critical and exits. wait-for-db.sh generates one + # into the data volume when neither the environment nor /run/secrets + # supplies it (#705). + docker volume create aio-nojwt > /dev/null + docker run -d --name aio-nojwt -p 3100:3000 -v aio-nojwt:/data picpeak-aio:smoke > /dev/null + for i in $(seq 1 60); do + curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break + sleep 2 + done + curl -fsS http://localhost:3100/health > /dev/null 2>&1 \ + || { echo "::error::container with no JWT_SECRET never became healthy"; docker logs aio-nojwt | tail -50; exit 1; } + + mode=$(docker exec aio-nojwt stat -c '%a' /data/db/jwt.secret 2>/dev/null || echo missing) + [ "$mode" = "600" ] \ + || { echo "::error::/data/db/jwt.secret missing or not 0600 (got: $mode)"; exit 1; } + + # The secret has to survive a restart. If it did not, every admin session + # and gallery link would be signed out on each container recreate — which + # is worse than failing to boot, because it looks like it works. + first=$(docker exec aio-nojwt cat /data/db/jwt.secret) + [ -n "$first" ] || { echo "::error::generated jwt.secret is empty"; exit 1; } + docker restart aio-nojwt > /dev/null + for i in $(seq 1 60); do + curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break + sleep 2 + done + # Assert recovery rather than falling through to `docker exec`, which + # would report a confusing cat failure for a container that never came + # back up. + curl -fsS http://localhost:3100/health > /dev/null 2>&1 \ + || { echo "::error::container did not become healthy again after restart"; docker logs aio-nojwt | tail -50; exit 1; } + second=$(docker exec aio-nojwt cat /data/db/jwt.secret) + [ "$first" = "$second" ] \ + || { echo "::error::jwt.secret changed across a restart — sessions would not survive"; exit 1; } + + # An explicit secret must still win over the generated one. The $ is + # escaped so the comparison happens in the INNERMOST shell, after + # wait-for-db.sh has run. Unescaped it is the CONTAINER's outer `sh -c` + # that expands it — the runner's single quotes do protect it, but the + # container shell then substitutes the value while parsing the + # double-quoted region, before the script runs at all. The test reduces + # to `[ x = x ]` and passes even against a script that clobbers the + # variable: an assertion that cannot fail. + docker run --rm -v aio-nojwt:/data -e JWT_SECRET=explicit-secret-at-least-32-characters-long \ + --entrypoint sh picpeak-aio:smoke -c \ + './wait-for-db.sh sh -c "[ \"\$JWT_SECRET\" = explicit-secret-at-least-32-characters-long ]"' \ + || { echo "::error::an explicit JWT_SECRET did not override the generated file"; exit 1; } + + docker rm -f aio-nojwt > /dev/null + # -f alone can return before the volume is released + for i in $(seq 1 10); do docker volume rm aio-nojwt > /dev/null 2>&1 && break; sleep 1; done + - name: Assert the sqlite3 CLI the backup service shells out to run: | # DatabaseBackupService spawns `sqlite3` for .backup and integrity_check; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6e37d17..2be69ed2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,23 +73,32 @@ cd picpeak # Install dependencies cd backend && npm install cd ../frontend && npm install +cd .. -# Set up environment -cp .env.example .env -# Edit .env with your settings +# Start Postgres and Redis (the app itself runs on the host, see below) +docker compose up -d postgres redis -# Start development servers -docker-compose -f docker-compose.dev.yml up +# Backend config — note this is backend/.env, not the root one +cp backend/.env.example backend/.env +# JWT_SECRET must be set: the host process validates it and exits without one. +# (The containers generate it themselves; `npm run dev` does not.) + +# Backend, with nodemon hot reload — http://localhost:3001 +cd backend && npm run dev + +# Frontend, with Vite hot reload, in a second shell — http://localhost:5173 +cd frontend && npm run dev ``` -**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps: +Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so +you do not need the root `.env` for this loop at all — that one configures the +compose stack. -```bash -docker compose -f docker-compose.dev.yml up -d --build backend -# (or `frontend`, or both) -``` +Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image. -The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container. +**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`). + +> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop. ### Running Tests diff --git a/README.md b/README.md index cc0e194b..9375c70b 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,14 @@ For a home server, a NAS, or a single small studio, the all-in-one image runs th ```bash docker run -d --name picpeak -p 3000:3000 \ -v picpeak:/data \ - -e JWT_SECRET="$(openssl rand -base64 48)" \ ghcr.io/picpeak/picpeak/aio:main ``` -Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`. +No environment variables to set — the JWT secret is generated on first start and kept on the volume. -`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a version tag (`3.107.4-beta.0`) if you would rather not track `main`. +Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell. + +`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`. 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. diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 53083463..83cd7e5a 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -60,6 +60,133 @@ DATA_ROOT_DIR="${DATA_ROOT:-}" # shellcheck disable=SC2086 — intentional word-splitting over the roots mkdir -p $DATA_ROOT_DIR $DATA_DIRS 2>/dev/null || true +# Last resort for JWT_SECRET, after the /run/secrets pass above: generate one +# and persist it next to the database. Deliberately placed here rather than +# beside that loop because it needs DATA_DIR to exist, and before the chown +# below so the new file is adopted along with everything else. +# +# The compose stack never reaches this — `secrets-init` has already written +# /run/secrets/jwt_secret. It exists for deployments with no shell: the +# all-in-one image mounts no secrets volume, and the documented one-liner asks +# the user to run `openssl rand` on the HOST, which a NAS Container Manager or +# Container Station form cannot do. Without this, JWT_SECRET is unset, +# validateEnv treats it as critical, and the container exits — so the whole +# GUI-driven install path is a dead end (#705). +# +# JWT_SECRET only. DB_PASSWORD must match whatever the Postgres volume was +# initialised with, so inventing one at boot would break the connection rather +# than fix it; the AIO default engine is SQLite and Redis is not used here. +# +# Losing the file invalidates every session and gallery link. Note the built-in +# backup does NOT carry it — backupService archives STORAGE_PATH and the +# database dump, not DATA_DIR — so only a copy of the whole volume preserves it. +if [ -z "${JWT_SECRET:-}" ]; then + _jwt_file="${DATA_DIR:-/app/data}/jwt.secret" + + # Read, trim, and length-check rather than testing that the file merely + # exists. A short write (ENOSPC, a SIGKILL mid-boot) leaves a partial file + # that a `-s` test accepts on every later boot, and validateEnv only WARNS + # below 32 characters — so the install would sign with a truncated key + # indefinitely and nothing would say so. Whitespace is stripped first because + # validateEnv rejects an all-blank secret as critical, and an all-blank file + # long enough to pass a naive length test would boot-loop forever. + _jwt_read() { + [ -f "$1" ] || return 0 + tr -d '\r\n\t ' < "$1" 2>/dev/null || true + } + + # Loop rather than a single pass, because the two failure causes look + # identical on the first read and need opposite responses: a file that is + # absent-then-present is a concurrent boot winning the race (adopt it), while + # a file that is still unusable after we have tried to link and re-read is + # genuine corruption (clear it and retry). Removing on the first pass would + # delete a perfectly good secret another container had just created. + _jwt_made="" + _jwt_cur="$(_jwt_read "$_jwt_file")" + _jwt_try=0 + while [ "${#_jwt_cur}" -lt 32 ] && [ "$_jwt_try" -lt 2 ]; do + _jwt_try=$((_jwt_try + 1)) + + if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then + # A directory (or anything non-regular) has to go BEFORE the link is + # attempted, not after: POSIX `ln src dir` links src INTO the directory + # instead of failing, so the first attempt would create + # jwt.secret/jwt.secret..tmp, leave the directory non-empty, and make + # rmdir impossible from then on — the container could never recover from a + # mistyped `-v` target without host access. Verified against the runtime + # image; busybox ln does exactly this. + echo "[secrets] $_jwt_file is not a regular file — removing it." >&2 + rmdir "$_jwt_file" 2>/dev/null || rm -f "$_jwt_file" 2>/dev/null || true + elif [ "$_jwt_try" -gt 1 ] && [ -f "$_jwt_file" ]; then + # Re-read immediately before unlinking. Our earlier read may be stale: + # another container can have linked a perfectly good secret in between, + # and deleting it would leave the two of us signing with different keys. + _jwt_cur="$(_jwt_read "$_jwt_file")" + [ "${#_jwt_cur}" -ge 32 ] && break + echo "[secrets] $_jwt_file is unusable — regenerating." >&2 + rm -f "$_jwt_file" 2>/dev/null || true + fi + + # Still not a regular file (a non-empty directory we refuse to delete): + # nothing further will work, so stop and let the warning below explain. + if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then + break + fi + + # openssl is not in the runtime image; /dev/urandom + base64 always are. + # 48 bytes -> 64 base64 chars, comfortably past the 32 above. + _jwt_new="$(head -c 48 /dev/urandom | base64 | tr -d '\n' || true)" + + # Two properties are needed at once, and each idiom alone gives only one: + # + # * the file must never be READABLE half-written, or a concurrent boot + # reads a partial secret — a plain `set -C` redirect creates the inode + # first and writes after, so a loser can read it in between + # * the create must not CLOBBER, or two containers each believe they own + # the secret and sign with different keys — which is what `mv -f` does + # + # Writing the full content to a private temp file and hard-linking it into + # place gives both: the link is atomic, fails with EEXIST when another + # container already won, and the content is complete before the name exists. + # + # The suffix must be random rather than $$: the PID namespace makes $$ equal + # to 1 in every container, so containers sharing a volume would all pick the + # same temp path — and since a hard link shares the INODE, a second process + # truncating that path writes straight through the linked secret. + if [ -n "$_jwt_new" ]; then + _jwt_sfx="$(head -c 12 /dev/urandom | base64 | tr -dc 'a-z0-9' | cut -c1-10 || true)" + [ -n "$_jwt_sfx" ] || _jwt_sfx="$$" + _jwt_tmp="$_jwt_file.$_jwt_sfx.tmp" + if (umask 077; printf '%s\n' "$_jwt_new" > "$_jwt_tmp") 2>/dev/null \ + && ln "$_jwt_tmp" "$_jwt_file" 2>/dev/null; then + _jwt_made=yes + fi + rm -f "$_jwt_tmp" 2>/dev/null || true + fi + + # Trust the file, never the value in hand — whoever won the link is the + # source of truth for every container on this volume, and the loser adopts + # it rather than signing with the value it happened to generate. + _jwt_cur="$(_jwt_read "$_jwt_file")" + done + + if [ "${#_jwt_cur}" -ge 32 ]; then + export JWT_SECRET="$_jwt_cur" + if [ -n "$_jwt_made" ]; then + echo "[secrets] No JWT_SECRET supplied — generated one and stored it in $_jwt_file." + echo "[secrets] Back up the whole data volume to keep it; the built-in backup does" + echo "[secrets] not include it, and losing it signs every admin session and gallery" + echo "[secrets] link out." + fi + else + echo "WARNING: no JWT_SECRET supplied and $_jwt_file could not be created." >&2 + echo " Startup will fail validation. Pass -e JWT_SECRET=... , or check that the" >&2 + echo " data directory is writable and that $_jwt_file is not a directory." >&2 + fi + unset _jwt_new _jwt_tmp _jwt_sfx _jwt_made _jwt_try + unset _jwt_file _jwt_cur +fi + if [ "$(id -u)" = "0" ]; then if [ -n "$DATA_ROOT_DIR" ] && ! chown nodejs:nodejs "$DATA_ROOT_DIR" 2>/dev/null; then echo "ERROR: failed to chown $DATA_ROOT_DIR to nodejs (UID 1001)." >&2 diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 4b2faeb7..420c2510 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -110,9 +110,20 @@ services: - FACE_ML_TOKEN=${FACE_ML_TOKEN:-} - FACE_PROCESSOR_CONCURRENCY=${FACE_PROCESSOR_CONCURRENCY:-} volumes: - - ${APP_STORAGE}:/app/storage - - ${LOGS}:/app/logs - - ${APP_DATA}:/app/data + # Defaulted so a .env that simply does not set these still works. Without + # them compose aborts with "invalid spec: :/app/storage: empty section + # between colons", which reads like a broken compose file rather than a + # missing variable (#705). + # + # Note this does NOT make `config` work with no .env at all: `env_file` + # above still requires the file. Making it optional needs + # `required: false`, which is Compose 2.24+ syntax that OLDER Compose + # rejects as a schema error — taking the whole stack down rather than + # just losing a default. Not worth it for a case the onboarding flow + # never hits, since it copies .env.example first. + - ${APP_STORAGE:-./storage}:/app/storage + - ${LOGS:-./logs}:/app/logs + - ${APP_DATA:-./data}:/app/data - picpeak-secrets:/run/secrets:ro ports: - "${BACKEND_PORT:-3001}:3000" @@ -182,7 +193,7 @@ services: # Uses same channel as backend for consistency image: ghcr.io/picpeak/picpeak/frontend:${PICPEAK_CHANNEL:-stable} container_name: picpeak-frontend - # Note: Pre-built frontend uses Nginx to proxy /api to backend:3001. + # Note: Pre-built frontend uses Nginx to proxy /api to backend:3000. # Prefer keeping API base as '/api' in builds to avoid CORS. environment: # Substituted into index.html at container start (see frontend/ diff --git a/docs/single-container.md b/docs/single-container.md index d9f6a1cc..59a88807 100644 --- a/docs/single-container.md +++ b/docs/single-container.md @@ -15,10 +15,16 @@ docker run -d \ --name picpeak \ -p 3000:3000 \ -v picpeak:/data \ - -e JWT_SECRET="$(openssl rand -base64 48)" \ - ghcr.io/picpeak/picpeak/aio:stable + ghcr.io/picpeak/picpeak/aio:main ``` +`:main` is the rolling tag that tracks the default branch. The curated +`:stable` and `:latest` tags exist for the backend and frontend images but have +not been cut for this one yet, so `:main` is the tag to pull today — pinning a +published version tag also works if you would rather not follow the branch — +the Releases page, or the package's tag list on the registry, shows what is +current. + Open `http://:3000`. The first visit lands on the setup wizard, which asks for a one-time token: @@ -26,10 +32,33 @@ asks for a one-time token: docker exec picpeak cat /data/db/SETUP_TOKEN ``` -The token is also printed to the container log on first start. +No shell? The file is on the volume you mounted, so any file manager can open +it — with `-v picpeak:/data` it is `db/SETUP_TOKEN` inside the volume, and with +a host folder it is `/db/SETUP_TOKEN`. -`JWT_SECRET` is the only variable you must set. Generate it once and keep it — -changing it invalidates every existing session and gallery link. +The token is deliberately **not** written to the container log. It is a live +credential for creating the first admin, and logging it would leave it sitting +in `combined.log` and `security.log` on the mounted volume long after setup. +The log line names the file instead. (If the file could not be written at all, +the log carries the token as a last-resort recovery path — that is the only +case where it appears there.) + +## Secrets + +Nothing to set. On first start the container generates a `JWT_SECRET` and +stores it at `db/jwt.secret` (mode 0600) on the volume, then reuses it on every +subsequent boot — so a deployment with no shell, like a NAS Container Manager +form, needs no preparation. + +Back it up along with the rest of the volume: losing that file signs every +admin session and gallery link out, exactly as changing the secret would. + +Note the **built-in backup does not include it** — that covers the database and +the storage tree, not the rest of `/data` — so a restore from `/data/backup` +alone will not bring the secret back. Copy the volume. + +Passing `-e JWT_SECRET=…` still overrides it, which is what you want for +config-as-code deployments or when several instances must share sessions. ## What is inside @@ -71,32 +100,89 @@ 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. +### Large libraries and slow start-ups + +The container starts as root just long enough to take ownership of `/data` +(UID 1001), then drops privileges. That step walks the tree, so on a big +library over a slow filesystem it can add noticeable time to **every** restart, +not only the first. + +If that becomes annoying, take ownership once yourself and run as that user — +the adoption step is then skipped entirely: + +```bash +chown -R 1001:1001 /volume1/docker/picpeak # once, on the host +docker run -d --name picpeak -p 3000:3000 \ + --user 1001:1001 \ + -v /volume1/docker/picpeak:/data \ + ghcr.io/picpeak/picpeak/aio:main +``` + +The container then verifies the directories are writable and fails with a clear +message if they are not, rather than trying to fix ownership itself. Note this +also means files you drop into the storage tree from outside must already be +readable by UID 1001 — relevant if you use a watched folder to ingest photos. + ## Environment -Only `JWT_SECRET` is required. Everything else has a working default. +Nothing is required. Everything below has a working default. | Variable | Default | Notes | |---|---|---| -| `JWT_SECRET` | — | **Required.** Long random string. | +| `JWT_SECRET` | generated | Generated into `db/jwt.secret` on first start and reused after. Set it explicitly to pin it. | | `PORT` | `3000` | Listen port inside the container. | | `FRONTEND_URL` | — | Optional override for the public URL. Normally you set this in the setup wizard instead (it proposes the address you opened), and it is editable later under Settings → General. Setting it here pins the value and makes that field read-only. | | `SMTP_*` | — | Optional override for outbound email, which is normally configured in the setup wizard / Settings → Email. Without either, 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`. | +| `EXTERNAL_MEDIA_ROOT` | `/external-media` | Read-only photo library to offer in the picker. Mount a folder there and it works without setting this. | ### 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 + ghcr.io/picpeak/picpeak/aio:main ``` The image waits for the database to accept connections before running migrations, exactly as the compose backend does. +## Using photos that are already on the disk + +Galleries do not have to be built from uploads. Mount an existing photo library +read-only at `/external-media` and it appears in the admin photo picker: + +```bash +docker run -d --name picpeak -p 3000:3000 \ + -v picpeak:/data \ + -v /volume1/photo/2026-weddings:/external-media:ro \ + ghcr.io/picpeak/picpeak/aio:main +``` + +No environment variable is needed — the path is the default. `EXTERNAL_MEDIA_ROOT` +overrides it if you would rather mount somewhere else. + +The location is resolved once, on first use, and cached for the life of the +process — so add the mount when you create the container, or restart it +afterwards. It will not appear in a running one. + +`:ro` is not a precaution, it is accurate: PicPeak only reads from this tree. +Thumbnails are written into the managed storage on the data volume, so the +originals are never touched, renamed, or moved. + +Two things to get right: + +- **Mount it outside `/data`.** The data volume is adopted at boot (`chown` to + UID 1001) so the app can write to it. A read-only mount nested inside it makes + that fail and the container will not start. `/external-media` is its own path + for exactly this reason. +- **A network share is fine here, and only here.** Because this tree is only + read and never adopted, an SMB/CIFS or NFS mount works — which is what makes + "the photos already live on the NAS" practical. The same share mounted under + `/data` would hang or fail the ownership step instead. + ## TLS None is included. Terminate TLS in front of it — your NAS's reverse proxy, @@ -107,9 +193,9 @@ Settings → General (or re-run the setup wizard) 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 +this from the registry UI: pull `ghcr.io/picpeak/picpeak/aio:main`, map a host port to container port `3000`, and add one volume mapping to `/data`. -Set `JWT_SECRET` under Environment. +No environment variables are needed — the secret is generated on first start. 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 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 390e3fcc..1678c0cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -40,15 +40,15 @@ const config: VitestUserConfig = { host: true, proxy: { '/api': { - target: 'http://localhost:7101', + target: 'http://localhost:3001', changeOrigin: true, }, '/photos': { - target: 'http://localhost:7101', + target: 'http://localhost:3001', changeOrigin: true, }, '/uploads': { - target: 'http://localhost:7101', + target: 'http://localhost:3001', changeOrigin: true, }, }, diff --git a/scripts/install.sh b/scripts/install.sh index 047ca3c5..5bd0976f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,30 @@ #!/bin/bash set -e +# DEPRECATED — do not use. Superseded by scripts/picpeak-setup.sh. +# +# This script predates the current deployment layout and no longer works. It +# refers to two files that do not exist (docker-compose.prod.yml and +# scripts/setup-ssl.sh), and its `sed` calls now match the COMMENTED lines in +# the current .env.example, so they produce `#JWT_SECRET=` — still +# commented, so no secret is ever set. It fails silently rather than loudly, +# which is the worst outcome for an installer. +# +# Nothing in the repository references it. It is kept as a stub only so an old +# bookmark or copied command gets a signpost instead of a broken install; the +# body below is unreachable and can be deleted outright whenever convenient. +cat >&2 <<'DEPRECATED' +scripts/install.sh is deprecated and does nothing. + +Use the current installer instead: + + ./scripts/picpeak-setup.sh + +Or run the stack directly — see the Quick Start in README.md, or +docs/single-container.md for the single-container image. +DEPRECATED +exit 1 + echo "Photo Sharing Platform - Docker Installation" echo "===========================================" diff --git a/scripts/picpeak-setup.sh b/scripts/picpeak-setup.sh index ad355b28..b7e64e5b 100755 --- a/scripts/picpeak-setup.sh +++ b/scripts/picpeak-setup.sh @@ -317,6 +317,55 @@ generate_password() { openssl rand -base64 32 | tr -d "=+/" | cut -c1-16 } +# Read one KEY=value out of an existing .env, ignoring commented lines. Prints +# the empty string when the file or the key is absent, so callers can fall back +# to generating a fresh value with `[[ -n "$x" ]] || x=$(generate...)`. +# Emit `KEY=value`, or a commented placeholder when the value is empty — an +# empty `KEY=` is NOT equivalent: wait-for-db.sh only falls back to +# /run/secrets when the variable is unset or empty, but compose would still +# define it, and any future reader that treats "defined" as "configured" gets +# the wrong answer. Commented out matches what .env.example ships. +env_secret_line() { + local key="$1" value="$2" + if [[ -n "$value" ]]; then + printf '%s=%s' "$key" "$value" + else + printf '# %s is auto-generated into the picpeak-secrets volume on first run.\n#%s=' "$key" "$key" + fi +} + +read_env_value() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 0 + # An unreadable file is NOT the same as a missing key: returning empty here + # would regenerate the secrets and then overwrite the file that held them. + # Say so loudly instead of silently rotating. + if [[ ! -r "$file" ]]; then + log_warn "Cannot read $file — existing secrets cannot be reused. Fix its permissions and re-run; the install will stop rather than overwrite it." + return 0 + fi + # Tolerant of the shapes a hand-edited .env actually takes — leading + # whitespace, an `export` prefix, spaces around the `=`. Reading one of + # those as "absent" would regenerate the secrets and then overwrite the + # evidence, which is the exact failure this helper exists to prevent. + # Never fails: an unreadable file must not abort the installer under + # `set -e` mid-run. + # Trailing whitespace and a CR are stripped too. A .env edited on Windows + # yields `abc\r`; docker compose treats the CR as a line terminator, so the + # running stack uses `abc`, but re-emitting `abc\r` mid-line into a fresh + # LF file makes it part of the value. The secret then silently differs from + # the one in use — every session dies for JWT_SECRET, and Postgres refuses + # the connection for DB_PASSWORD. That is the exact outcome this reuse + # exists to prevent, so it must not be reachable through it. + # Matching surrounding quotes are stripped too. Compose strips them on read, + # so `KEY="abc"` round-trips harmlessly there, but the native path is read by + # dotenv into the process env — re-emitting `KEY=\"abc\"` would make the + # quotes part of the secret on one path and not the other. + sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*//p" \ + "$file" 2>/dev/null | head -n1 \ + | sed -E -e 's/[[:space:]]+$//' -e 's/^"(.*)"$/\1/' -e "s/^'(.*)'\$/\1/" || true +} + generate_jwt_secret() { openssl rand -base64 64 | tr -d "\n" } @@ -589,16 +638,66 @@ setup_docker_installation() { fi # Generate machine secrets. Written to .env so they are stable across - # restarts; once PR #714's secrets-init service is present it reuses these - # exact values (explicit env always wins), so this stays correct either way. - local jwt_secret=$(generate_jwt_secret) - local db_password=$(generate_password) - local redis_password=$(generate_password) + # restarts; the compose secrets-init service reuses these exact values + # (explicit env always wins), so this stays correct either way. + # + # Reuse whatever an existing .env already holds. The file is rewritten + # wholesale below, so generating unconditionally would rotate the secrets of + # an install that is already running: a new JWT_SECRET invalidates every + # admin session and every gallery link, and a new DB_PASSWORD no longer + # matches the password baked into the initialised Postgres volume, so the + # stack stops booting entirely. `--update` has always been the safe path, + # but re-running the installer is an easy mistake to make and it should not + # cost the user their data access. + local jwt_secret db_password redis_password + jwt_secret=$(read_env_value "$app_dir/.env" JWT_SECRET) + db_password=$(read_env_value "$app_dir/.env" DB_PASSWORD) + redis_password=$(read_env_value "$app_dir/.env" REDIS_PASSWORD) + + # Reading nothing back does NOT mean "no secrets exist". The documented + # install leaves all three commented out (.env.example) and lets the + # compose `secrets-init` service generate them into the picpeak-secrets + # volume, which is then the only place the real values live. Writing freshly + # generated ones into .env for that install is the worst possible outcome: + # secrets-init keeps the OLD values (it never overwrites), Postgres is still + # running on the old password, but explicit env now wins in the backend — so + # it can no longer authenticate, and JWT_SECRET rotates every session and + # gallery link out. Exactly what this reuse exists to prevent. + # + # So blank stays blank whenever the volume is there to own it. Only a truly + # fresh install — no .env value and no volume — gets generated secrets. + local secrets_volume_exists="no" + if command_exists docker && docker volume ls --format '{{.Name}}' 2>/dev/null \ + | grep -qE '(^|_)picpeak-secrets$'; then + secrets_volume_exists="yes" + fi + + if [[ "$secrets_volume_exists" == "yes" ]]; then + [[ -n "$jwt_secret" ]] || log_info "JWT_SECRET is managed by the picpeak-secrets volume — leaving it unset." + [[ -n "$db_password" ]] || log_info "DB_PASSWORD is managed by the picpeak-secrets volume — leaving it unset." + [[ -n "$redis_password" ]] || log_info "REDIS_PASSWORD is managed by the picpeak-secrets volume — leaving it unset." + else + [[ -n "$jwt_secret" ]] || jwt_secret=$(generate_jwt_secret) + [[ -n "$db_password" ]] || db_password=$(generate_password) + [[ -n "$redis_password" ]] || redis_password=$(generate_password) + fi local frontend_port="${CUSTOM_PORT:-3000}" local site_url; site_url="$(base_url)" # Create .env for docker-compose.production.yml (prebuilt GHCR images). + # The write is wholesale, so anything else the operator hand-edited (extra + # PICPEAK_* flags, S3 settings, TZ) is replaced, not merged — reusing the + # three secrets above does not make that safe on its own. Keep a copy first, + # the same way update_docker_installation already does. + if [ -f "$app_dir/.env" ]; then + # The rewrite below is wholesale, so losing this copy loses every other + # hand-edit in the file. Fail loudly rather than proceeding without it. + if ! cp "$app_dir/.env" "$app_dir/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"; then + log_error "Could not back up $app_dir/.env before rewriting it — aborting rather than overwriting it." + exit 1 + fi + fi log_step "Creating configuration..." cat > "$app_dir/.env" < "$NATIVE_APP_DIR/app/backend/.env" <