diff --git a/Dockerfile b/Dockerfile index a7e293677..b6ce171c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,11 @@ COPY shared/package.json ./shared/ COPY nest-mcp/package.json ./nest-mcp/ COPY server/package.json ./server/ +# The trailing chown runs in this layer on purpose: it covers the manifests and +# the freshly installed node_modules while they are already part of this layer's +# changeset, so it costs nothing. Everything copied after this point carries +# --chown=node:node for the same reason — a recursive chown in a later layer +# would copy up every inode it touches and duplicate the whole tree in the image. RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ libkitinerary-bin && \ @@ -57,7 +62,8 @@ RUN apt-get update && \ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ apt-get purge -y python3 build-essential && \ apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ + chown -R node:node /app # gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node. COPY --from=gosu-build /out/gosu /usr/local/bin/gosu @@ -69,36 +75,43 @@ ENV QT_QPA_PLATFORM=offscreen # Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere. ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor -COPY --from=server-builder /app/server/dist ./server/dist +COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist # Runtime data assets read from server/assets at runtime: airports.json (flight # transport search) and atlas/*.geojson.gz (Atlas country/region map). The build # only emits dist, so these must be copied explicitly or the features silently # degrade to empty in the image. -COPY --from=server-builder /app/server/assets ./server/assets +COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets # The in-app help pages (/help) read this straight from disk at runtime, so the # docs always match the version running. Without it, wikiService falls back to # fetching the GitHub wiki, which tracks main and needs network access. -COPY wiki ./wiki +COPY --chown=node:node wiki ./wiki # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. -COPY server/tsconfig.json ./server/ +COPY --chown=node:node server/tsconfig.json ./server/ # Encryption-key rotation is run on demand via tsx (a prod dep) straight from the # raw .ts source — it never enters dist, so it must be copied in explicitly or # `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found. -COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts +COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts # Admin recovery script (node server/reset-admin.js) for locked-out installs. -COPY server/reset-admin.js ./server/reset-admin.js -COPY --from=shared-builder /app/shared/dist ./shared/dist +COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js +COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist # server dist requires @trek/nest-mcp at runtime through the workspace symlink; # its dist's MCP SDK subpath requires ride the same tsconfig-paths/register # hook the server already boots with. -COPY --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist -COPY --from=client-builder /app/client/dist ./server/public -COPY --from=client-builder /app/client/public/fonts ./server/public/fonts +COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist +COPY --chown=node:node --from=client-builder /app/client/dist ./server/public +COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts -RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ +# journey/ and places/ must be listed here and in server/src/index.ts (#1762) — +# a dir created lazily on first upload needs write permission on the uploads +# mount point itself, which fails with EACCES when the bind-mounted host dir +# isn't writable by node. Only paths this layer creates are chowned; anything +# already in the image arrived node-owned via --chown above. +RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \ + /app/uploads/photos /app/uploads/journey /app/uploads/places && \ ln -s /app/uploads /app/server/uploads && \ ln -s /app/data /app/server/data && \ - chown -R node:node /app + chown -R node:node /app/data /app/uploads && \ + chown -h node:node /app/server/uploads /app/server/data ENV NODE_ENV=production ENV PORT=3000 diff --git a/server/src/index.ts b/server/src/index.ts index 97b0bff2c..e1c4c3bc6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,16 +10,26 @@ import http from 'node:http'; import type { INestApplication } from '@nestjs/common'; import { buildApp } from './bootstrap'; -// Create upload and data directories on startup +// Create upload and data directories on startup. +// Every uploads subdir the app writes to must be listed here (#1762): a dir +// created lazily on first upload needs write permission on the uploads mount +// point itself at request time, which fails with EACCES on Docker hosts whose +// bind-mounted uploads dir isn't writable by the runtime user — while every +// feature writing into an already-existing subdir keeps working. Creating them +// at boot turns that into one loud startup failure instead of a stray 500. +// Keep in sync with the `mkdir -p` list in the Dockerfile (guarded by +// tests/unit/uploads-dirs.test.ts). const uploadsDir = path.join(__dirname, '../uploads'); const photosDir = path.join(uploadsDir, 'photos'); const filesDir = path.join(uploadsDir, 'files'); const coversDir = path.join(uploadsDir, 'covers'); const avatarsDir = path.join(uploadsDir, 'avatars'); +const journeyDir = path.join(uploadsDir, 'journey'); +const placesDir = path.join(uploadsDir, 'places'); const backupsDir = path.join(__dirname, '../data/backups'); const tmpDir = path.join(__dirname, '../data/tmp'); -[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, backupsDir, tmpDir].forEach(dir => { +[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, journeyDir, placesDir, backupsDir, tmpDir].forEach(dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); });