From 41ba99c0179ab9b026d832da46681f19252f888a Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 8 Aug 2026 13:40:45 +0200 Subject: [PATCH] =?UTF-8?q?Node=2020=20=E2=86=92=2024,=20and=20say=20what?= =?UTF-8?q?=20is=20wrong=20when=20the=20data=20directory=20is=20not=20writ?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 20 left maintenance in April 2026, so a production instance was running an interpreter that no longer gets security fixes. The upgrade needed better-sqlite3 as well. On Node 24 the version we pinned (^11) aborts inside the native addon — node[1]: void node::RemoveEnvironmentCleanupHook(...) at hooks.cc:142 Assertion failed: (env) != nullptr — so bumping only the base image would have shipped a server that dies on shutdown. Raised to ^12, which handles Node 24; verified against a copy of a real database that existing encrypted notes and version history still decrypt, and that SIGTERM now closes cleanly. Separately: a bind-mounted data directory the container cannot write to surfaced as "SqliteError: unable to open database file" out of better-sqlite3, which points at the database when the problem is on the host. Startup now checks both directories and says which uid it runs as, who owns the directory, and what to run to fix it. Frontend build now copies the lockfile and uses npm ci, so the image is built from the dependency versions that were tested. Co-Authored-By: Claude Opus 5 --- backend/Dockerfile | 7 ++++--- backend/package.json | 2 +- backend/server.js | 42 ++++++++++++++++++++++++++++++++++++++++-- frontend/dockerfile | 8 +++++--- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index d84af42..b364eb6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,11 +1,12 @@ -FROM node:20-alpine +FROM node:24-alpine -# better-sqlite3 needs build tools +# better-sqlite3 is a native module and is compiled here against this exact +# Node version — the reason a Node upgrade needs a rebuild, not just a retag. RUN apk add --no-cache python3 make g++ WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm install --production +RUN npm install --omit=dev COPY . . RUN mkdir -p /app/data /app/uploads diff --git a/backend/package.json b/backend/package.json index aa53864..2a61fc2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", - "better-sqlite3": "^11.0.0", + "better-sqlite3": "^12.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", diff --git a/backend/server.js b/backend/server.js index f84eb68..84fa5bf 100644 --- a/backend/server.js +++ b/backend/server.js @@ -67,8 +67,46 @@ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "") const DATA_DIR = path.join(__dirname, "data"); const UPLOAD_DIR = path.join(__dirname, "uploads"); -if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); -if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +/** + * Both directories are bind mounts from the Docker host, and the container runs + * unprivileged. If the host directory belongs to someone else — usually because + * Docker created it as root when it did not exist — nothing here can work. + * + * Without this check the failure surfaces from deep inside better-sqlite3 as + * `SqliteError: unable to open database file`, which says nothing about the + * cause and sends people looking at the database instead of at `ls -ld`. + */ +function ensureWritable(dir, label) { + try { + fs.mkdirSync(dir, { recursive: true }); + const probe = path.join(dir, `.write-probe-${process.pid}`); + fs.writeFileSync(probe, ""); + fs.unlinkSync(probe); + } catch (err) { + const uid = typeof process.getuid === "function" ? process.getuid() : "?"; + const gid = typeof process.getgid === "function" ? process.getgid() : "?"; + let owner = "unknown"; + try { + const s = fs.statSync(dir); + owner = `uid ${s.uid}, gid ${s.gid}, mode ${(s.mode & 0o777).toString(8)}`; + } catch { + owner = "does not exist"; + } + console.error( + `\n❌ Cannot write to the ${label} directory (${dir}).\n` + + ` The server runs as uid ${uid}, gid ${gid}; the directory is ${owner}.\n` + + ` This is the host directory bind-mounted into the container. Fix it there:\n\n` + + ` mkdir -p /{db,uploads}\n` + + ` chown -R ${uid}:${gid} \n` + + ` # under /root also: chmod o+x /root\n\n` + + ` (${err.code || err.message})\n` + ); + process.exit(1); + } +} +ensureWritable(DATA_DIR, "data"); +ensureWritable(UPLOAD_DIR, "uploads"); /* ─────────── Database ─────────── */ const db = new Database(path.join(DATA_DIR, "inknote.db")); diff --git a/frontend/dockerfile b/frontend/dockerfile index 8af3d66..119ecac 100644 --- a/frontend/dockerfile +++ b/frontend/dockerfile @@ -1,7 +1,9 @@ -FROM node:20-alpine AS build +FROM node:24-alpine AS build WORKDIR /app -COPY package.json ./ -RUN npm install +# The lockfile comes along so the image is built from the same dependency +# versions that were tested, rather than whatever resolves on build day. +COPY package.json package-lock.json ./ +RUN npm ci COPY . . RUN npm run build