diff --git a/Dockerfile.aio b/Dockerfile.aio index a1a92728..ca4d0746 100644 --- a/Dockerfile.aio +++ b/Dockerfile.aio @@ -95,7 +95,8 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx # not provide that executable. The compose backend image omits it because # it always runs Postgres; this image defaults to SQLite, so without it the # built-in database backup fails with ENOENT. -RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec \ +# gettext — envsubst, used by docker-entrypoint.aio.sh to render index.html +RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec gettext \ fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \ fc-cache -f @@ -108,14 +109,15 @@ COPY --chown=nodejs:nodejs backend/ ./ COPY --from=frontend-builder --chown=nodejs:nodejs /build/dist ./public # index.html ships with ${BRAND_TITLE} / ${BRAND_DESCRIPTION} placeholders that -# frontend/docker-entrypoint.sh substitutes at container start. This image runs -# no nginx and therefore never runs that entrypoint, so without this every AIO -# install would serve a literal "${BRAND_TITLE}" as its and in every -# og:/twitter: card. Render them here with the same defaults that entrypoint -# applies when the vars are unset. -RUN sed -i 's/\${BRAND_TITLE}/PicPeak/g; s/\${BRAND_DESCRIPTION}/Photo gallery shared with PicPeak./g' \ - ./public/index.html && \ - ! grep -q '\${BRAND_' ./public/index.html +# frontend/docker-entrypoint.sh renders at container start. This image runs no +# nginx and never invokes that entrypoint, so keep the built file as a template +# and render it on startup instead (docker-entrypoint.aio.sh) — baking the +# defaults at build time would fix the literal-token bug but silently make +# BRAND_TITLE/BRAND_DESCRIPTION inert for this image. +RUN mv ./public/index.html ./public/index.html.template + +COPY --chown=nodejs:nodejs docker-entrypoint.aio.sh /usr/local/bin/docker-entrypoint.aio.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.aio.sh RUN chmod -R a+r /app && chmod +x wait-for-db.sh @@ -142,6 +144,11 @@ ENV SERVE_FRONTEND=true \ DATABASE_PATH=/data/db/picpeak.db \ STORAGE_PATH=/data/storage \ LOG_DIR=/data/logs \ + # Point the startup chown/preflight at the REAL directory, not the /backup + # symlink: `chown -R` on a symlink argument acts on the link itself, so the + # target under a bind-mounted /data would never be adopted and the + # writability preflight would fail as UID 1001. + BACKUP_DIR=/data/backup \ PORT=3000 # /backup is where migrations 029 + 030 seed the built-in backup destinations @@ -169,4 +176,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ # No USER directive: the container starts as root so wait-for-db.sh can chown a # bind-mounted /data to UID 1001 before dropping privileges via su-exec (#484). ENTRYPOINT ["dumb-init", "--"] -CMD ["./wait-for-db.sh", "node", "server.js"] +CMD ["/usr/local/bin/docker-entrypoint.aio.sh", "node", "server.js"] diff --git a/backend/server.js b/backend/server.js index 3c29eb5e..928388d2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -967,7 +967,7 @@ if (typeof serveFrontendIndexPath === 'string') { // /uploads are backend-owned file routes whose middleware calls next() when // the file is missing, and swallowing that into a 200 index.html would turn // a missing photo into an HTML body served under an image URL. - const BACKEND_OWNED = ['/api/', '/photos/', '/thumbnails/', '/uploads/', '/health']; + const BACKEND_OWNED = ['/api/', '/photos/', '/thumbnails/', '/uploads/', '/fonts/', '/health']; app.get('*', (req, res, next) => { if (BACKEND_OWNED.some((prefix) => req.path.startsWith(prefix))) return next(); return res.sendFile(serveFrontendIndexPath); diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 02f3ba35..417b0e14 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -29,7 +29,7 @@ unset _pair _var _file _cur # single-container image (#1042) points all three under one mounted volume, # so these must follow the same env vars the app itself reads rather than # hard-coding /app — otherwise the checks below guard directories nothing uses. -DATA_DIRS="${STORAGE_PATH:-/app/storage} ${DATA_DIR:-/app/data} ${LOG_DIR:-/app/logs}" +DATA_DIRS="${STORAGE_PATH:-/app/storage} ${DATA_DIR:-/app/data} ${LOG_DIR:-/app/logs} ${BACKUP_DIR:-/backup}" # Create the roots before touching them. With the compose layout each of the # three is its own mount point, so they always exist — but the single-container @@ -72,46 +72,20 @@ for _dir in $DATA_DIRS; do fi done -# Resolve which database engine this boot should use (#1038) BEFORE migrations -# run — and, since #1042, before the PostgreSQL readiness wait below, so a -# SQLite install never blocks on a Postgres that will never answer. +# Everything below this point is PostgreSQL readiness. On SQLite the database is +# a file this process opens itself — there is no daemon to wait for — so the +# loop would block forever on a host that will never answer. That is the +# single-container case (#1042), where the image DECLARES sqlite3. # -# An install that has been unknowingly running on SQLite (the image used to -# leave NODE_ENV unset, so -# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD) -# keeps serving from its SQLite file instead of coming up against an empty -# Postgres. The exported value survives the `exec` below, so the migration -# runner and the server agree on the engine. -RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)" -RESOLVER_STATUS=$? -# Exit 3 means two populated databases with no record of which is current -# (#1038). Starting either would hide the other's data, so stop here — the -# resolver has already printed what to do. -if [ "$RESOLVER_STATUS" = "3" ]; then - exit 1 -fi -# Validate rather than trust: anything unexpected on stdout (a stray log line -# from a library that writes to the console) must not become DATABASE_CLIENT, -# which would break knexfile for every process that follows. -case "$RESOLVED_DB_CLIENT" in - pg|sqlite3) - export DATABASE_CLIENT="$RESOLVED_DB_CLIENT" - ;; - "") - >&2 echo "Database engine resolver returned nothing; falling back to the configured client." - ;; - *) - >&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client." - ;; -esac - -# Everything below this point is PostgreSQL readiness. On SQLite the -# database is a file this process opens itself — there is no daemon to -# wait for — so the wait loop would block forever on a host that will -# never answer. That is exactly the single-container case (#1042), where -# SQLite is the documented default and no DB_HOST is set. +# The test is deliberately the raw env var, not the resolver's answer. Resolving +# first would change behaviour for installs that declare nothing: probePgData +# reports "occupied" when Postgres is unreachable, and the target database may +# not exist until the loop below creates it — so an unset-client install with a +# populated legacy SQLite file would resolve to ambiguous-both-populated and +# refuse to start. Those installs keep today's ordering exactly: wait, create, +# then resolve. if [ "${DATABASE_CLIENT:-}" = "sqlite3" ]; then - echo "Database engine: sqlite3 — skipping the PostgreSQL readiness wait." + echo "Database engine: sqlite3 declared — skipping the PostgreSQL readiness wait." else host="${DB_HOST:-postgres}" @@ -191,9 +165,49 @@ echo "Ensuring storage directories exist..." STORAGE_BASE="${STORAGE_PATH:-/app/storage}" mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true +# Backup destinations seeded by migrations 029 + 030 (/backup/picpeak and +# /backup/database). Creating the root alone is not enough: on a bind mount the +# subdirectories baked into the image are hidden, and the backup services do not +# create them, so a scheduled or manual backup fails with ENOENT. +BACKUP_BASE="${BACKUP_DIR:-/backup}" +mkdir -p "$BACKUP_BASE/picpeak" "$BACKUP_BASE/database" 2>/dev/null || true + # Resolve which database engine this boot should use (#1038) BEFORE migrations # run, while the Postgres target is still untouched. An install that has been +# Resolve which database engine this boot should use (#1038) BEFORE migrations +# run — and, since #1042, before the PostgreSQL readiness wait below, so a +# SQLite install never blocks on a Postgres that will never answer. +# +# An install that has been unknowingly running on SQLite (the image used to +# leave NODE_ENV unset, so +# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD) +# keeps serving from its SQLite file instead of coming up against an empty +# Postgres. The exported value survives the `exec` below, so the migration +# runner and the server agree on the engine. +RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)" +RESOLVER_STATUS=$? +# Exit 3 means two populated databases with no record of which is current +# (#1038). Starting either would hide the other's data, so stop here — the +# resolver has already printed what to do. +if [ "$RESOLVER_STATUS" = "3" ]; then + exit 1 +fi +# Validate rather than trust: anything unexpected on stdout (a stray log line +# from a library that writes to the console) must not become DATABASE_CLIENT, +# which would break knexfile for every process that follows. +case "$RESOLVED_DB_CLIENT" in + pg|sqlite3) + export DATABASE_CLIENT="$RESOLVED_DB_CLIENT" + ;; + "") + >&2 echo "Database engine resolver returned nothing; falling back to the configured client." + ;; + *) + >&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client." + ;; +esac + # Run migrations (use safe runner in production). Invoked via node directly — # the runtime image no longer ships npm (see Dockerfile: its bundled deps kept # tripping CVE scanners while npm itself never runs in production). diff --git a/docker-entrypoint.aio.sh b/docker-entrypoint.aio.sh new file mode 100644 index 00000000..b86166ca --- /dev/null +++ b/docker-entrypoint.aio.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# All-in-one entrypoint (#1042). +# +# The compose deployment renders index.html in the frontend container via +# frontend/docker-entrypoint.sh. This image runs no nginx, so that never +# happens — and Vite does not substitute ${BRAND_TITLE} at build time, so the +# built index.html ships the placeholders verbatim. Without this step every +# AIO install would serve a literal "${BRAND_TITLE}" as its <title> and in +# every og:/twitter: card. +# +# Kept as a startup step rather than a build-time sed so BRAND_TITLE and +# BRAND_DESCRIPTION behave the same way they do for the frontend image: +# set the env var, restart the container, new branding. Baking them at build +# would silently make those variables inert. +set -e + +TEMPLATE=/app/public/index.html.template +RENDERED=/app/public/index.html + +if [ -f "$TEMPLATE" ]; then + # Same defaults frontend/docker-entrypoint.sh applies when the vars are unset. + : "${BRAND_TITLE:=PicPeak}" + : "${BRAND_DESCRIPTION:=Photo gallery shared with PicPeak.}" + + # Substitute ONLY these two, exactly like the frontend entrypoint: letting + # envsubst expand every ${...} it finds would mangle unrelated content. + export BRAND_TITLE BRAND_DESCRIPTION + envsubst '${BRAND_TITLE} ${BRAND_DESCRIPTION}' < "$TEMPLATE" > "$RENDERED" + + # The app runs as UID 1001 after the privilege drop below; the render happens + # while we are still root, so hand the result over explicitly. + chown nodejs:nodejs "$RENDERED" 2>/dev/null || true +fi + +exec ./wait-for-db.sh "$@"