/* * InkNote — Backend Sync Server * ───────────────────────────── * Node.js + Express + SQLite * * Features: * • User registration / login (JWT) * • CRUD for notes, notebooks, PDFs * • Conflict-aware sync with vector clocks * • WebSocket real-time push * • Encrypted storage at rest (AES-256) * * Install: * npm init -y * npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv * * Run: * node server.js */ require("dotenv").config(); const express = require("express"); const cors = require("cors"); const helmet = require("helmet"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const { WebSocketServer } = require("ws"); const Database = require("better-sqlite3"); const { v4: uuid } = require("uuid"); const crypto = require("crypto"); const path = require("path"); const fs = require("fs"); const http = require("http"); /* ─────────── Config ─────────── */ const PORT = process.env.PORT || 4000; /* Secrets must be supplied. They used to fall back to a fresh random value, * which meant a restart without them silently made every stored note * undecryptable — the data was still there, just lost forever. Refuse to boot * instead, and say exactly how to generate what is missing. */ function requireSecret(name, { hexBytes } = {}) { const v = process.env[name]; if (!v) { const how = hexBytes ? `openssl rand -hex ${hexBytes}` : "openssl rand -base64 32"; console.error(`\n ✖ ${name} is not set. Refusing to start.`); console.error(` Generate one with: ${how}`); console.error(` Then put it in .env or pass it as an environment variable.\n`); process.exit(1); } if (hexBytes && !new RegExp(`^[0-9a-fA-F]{${hexBytes * 2}}$`).test(v)) { console.error(`\n ✖ ${name} must be ${hexBytes * 2} hex characters (${hexBytes} bytes).`); console.error(` Generate one with: openssl rand -hex ${hexBytes}\n`); process.exit(1); } return v; } const JWT_SECRET = requireSecret("JWT_SECRET"); const ENCRYPTION_KEY = requireSecret("ENCRYPTION_KEY", { hexBytes: 32 }); const ENCRYPTION_KEY_BUF = Buffer.from(ENCRYPTION_KEY, "hex"); /** Reads a positive number from the environment, or falls back. */ const envNum = (name, fallback) => { const v = Number(process.env[name]); return Number.isFinite(v) && v > 0 ? v : fallback; }; const mb = (name, fallbackMb) => envNum(name, fallbackMb) * 1024 * 1024; /* Images and PDFs are limited separately: a picture that large is almost always * a mistake, while a scanned book legitimately is. Both are generous because * this is a private instance — the limits exist so a runaway upload cannot fill * the disk, not to ration anything. */ const MAX_IMAGE_BYTES = mb("INKNOTE_MAX_IMAGE_MB", 25); const MAX_PDF_BYTES = mb("INKNOTE_MAX_PDF_MB", 200); // Which origins may call the API. Same-origin deployments (nginx in front) need // none; set a comma-separated list only if you serve the UI from another host. const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "") .split(",").map((s) => s.trim()).filter(Boolean); const DATA_DIR = path.join(__dirname, "data"); const UPLOAD_DIR = path.join(__dirname, "uploads"); /** * 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")); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS notebooks ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, cover_index INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), deleted_at TEXT, version INTEGER DEFAULT 1, device_id TEXT ); CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, notebook_id TEXT REFERENCES notebooks(id) ON DELETE SET NULL, type TEXT NOT NULL DEFAULT 'text', -- text | drawing | pdf title TEXT NOT NULL DEFAULT 'Untitled', content_encrypted BLOB, drawing_data_path TEXT, pdf_path TEXT, annotations_path TEXT, starred INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), deleted_at TEXT, version INTEGER DEFAULT 1, device_id TEXT ); CREATE TABLE IF NOT EXISTS sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, entity_type TEXT NOT NULL, -- note | notebook entity_id TEXT NOT NULL, action TEXT NOT NULL, -- create | update | delete payload TEXT, device_id TEXT, timestamp TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, platform TEXT, last_sync TEXT DEFAULT (datetime('now')) ); -- Which note references which. Note bodies are encrypted, so the server -- cannot derive this itself; the client sends the link list when it saves. CREATE TABLE IF NOT EXISTS note_links ( source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, target_id TEXT NOT NULL, target_kind TEXT NOT NULL DEFAULT 'note', user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (source_id, target_id, target_kind) ); -- Past states of a note, so an accidental overwrite can be undone after the -- fact. Content is encrypted here exactly as it is in the notes table. /* Which attachments a note's current body uses, and which each kept version * used. The server cannot read either — both are encrypted — so the client * reports the note's set on every save, and a version inherits whatever the * note held at the moment it was snapshotted, which is by definition what * that version contains. * * Together these are what make freeing an attachment safe: a picture removed * from a note is still in the versions that came before, and deleting the file * would leave a hole in history that restoring cannot fill. */ CREATE TABLE IF NOT EXISTS note_attachments ( note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, attachment_id TEXT NOT NULL, PRIMARY KEY (note_id, attachment_id) ); CREATE TABLE IF NOT EXISTS version_attachments ( version_id INTEGER NOT NULL REFERENCES note_versions(id) ON DELETE CASCADE, attachment_id TEXT NOT NULL, PRIMARY KEY (version_id, attachment_id) ); CREATE TABLE IF NOT EXISTS note_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT, content_encrypted BLOB, drawing_strokes BLOB, device_id TEXT, reason TEXT, created_at TEXT DEFAULT (datetime('now')) ); -- Binary blobs referenced from note bodies. Stored on disk under a content -- hash, so the same image pasted into ten notes is kept once. CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, sha256 TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, filename TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_note_attachments_att ON note_attachments(attachment_id); CREATE INDEX IF NOT EXISTS idx_version_attachments_att ON version_attachments(attachment_id); CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_owner_hash ON attachments(user_id, sha256); CREATE INDEX IF NOT EXISTS idx_note_versions ON note_versions(note_id, id DESC); CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id); CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id); CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp); CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(user_id, target_id); CREATE INDEX IF NOT EXISTS idx_note_links_source ON note_links(source_id); `); /* ─────────── Migrations ─────────── */ const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/; const MIN_PASSWORD_LENGTH = 6; function normalizeUsername(raw) { const v = String(raw ?? "").trim(); return USERNAME_RE.test(v) ? v : null; } /** The fields of a user row that may leave the server. Never the password hash. */ const publicUser = (row) => ({ id: row.id, username: row.username, displayName: row.display_name, isAdmin: !!row.is_admin, }); // Login used to be e-mail based. Rebuild the table with a username column and // derive each existing user's username from the local part of their address. (function migrateEmailToUsername() { const cols = tableColumns("users"); if (!cols.includes("email")) return; db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` CREATE TABLE users_migrated ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); `); const insert = db.prepare( "INSERT INTO users_migrated (id, username, password_hash, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" ); const taken = new Set(); for (const u of db.prepare("SELECT * FROM users").all()) { const seed = String(u.email || u.display_name || "user").split("@")[0]; const base = (seed.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 32) || "user").padEnd(3, "0"); let name = base; for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base.slice(0, 30)}${i}`; taken.add(name.toLowerCase()); insert.run(u.id, name, u.password_hash, u.display_name || name, u.created_at, u.updated_at); console.log(` ↪ migrated login: ${u.email} → username "${name}"`); } db.exec("DROP TABLE users; ALTER TABLE users_migrated RENAME TO users;"); })(); db.pragma("foreign_keys = ON"); console.log(" ✅ users table migrated from e-mail to username login"); })(); // Drawings are stored as resolution-independent vector strokes. The legacy // raster path (drawing_data_path) stays readable for notes drawn before this. if (!tableColumns("notes").includes("drawing_strokes")) { db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB"); } // A PDF note points at the attachment holding its file. The old pdf_path column // stored a filesystem path instead, which is why it never survived a container // rebuild; attachments are content-addressed, encrypted and deduplicated. // How many versions a notebook keeps per note. Null falls back to the global // default, so a notebook only carries a number once someone has chosen one. /* Whether this note's client has told us which attachments it uses. * * Notes written before that existed have no reference rows, which is * indistinguishable from a note using no pictures at all — and freeing on that * basis would delete images that are still on screen. Nothing is released for a * user until every one of their notes has reported, which the background * prefetch takes care of on the first pass after an upgrade. */ if (!tableColumns("notes").includes("attachments_reported")) { db.exec("ALTER TABLE notes ADD COLUMN attachments_reported INTEGER NOT NULL DEFAULT 0"); } if (!tableColumns("notebooks").includes("max_versions")) { db.exec("ALTER TABLE notebooks ADD COLUMN max_versions INTEGER"); } /* How a notebook's notes are ordered on screen. Stored on the server rather * than per device: it is a property of the notebook, and finding a notebook * sorted differently on the phone than on the desktop is its own small * annoyance. Null means the app's default, newest first. */ if (!tableColumns("notebooks").includes("sort_mode")) { db.exec("ALTER TABLE notebooks ADD COLUMN sort_mode TEXT"); } /* Lines the user drew between notes in the manual order, as JSON. * * A column rather than a table: they are a handful of labels that only mean * anything as an ordered list, they are always read and written whole with the * notebook, and nothing ever queries across them. */ if (!tableColumns("notebooks").includes("separators")) { db.exec("ALTER TABLE notebooks ADD COLUMN separators TEXT"); } if (!tableColumns("notes").includes("pdf_attachment_id")) { db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT"); } if (!tableColumns("users").includes("is_admin")) { db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"); } /** * Makes sure exactly one way in exists. * * There is no public sign-up: accounts are created by an admin. That leaves the * question of where the first admin comes from, and the answer is this — a * username and password in the environment, used only when the database has no * users at all. An installation that already has users is never touched by the * credentials, so leaving them in .env cannot silently reset anything. * * The second branch covers upgrades: a database that predates admin rights has * users but no admin, and would otherwise be locked out of creating any more. */ (function ensureAdminExists() { const wanted = normalizeUsername(process.env.INKNOTE_ADMIN_USER); const password = process.env.INKNOTE_ADMIN_PASSWORD; const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n; if (userCount === 0) { if (!wanted || !password) { console.warn( "⚠️ No users and no INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD set — nobody can log in.\n" + " Set both in .env and restart to create the first administrator." ); return; } if (String(password).length < MIN_PASSWORD_LENGTH) { console.warn(`⚠️ INKNOTE_ADMIN_PASSWORD is shorter than ${MIN_PASSWORD_LENGTH} characters — not creating an admin.`); return; } db.prepare( "INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 1)" ).run(uuid(), wanted, bcrypt.hashSync(String(password), 12), wanted); console.log(` ✅ created first administrator "${wanted}" from the environment`); return; } if (db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n > 0) return; // Prefer the account named in the environment; otherwise the oldest one, so // an existing single-user installation promotes the obvious candidate. const promote = (wanted && db.prepare("SELECT id, username FROM users WHERE username = ?").get(wanted)) || db.prepare("SELECT id, username FROM users ORDER BY created_at LIMIT 1").get(); db.prepare("UPDATE users SET is_admin = 1 WHERE id = ?").run(promote.id); console.log(` ✅ granted administrator rights to existing user "${promote.username}"`); })(); /* ─────────── Encryption helpers ─────────── */ /* AES-256-GCM. CBC gave confidentiality but no integrity — a flipped bit in the * ciphertext decrypted to garbage instead of failing loudly. GCM authenticates, * so tampering and corruption are detected. Records written before this are * still CBC and stay readable; they re-encrypt as GCM on their next write. */ function encrypt(text) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]); return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`; } /* ─── Files at rest ─── * * Note bodies were encrypted from the start, but uploaded files were not: a * copy of the uploads directory handed over every image in the clear while the * notes around them stayed unreadable. Same key, same cipher, so a backup is * worth exactly as much (or as little) as its .env either way. * * The header lets a file say for itself whether it is encrypted, which is what * makes the migration below safe to run again and again: files are shared * between users by content hash, so marking a database row would not have been * enough to tell. */ const FILE_MAGIC = Buffer.from("INKENC01", "ascii"); // 8 bytes const IV_BYTES = 12; const TAG_BYTES = 16; function encryptBuffer(plain) { const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); const enc = Buffer.concat([cipher.update(plain), cipher.final()]); return Buffer.concat([FILE_MAGIC, iv, cipher.getAuthTag(), enc]); } const isEncryptedFile = (buf) => buf.length >= FILE_MAGIC.length && buf.subarray(0, FILE_MAGIC.length).equals(FILE_MAGIC); /** * Writes through a temporary file and renames. * * A rename is atomic on the same filesystem, so a crash mid-write leaves either * the old file or the new one — never a half-written attachment that would fail * its authentication tag and read as corrupt forever after. */ function writeFileAtomic(filePath, bytes) { const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, bytes); fs.renameSync(tmp, filePath); } function decryptBuffer(buf) { // Files written before this existed are still plain bytes; they read straight // through until the sweep below gets to them. if (!isEncryptedFile(buf)) return buf; const at = FILE_MAGIC.length; const iv = buf.subarray(at, at + IV_BYTES); const tag = buf.subarray(at + IV_BYTES, at + IV_BYTES + TAG_BYTES); const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(buf.subarray(at + IV_BYTES + TAG_BYTES)), decipher.final()]); } function decrypt(data) { const s = String(data); if (s.startsWith("v2:")) { const [, ivHex, tagHex, encHex] = s.split(":"); const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex")); decipher.setAuthTag(Buffer.from(tagHex, "hex")); return decipher.update(encHex, "hex", "utf8") + decipher.final("utf8"); } const [ivHex, encryptedHex] = s.split(":"); // legacy AES-256-CBC const decipher = crypto.createDecipheriv("aes-256-cbc", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex")); return decipher.update(encryptedHex, "hex", "utf8") + decipher.final("utf8"); } function detectImageMime(buffer, filePath = "") { if (buffer && buffer.length >= 12) { if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png"; if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp"; } const ext = path.extname(filePath).toLowerCase(); if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".webp") return "image/webp"; return "image/png"; } /* ─────────── Conflict detection ─────────── */ /** * Whether this write is based on a stale copy of the note. * * A client sends the `updatedAt` it last saw. If the note has moved on since — * another device saved in between — the two edits are independent and one is * about to bury the other. * * Nothing is rejected: refusing the write would lose whatever was just typed, * and on a phone that was offline for a day that is the worse outcome. The * incoming edit wins, but the version it replaces is kept with reason * "conflict", so both exist and the newer one can be picked in the history. * * This deliberately replaces the vector clocks that used to sit here. They * could tell a genuine conflict from a simple catch-up, but no client ever sent * one, and the extra precision only pays off with a merge interface to spend it * on. Comparing revisions needs one field and reuses the history already built. */ function isStaleWrite(serverUpdatedAt, clientBaseUpdatedAt) { if (!clientBaseUpdatedAt || !serverUpdatedAt) return false; return String(serverUpdatedAt) > String(clientBaseUpdatedAt); } /* ─────────── Auth middleware ─────────── */ function authMiddleware(req, res, next) { const header = req.headers.authorization; if (!header || !header.startsWith("Bearer ")) { return res.status(401).json({ error: "Authentication required" }); } try { const token = header.slice(7); const payload = jwt.verify(token, JWT_SECRET); // Tokens are signed rather than stored, so there is no session to revoke // when an admin deletes an account. Checking that the user still exists is // what stops a deleted person's open tab from carrying on as if nothing // happened; the client treats the 401 as a sign-out. if (!userExists.get(payload.userId)) { return res.status(401).json({ error: "Account no longer exists" }); } req.userId = payload.userId; req.deviceId = req.headers["x-device-id"] || "unknown"; next(); } catch { res.status(401).json({ error: "Invalid token" }); } } const userExists = db.prepare("SELECT 1 FROM users WHERE id = ?").pluck(); /* ─────────── Express app ─────────── */ const app = express(); app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs app.use(helmet()); app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false })); // Images arrive base64-encoded inside JSON, which inflates them by a third; // PDFs take the raw-body route below and never pass through here. app.use(express.json({ limit: `${Math.ceil((MAX_IMAGE_BYTES / 1048576) * 1.4)}mb` })); /* Password checks are deliberately slow, which makes them both a brute-force * defence and a denial-of-service lever. Cap attempts per username+IP. */ const RATE_WINDOW_MS = 15 * 60 * 1000; const RATE_MAX_ATTEMPTS = 10; const loginAttempts = new Map(); // key -> { count, resetAt } function rateLimitLogin(req, res, next) { const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`; const now = Date.now(); const entry = loginAttempts.get(key); if (!entry || now > entry.resetAt) { loginAttempts.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS }); return next(); } if (entry.count >= RATE_MAX_ATTEMPTS) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000); res.set("Retry-After", String(retryAfter)); return res.status(429).json({ error: "Too many attempts. Try again later.", retryAfter }); } entry.count++; next(); } const clearLoginAttempts = (req) => { loginAttempts.delete(`${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`); }; setInterval(() => { const now = Date.now(); for (const [k, v] of loginAttempts) if (now > v.resetAt) loginAttempts.delete(k); }, RATE_WINDOW_MS).unref(); // A dummy hash keeps "no such user" and "wrong password" equally slow, so the // response time does not reveal which usernames exist. const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12); /* ─── Auth routes ─── */ /* There is no registration route on purpose: this is a private instance, and * accounts are created by an administrator under /api/admin/users. */ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { try { const { username, password } = req.body; const name = String(username ?? "").trim(); const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null; // Always run a comparison so timing does not leak whether the user exists. const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH); if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" }); clearLoginAttempts(req); const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); res.json({ token, user: publicUser(user) }); } catch (err) { next(err); } }); /* ─── Auth profile routes ─── */ app.get("/api/auth/me", authMiddleware, (req, res) => { const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); res.json(publicUser(user)); }); app.put("/api/auth/profile", authMiddleware, (req, res) => { const { displayName } = req.body; if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" }); db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId); res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId))); }); app.put("/api/auth/username", authMiddleware, async (req, res, next) => { try { const { newUsername, password } = req.body; const name = normalizeUsername(newUsername); if (!name || !password) return res.status(400).json({ error: "Username and password required" }); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" }); const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId); if (exists) return res.status(409).json({ error: "Username already taken" }); db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId); res.json(publicUser({ ...user, username: name })); } catch (err) { next(err); } }); app.put("/api/auth/password", authMiddleware, async (req, res, next) => { try { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); if (String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" }); const hash = await bcrypt.hash(String(newPassword), 12); db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); res.json({ success: true }); } catch (err) { next(err); } }); /* ─── Admin routes ─── */ /** Gate for everything under /api/admin. Runs after authMiddleware. */ function requireAdmin(req, res, next) { const me = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId); // Deliberately the same 404-ish silence an ordinary user gets elsewhere: a // non-admin should not learn that an admin area exists. if (!me || !me.is_admin) return res.status(403).json({ error: "Not allowed" }); next(); } app.get("/api/admin/users", authMiddleware, requireAdmin, (req, res) => { const rows = db .prepare( `SELECT u.*, (SELECT COUNT(*) FROM notes n WHERE n.user_id = u.id AND n.deleted_at IS NULL) AS note_count FROM users u ORDER BY u.created_at` ) .all(); res.json( rows.map((r) => ({ ...publicUser(r), createdAt: r.created_at, noteCount: r.note_count, isSelf: r.id === req.userId, })) ); }); app.post("/api/admin/users", authMiddleware, requireAdmin, async (req, res, next) => { try { const { username, password, displayName } = req.body; const name = normalizeUsername(username); if (!name) return res.status(400).json({ error: "Username must be 3-32 characters: letters, digits, . _ -" }); if (!password || String(password).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); const id = uuid(); const hash = await bcrypt.hash(String(password), 12); try { db.prepare( "INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 0)" ).run(id, name, hash, String(displayName || name).trim() || name); } catch (err) { if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" }); throw err; } console.log(` 👤 admin ${req.userId} created user "${name}"`); res.status(201).json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(id))); } catch (err) { next(err); } }); app.put("/api/admin/users/:id/password", authMiddleware, requireAdmin, async (req, res, next) => { try { const { newPassword } = req.body; if (!newPassword || String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id); if (!target) return res.status(404).json({ error: "User not found" }); const hash = await bcrypt.hash(String(newPassword), 12); db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run( hash, target.id ); console.log(` 🔑 admin ${req.userId} reset the password of "${target.username}"`); // Sessions elsewhere keep working: tokens are signed, not stored, so there // is nothing to revoke. Resetting a password locks nobody out mid-edit. res.json({ success: true }); } catch (err) { next(err); } }); /** * Runs the housekeeping sweep now instead of waiting for its timer. * * `graceSeconds` lowers the delay that protects an upload still in flight — its * row exists before the client has had a chance to report the note that uses it. * Only an administrator can ask, and only on their own instance; the default * leaves the protection alone. */ app.post("/api/admin/housekeeping", authMiddleware, requireAdmin, (req, res) => { const grace = req.body?.graceSeconds === undefined ? ORPHAN_GRACE_MS : Math.max(0, Number(req.body.graceSeconds) || 0) * 1000; pruneSyncLog(); pruneOldVersions(); pruneUnreferencedAttachments(grace); pruneOrphanAttachments(grace); res.json({ success: true }); }); app.delete("/api/admin/users/:id", authMiddleware, requireAdmin, (req, res) => { const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id); if (!target) return res.status(404).json({ error: "User not found" }); if (target.id === req.userId) return res.status(400).json({ error: "You cannot delete your own account" }); // A backstop against leaving the instance with no way to add users. Today the // self-delete check above already covers it — the only admin can only be // yourself — but that stops being true the moment admins can appoint admins. if (target.is_admin && db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n <= 1) return res.status(400).json({ error: "This is the last administrator" }); // Notes, notebooks, versions and attachment rows go with the user through // ON DELETE CASCADE; the files behind them are swept up by pruneOrphanAttachments. db.prepare("DELETE FROM users WHERE id = ?").run(target.id); console.log(` 🗑️ admin ${req.userId} deleted user "${target.username}" and all their data`); res.json({ success: true }); }); /* ─── Notebook routes ─── */ app.get("/api/notebooks", authMiddleware, (req, res) => { const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId); res.json(notebooks); }); app.post("/api/notebooks", authMiddleware, (req, res) => { const { name, coverIndex } = req.body; const id = uuid(); db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)") .run(id, req.userId, name, coverIndex || 0, req.deviceId); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)") .run(req.userId, id, req.deviceId); broadcastToUser(req.userId, { type: "notebook_created", data: { id, name, coverIndex } }, req.deviceId); res.json({ id, name, coverIndex }); }); /** The orders the note list offers. Anything else is refused rather than stored. */ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"]; /* An upper bound on the arrangement, so a malformed client cannot store an * unbounded blob on a notebook. */ const MAX_SEPARATORS = 200; const MAX_SEPARATOR_LABEL = 80; /** Keeps only what a separator is allowed to be. */ function cleanSeparators(input) { if (!Array.isArray(input)) return null; return input .slice(0, MAX_SEPARATORS) .filter((s) => s && typeof s.id === "string" && s.id) .map((s) => ({ id: s.id.slice(0, 64), label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "", order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0, })); } app.put("/api/notebooks/:id", authMiddleware, (req, res) => { const { name, coverIndex, sortOrder, maxVersions, sortMode, separators } = req.body; const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!nb) return res.status(404).json({ error: "Not found" }); // null clears the notebook's own setting and puts it back on the default; // undefined leaves whatever it had. let versions; if (maxVersions === null) versions = null; else if (maxVersions !== undefined) { const n = Number(maxVersions); if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: "maxVersions must be at least 1" }); versions = Math.min(Math.round(n), MAX_VERSIONS_CEILING); } if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) { return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` }); } db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?") .run(name, coverIndex, sortOrder, req.params.id); if (maxVersions !== undefined) { db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id); } if (sortMode !== undefined) { db.prepare("UPDATE notebooks SET sort_mode = ? WHERE id = ?").run(sortMode, req.params.id); } if (separators !== undefined) { const cleaned = cleanSeparators(separators); if (!cleaned) return res.status(400).json({ error: "separators must be an array" }); db.prepare("UPDATE notebooks SET separators = ? WHERE id = ?").run(JSON.stringify(cleaned), req.params.id); } broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId); res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); }); app.delete("/api/notebooks/:id", authMiddleware, (req, res) => { db.prepare("UPDATE notebooks SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); broadcastToUser(req.userId, { type: "notebook_deleted", data: { id: req.params.id } }, req.deviceId); res.json({ success: true }); }); /* ─────────── Version history ─────────── * * A snapshot records what a note looked like *before* a write, so the previous * state is always recoverable. * * Saves arrive roughly once per second while typing, so snapshotting every one * would be useless noise. Instead they coalesce: within the window below, the * existing newest version already represents "before this editing session". * A write that shrinks a note sharply bypasses the window, because that is * exactly the case worth being able to undo. */ const num = (name, fallback) => { const v = Number(process.env[name]); return Number.isFinite(v) && v > 0 ? v : fallback; }; const VERSION_COALESCE_MS = num("VERSION_COALESCE_MINUTES", 5) * 60 * 1000; const MAX_VERSIONS_PER_NOTE = num("MAX_VERSIONS_PER_NOTE", 50); /* A per-notebook setting is a number a user types; it needs an upper bound. */ const MAX_VERSIONS_CEILING = 500; /* Zero means "never expire by age", and that is the default. * * Versions used to be dropped after 90 days as well as being capped per note. * The cap already bounds how much a note can accumulate, so the age rule only * ever deleted history that nothing had replaced — and it hit hardest exactly * where history is most wanted: a note untouched for a quarter of a year lost * its whole past, having done nothing but sit there. Set the variable to a * number of days to bring it back. */ const VERSION_RETENTION_DAYS = num("VERSION_RETENTION_DAYS", 0); const sqliteTimeToMs = (t) => (t ? Date.parse(`${String(t).replace(" ", "T")}Z`) : 0); /** How many versions this note's notebook keeps — its own setting, or the default. */ function versionLimitFor(noteId) { const row = db .prepare( "SELECT b.max_versions AS limit_ FROM notes n LEFT JOIN notebooks b ON b.id = n.notebook_id WHERE n.id = ?" ) .get(noteId); const own = Number(row?.limit_); return Number.isFinite(own) && own > 0 ? Math.min(own, MAX_VERSIONS_CEILING) : MAX_VERSIONS_PER_NOTE; } function pruneNoteVersions(noteId) { db.prepare(` DELETE FROM note_versions WHERE note_id = ? AND id NOT IN ( SELECT id FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT ? )`).run(noteId, noteId, versionLimitFor(noteId)); } function snapshotNote(userId, noteId, deviceId, { force = false, reason = "edit" } = {}) { const note = db.prepare( "SELECT title, content_encrypted, drawing_strokes FROM notes WHERE id = ? AND user_id = ?" ).get(noteId, userId); if (!note) return; // An empty note has nothing worth going back to. if (!note.content_encrypted && !note.drawing_strokes) return; if (!force) { const last = db.prepare("SELECT created_at FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT 1").get(noteId); if (last && Date.now() - sqliteTimeToMs(last.created_at) < VERSION_COALESCE_MS) return; } const inserted = db.prepare(`INSERT INTO note_versions (note_id, user_id, title, content_encrypted, drawing_strokes, device_id, reason) VALUES (?, ?, ?, ?, ?, ?, ?)`) .run(noteId, userId, note.title, note.content_encrypted, note.drawing_strokes, deviceId, reason); /* The version inherits the note's attachments as they stand right now, which * is exactly what the body being snapshotted refers to — the snapshot is taken * before the incoming write is applied, and so is this. Without it, removing a * picture from a note would free the file while older versions still showed it. */ db.prepare( "INSERT OR IGNORE INTO version_attachments (version_id, attachment_id) SELECT ?, attachment_id FROM note_attachments WHERE note_id = ?" ).run(inserted.lastInsertRowid, noteId); pruneNoteVersions(noteId); } /* Ciphertext length tracks plaintext length closely enough to spot a write that * throws most of a note away, without decrypting to find out. */ function isBigShrink(previous, next) { const before = previous ? previous.length : 0; const after = next ? next.length : 0; return before > 200 && after < before * 0.5; } /* ─── Note routes ─── */ app.get("/api/notes", authMiddleware, (req, res) => { const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); res.json(notes); }); app.get("/api/notes/trash", authMiddleware, (req, res) => { const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId); res.json(notes); }); app.get("/api/notes/:id", authMiddleware, (req, res) => { const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); /* A failed decrypt used to be reported as empty content. The client then * treated the note as blank and the next edit overwrote the real text — a * read error turned into permanent data loss. Fail the request instead, so * the client keeps showing its local copy. */ if (note.content_encrypted) { try { note.content = decrypt(note.content_encrypted.toString()); } catch (err) { console.error(`[decrypt] note ${note.id} content unreadable:`, err.message); return res.status(500).json({ error: "Note content could not be decrypted", id: note.id }); } } delete note.content_encrypted; // Vector strokes are the source of truth for drawings. note.drawingStrokes = null; if (note.drawing_strokes) { try { note.drawingStrokes = JSON.parse(decrypt(note.drawing_strokes.toString())); } catch (err) { console.error(`[decrypt] note ${note.id} strokes unreadable:`, err.message); return res.status(500).json({ error: "Note drawing could not be decrypted", id: note.id }); } } delete note.drawing_strokes; // Legacy raster drawing — only for notes created before vector strokes existed. if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) { try { const raw = fs.readFileSync(note.drawing_data_path); const mime = detectImageMime(raw, note.drawing_data_path); note.drawingData = `data:${mime};base64,` + raw.toString("base64"); } catch { note.drawingData = null; } } else { note.drawingData = null; } res.json(note); }); /** * The user's own order for a notebook's notes. * * Deliberately not part of `PUT /api/notes/:id`. That route bumps `updated_at` * and snapshots a version — so dragging a note into place would move it to the * top of "last changed" and burn a slot of its history, for a change that is not * to the note at all. This writes `sort_order` and nothing else. */ const MAX_REORDER = 5000; app.post("/api/notes/reorder", authMiddleware, (req, res) => { const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null; if (!ids) return res.status(400).json({ error: "ids must be an array" }); const write = db.transaction(() => { const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?"); ids.forEach((id, index) => { if (typeof id === "string" && id) update.run(index, id, req.userId); }); }); write(); broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId); res.json({ success: true }); }); app.post("/api/notes", authMiddleware, (req, res) => { const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body; const id = uuid(); const encContent = content ? encrypt(content) : null; db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)") .run(req.userId, id, req.deviceId); broadcastToUser(req.userId, { type: "note_created", data: { id, type, title } }, req.deviceId); res.json({ id }); }); /* Replaces a note's outgoing links. Called whenever its body is saved. */ const MAX_LINKS_PER_NOTE = 500; const replaceNoteLinks = db.transaction((userId, sourceId, links) => { db.prepare("DELETE FROM note_links WHERE source_id = ?").run(sourceId); const insert = db.prepare( "INSERT OR IGNORE INTO note_links (source_id, target_id, target_kind, user_id) VALUES (?, ?, ?, ?)" ); for (const link of links.slice(0, MAX_LINKS_PER_NOTE)) { const targetId = typeof link === "string" ? link : link?.id; const kind = (typeof link === "object" && link?.kind) === "notebook" ? "notebook" : "note"; if (typeof targetId === "string" && targetId && targetId !== sourceId) { insert.run(sourceId, targetId, kind, userId); } } }); /* An upper bound on what one note may claim, so a malformed client cannot * write an unbounded number of rows. */ const MAX_ATTACHMENTS_PER_NOTE = 500; /** Records which attachments a note's body currently uses. */ const replaceNoteAttachments = db.transaction((noteId, ids) => { db.prepare("DELETE FROM note_attachments WHERE note_id = ?").run(noteId); db.prepare("UPDATE notes SET attachments_reported = 1 WHERE id = ?").run(noteId); const insert = db.prepare( "INSERT OR IGNORE INTO note_attachments (note_id, attachment_id) VALUES (?, ?)" ); for (const id of ids.slice(0, MAX_ATTACHMENTS_PER_NOTE)) { if (typeof id === "string" && id) insert.run(noteId, id); } }); app.put("/api/notes/:id", authMiddleware, (req, res) => { const { title, content, notebookId, starred, baseUpdatedAt, links, attachments } = req.body; const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); const conflict = isStaleWrite(note.updated_at, baseUpdatedAt); const encContent = content ? encrypt(content) : note.content_encrypted; // Keep the state we are about to replace. A conflicting write always keeps // one, whatever the coalescing window would otherwise say — that snapshot is // the only copy of the other device's work. if (content !== undefined || conflict) { const shrank = isBigShrink(note.content_encrypted, encContent); snapshotNote(req.userId, req.params.id, req.deviceId, { force: shrank || conflict, reason: conflict ? "conflict" : shrank ? "large-deletion" : "edit", }); if (conflict) { console.log(` ⚠️ conflicting write to note ${req.params.id} from ${req.deviceId} — previous state kept`); } } db.prepare(`UPDATE notes SET title = COALESCE(?, title), content_encrypted = COALESCE(?, content_encrypted), notebook_id = COALESCE(?, notebook_id), starred = COALESCE(?, starred), updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?`) .run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id); if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links); if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments); const fresh = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(req.params.id); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId); // `conflict` lets the client say so; `updatedAt` is what the next write bases itself on. res.json({ success: true, updatedAt: fresh.updated_at, conflict }); }); /* ─── Version history ─── */ app.get("/api/notes/:id/versions", authMiddleware, (req, res) => { const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); // Sizes only — sending every past body would be a large, mostly unread payload. const rows = db.prepare(` SELECT id, title, device_id, reason, created_at, LENGTH(content_encrypted) AS content_size, drawing_strokes IS NOT NULL AS has_drawing FROM note_versions WHERE note_id = ? ORDER BY id DESC `).all(req.params.id); res.json(rows.map((r) => ({ ...r, has_drawing: !!r.has_drawing }))); }); app.get("/api/notes/:id/versions/:versionId", authMiddleware, (req, res) => { const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?") .get(req.params.versionId, req.params.id, req.userId); if (!v) return res.status(404).json({ error: "Not found" }); const out = { id: v.id, title: v.title, created_at: v.created_at, device_id: v.device_id, reason: v.reason }; try { out.content = v.content_encrypted ? decrypt(v.content_encrypted.toString()) : ""; out.drawingStrokes = v.drawing_strokes ? JSON.parse(decrypt(v.drawing_strokes.toString())) : null; } catch (err) { console.error(`[decrypt] version ${v.id} unreadable:`, err.message); return res.status(500).json({ error: "This version could not be decrypted" }); } res.json(out); }); /* Restoring is itself an edit, so the state being replaced is snapshotted first * — restoring the wrong version is never a one-way door. */ app.post("/api/notes/:id/versions/:versionId/restore", authMiddleware, (req, res) => { const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?") .get(req.params.versionId, req.params.id, req.userId); if (!v) return res.status(404).json({ error: "Version not found" }); snapshotNote(req.userId, req.params.id, req.deviceId, { force: true, reason: "before-restore" }); db.prepare(`UPDATE notes SET content_encrypted = ?, drawing_strokes = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?`) .run(v.content_encrypted, v.drawing_strokes, req.deviceId, req.params.id); // Links live in the restored body, so the index has to follow it back. let links = []; try { if (v.content_encrypted) { const doc = JSON.parse(decrypt(v.content_encrypted.toString()))?.doc; const found = new Set(); (function walk(n) { if (!n) return; if (n.type === "noteLink" && n.attrs?.noteId) found.add(n.attrs.noteId); if (Array.isArray(n.content)) n.content.forEach(walk); })(doc); links = [...found]; } } catch { /* a v1 body has no links to recover */ } replaceNoteLinks(req.userId, req.params.id, links); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") .run(req.userId, req.params.id, JSON.stringify({ restored: v.id }), req.deviceId); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, restored: true } }, req.deviceId); res.json({ success: true, restoredFrom: v.created_at }); }); /* Notes that reference this one. Trashed sources are excluded — a backlink from * something in the bin is noise. */ app.get("/api/notes/:id/backlinks", authMiddleware, (req, res) => { const rows = db.prepare(` SELECT n.id, n.title, n.notebook_id, n.updated_at, nb.name AS notebook_name FROM note_links l JOIN notes n ON n.id = l.source_id LEFT JOIN notebooks nb ON nb.id = n.notebook_id WHERE l.target_id = ? AND l.user_id = ? AND n.deleted_at IS NULL ORDER BY n.updated_at DESC `).all(req.params.id, req.userId); res.json(rows); }); /* Every link in one round trip, so the client can render backlink counts in a * list without asking per note. */ app.get("/api/links", authMiddleware, (req, res) => { const rows = db.prepare(` SELECT l.source_id, l.target_id, l.target_kind FROM note_links l JOIN notes n ON n.id = l.source_id WHERE l.user_id = ? AND n.deleted_at IS NULL `).all(req.userId); res.json(rows); }); /* ─── Trash batch endpoint (MUST be before :id routes) ─── */ app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => { db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId); res.json({ success: true }); }); app.delete("/api/notes/:id", authMiddleware, (req, res) => { db.prepare("UPDATE notes SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); broadcastToUser(req.userId, { type: "note_deleted", data: { id: req.params.id } }, req.deviceId); res.json({ success: true }); }); /* ─── Trash endpoints ─── */ app.put("/api/notes/:id/restore", authMiddleware, (req, res) => { const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); db.prepare("UPDATE notes SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ?").run(req.params.id); broadcastToUser(req.userId, { type: "note_restored", data: { id: req.params.id } }, req.deviceId); res.json({ success: true }); }); app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => { db.prepare("DELETE FROM notes WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); res.json({ success: true }); }); /* ─── Drawing data route ─── */ const MAX_STROKES = 20000; /** * Counts the strokes in either shape of drawing document. * * A note's drawing is one page: `{ w, h, strokes }`. A PDF's is one entry per * page it has been drawn on: `{ pages: { "1": { w, h, strokes }, … } }`, because * every PDF page has its own size and a stroke belongs to the page it was drawn * on, not to a canvas shared by all of them. * * Returns null for anything that is neither, so the caller can refuse it. */ function countStrokes(doc) { if (!doc || typeof doc !== "object") return null; if (Array.isArray(doc.strokes)) return doc.strokes.length; if (doc.pages && typeof doc.pages === "object") { let n = 0; for (const page of Object.values(doc.pages)) { if (!page || !Array.isArray(page.strokes)) return null; n += page.strokes.length; } return n; } return null; } /* Notes pinned to spots in a PDF. They ride in the same overlay as the strokes * and are just as much the reader's work, so "is there anything here" has to ask * about both — storing an overlay that holds only pinned notes as null threw * every one of them away. */ const MAX_PINNED_NOTES = 500; const MAX_PINNED_LENGTH = 4000; function countPinnedNotes(doc) { if (!doc || typeof doc !== "object" || !doc.pages || typeof doc.pages !== "object") return 0; let n = 0; for (const page of Object.values(doc.pages)) { if (!page || page.notes === undefined) continue; if (!Array.isArray(page.notes)) return null; for (const pin of page.notes) { if (!pin || typeof pin.id !== "string" || !Number.isFinite(pin.x) || !Number.isFinite(pin.y)) { return null; } if (typeof pin.text === "string" && pin.text.length > MAX_PINNED_LENGTH) return null; } n += page.notes.length; } return n; } app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { const { drawingStrokes, drawingData } = req.body; const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); if (drawingStrokes !== undefined) { // Vector strokes in the note's canonical coordinate space — the current format. let count = 0; let pinned = 0; if (drawingStrokes !== null) { count = countStrokes(drawingStrokes); if (count === null) return res.status(400).json({ error: "Invalid strokes format" }); if (count > MAX_STROKES) return res.status(413).json({ error: "Too many strokes" }); pinned = countPinnedNotes(drawingStrokes); if (pinned === null) return res.status(400).json({ error: "Invalid pinned notes format" }); if (pinned > MAX_PINNED_NOTES) return res.status(413).json({ error: "Too many pinned notes" }); } // A page carrying only pinned notes is not an empty overlay. const empty = !drawingStrokes || (count === 0 && pinned === 0); const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id); // Clearing a drawing is always worth being able to undo. snapshotNote(req.userId, req.params.id, req.deviceId, { force: empty && !!previous?.drawing_strokes, reason: empty ? "drawing-cleared" : "drawing", }); // A note's legacy raster is left untouched — it renders underneath the strokes // on the client, so migrating an old drawing never loses anything. db.prepare("UPDATE notes SET drawing_strokes = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") .run(empty ? null : Buffer.from(encrypt(JSON.stringify(drawingStrokes))), req.deviceId, req.params.id); } else { // Legacy raster path, kept so older clients keep working. if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingStrokes or drawingData required" }); const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); if (!m) return res.status(400).json({ error: "Invalid drawing format" }); const mime = m[1]; const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; const base64Data = m[2]; const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`); // Remove older file variants from previous format writes. for (const oldExt of ["png", "jpg", "jpeg", "webp"]) { const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`); if (oldPath !== filePath && fs.existsSync(oldPath)) { try { fs.unlinkSync(oldPath); } catch {} } } fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") .run(filePath, req.deviceId, req.params.id); } db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") .run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId); res.json({ success: true }); }); /* ─── Attachments ─── * * Images used to be inlined into the note body as base64 data URIs: a few * screenshots turned a note into megabytes that were re-encrypted and re-sent on * every keystroke. They are now separate blobs the body only references. * * Files are named by SHA-256 of their bytes, so the same image pasted into * several notes is stored once. */ const ATTACHMENT_DIR = path.join(UPLOAD_DIR, "attachments"); if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: true }); const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]); /** * Encrypts any attachment still lying on disk in the clear. * * Runs at every start and is idempotent: the header says whether a file has * already been done, so an interrupted sweep simply resumes. Each file is * written through a temporary name and renamed, so a crash cannot leave one * half-encrypted — the state on disk is always either fully one or the other. */ (function encryptExistingAttachments() { let files; try { files = fs.readdirSync(ATTACHMENT_DIR); } catch { return; } let done = 0; let failed = 0; for (const name of files) { if (name.includes(".tmp-")) continue; // leftover from an interrupted run const filePath = path.join(ATTACHMENT_DIR, name); try { if (!fs.statSync(filePath).isFile()) continue; const buf = fs.readFileSync(filePath); if (isEncryptedFile(buf)) continue; writeFileAtomic(filePath, encryptBuffer(buf)); done++; } catch (err) { console.error(` ⚠️ could not encrypt attachment ${name}: ${err.message}`); failed++; } } if (done) console.log(` 🔒 encrypted ${done} attachment file(s) that were stored in the clear`); if (failed) console.error(` ⚠️ ${failed} attachment file(s) could not be encrypted — they still read fine`); })(); app.post("/api/attachments", authMiddleware, (req, res) => { const { data, filename } = req.body || {}; if (typeof data !== "string") return res.status(400).json({ error: "data required" }); const m = data.match(/^data:([a-z0-9.+/-]+);base64,(.+)$/i); if (!m) return res.status(400).json({ error: "Expected a base64 data URL" }); const mime = m[1].toLowerCase(); if (!ALLOWED_ATTACHMENT_MIME.has(mime)) return res.status(415).json({ error: `Unsupported type: ${mime}` }); const buf = Buffer.from(m[2], "base64"); if (buf.length === 0) return res.status(400).json({ error: "Empty file" }); if (buf.length > MAX_IMAGE_BYTES) return res.status(413).json({ error: `Image is larger than ${Math.round(MAX_IMAGE_BYTES / 1048576)} MB` }); // Trust the bytes, not the declared type. const sniffed = detectImageMime(buf); if (mime !== "image/gif" && mime !== "image/avif" && sniffed !== mime) { return res.status(415).json({ error: "File content does not match its type" }); } const sha256 = crypto.createHash("sha256").update(buf).digest("hex"); const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256); if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true }); // Named by the hash of the plaintext, so identical files still land on one // copy — encryption uses a fresh IV each time and would otherwise defeat that. const filePath = path.join(ATTACHMENT_DIR, sha256); if (!fs.existsSync(filePath)) writeFileAtomic(filePath, encryptBuffer(buf)); const id = uuid(); db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)") .run(id, req.userId, sha256, mime, buf.length, typeof filename === "string" ? filename.slice(0, 200) : null); res.json({ id, mime, size: buf.length, deduplicated: false }); }); /** * Takes a PDF and stores it like any other attachment: encrypted, named by the * hash of its contents, shared between notes that hold the same file. * * Raw body rather than the base64 JSON the image route uses — a scanned book * runs to tens of megabytes, and base64 would add a third to that for no gain * on a path that carries nothing but the file itself. */ app.post( "/api/attachments/pdf", authMiddleware, express.raw({ type: ["application/pdf", "application/octet-stream"], limit: MAX_PDF_BYTES }), (req, res) => { const buf = req.body; if (!Buffer.isBuffer(buf) || buf.length === 0) return res.status(400).json({ error: "No file" }); // Trust the bytes, not the header. Every PDF starts with %PDF-. if (buf.subarray(0, 5).toString("ascii") !== "%PDF-") { return res.status(415).json({ error: "That does not look like a PDF" }); } const filename = typeof req.query.filename === "string" ? req.query.filename.slice(0, 200) : null; const sha256 = crypto.createHash("sha256").update(buf).digest("hex"); const existing = db .prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?") .get(req.userId, sha256); if (existing) { return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true }); } const filePath = path.join(ATTACHMENT_DIR, sha256); if (!fs.existsSync(filePath)) writeFileAtomic(filePath, encryptBuffer(buf)); const id = uuid(); db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)") .run(id, req.userId, sha256, "application/pdf", buf.length, filename); res.json({ id, mime: "application/pdf", size: buf.length, deduplicated: false }); } ); app.get("/api/attachments/:id", authMiddleware, (req, res) => { const a = db.prepare("SELECT * FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!a) return res.status(404).json({ error: "Not found" }); // The hash comes from our own database, never from the request, so this path // cannot be steered outside the attachment directory. const filePath = path.join(ATTACHMENT_DIR, a.sha256); if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" }); let bytes; try { // Read whole rather than streamed: GCM only proves the file is intact once // the last block is in, and half a corrupt image is worse than an error. bytes = decryptBuffer(fs.readFileSync(filePath)); } catch (err) { console.error(` ❌ could not decrypt attachment ${a.id} (${a.sha256}): ${err.message}`); return res.status(500).json({ error: "File could not be read" }); } res.setHeader("Content-Type", a.mime); res.setHeader("Content-Length", bytes.length); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes res.setHeader("X-Content-Type-Options", "nosniff"); res.end(bytes); }); /* Two routes used to sit here and were removed on 2026-08-08: * * POST /api/notes/pdf accepted a PDF and stored it, but nothing could ever * read it back — there was no retrieval route and no * viewer, so uploads simply accumulated unreachable. * POST /api/sync a batch sync endpoint the client never called; it * syncs through the individual routes instead. * * Both are in the git history if a PDF viewer is ever built for real. */ /* ─── WebSocket for real-time sync ─── */ const server = http.createServer(app); const wss = new WebSocketServer({ server, path: "/ws" }); const userConnections = new Map(); // userId -> Set const AUTH_GRACE_MS = 10000; const HEARTBEAT_MS = 30000; wss.on("connection", (ws) => { ws._userId = null; ws._alive = true; ws.on("pong", () => { ws._alive = true; }); // Drop sockets that connect and never authenticate. const authTimer = setTimeout(() => { if (!ws._userId) ws.close(4001, "auth timeout"); }, AUTH_GRACE_MS); ws.on("message", (data) => { try { const msg = JSON.parse(data); if (msg.type !== "auth") return; const payload = jwt.verify(msg.token, JWT_SECRET); ws._userId = payload.userId; ws._deviceId = msg.deviceId || "unknown"; clearTimeout(authTimer); // A Set, not a Map keyed by device: two tabs on one machine share a // device id, and keying by it meant the second tab silently replaced the // first and the first stopped receiving updates. if (!userConnections.has(ws._userId)) userConnections.set(ws._userId, new Set()); userConnections.get(ws._userId).add(ws); ws.send(JSON.stringify({ type: "auth_ok" })); } catch { try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {} } }); ws.on("close", () => { clearTimeout(authTimer); const set = ws._userId && userConnections.get(ws._userId); if (!set) return; set.delete(ws); if (set.size === 0) userConnections.delete(ws._userId); }); ws.on("error", () => { try { ws.terminate(); } catch {} }); }); // Reap sockets that stopped answering — otherwise half-open connections from // sleeping phones accumulate forever. const heartbeat = setInterval(() => { for (const ws of wss.clients) { if (!ws._alive) { ws.terminate(); continue; } ws._alive = false; try { ws.ping(); } catch {} } }, HEARTBEAT_MS); heartbeat.unref(); function broadcastToUser(userId, message, excludeDeviceId) { const conns = userConnections.get(userId); if (!conns) return; const payload = JSON.stringify(message); for (const ws of conns) { if (ws._deviceId !== excludeDeviceId && ws.readyState === 1) { try { ws.send(payload); } catch {} } } } /* ─── Health check ─── */ app.get("/api/health", (req, res) => { res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() }); }); /* ─── Error handling ─── */ app.use((req, res) => res.status(404).json({ error: "Not found" })); // Without this, a throw inside a route hangs the request until the client times // out, and the stack trace goes to the client in Express' default handler. app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars console.error("[error]", req.method, req.path, err); if (res.headersSent) return; if (err?.type === "entity.too.large") return res.status(413).json({ error: "Payload too large" }); res.status(500).json({ error: "Internal server error" }); }); process.on("unhandledRejection", (err) => console.error("[unhandledRejection]", err)); process.on("uncaughtException", (err) => console.error("[uncaughtException]", err)); /* ─── Housekeeping ─── */ // sync_log is append-only and grew without bound; every note edit added a row. const SYNC_LOG_RETENTION_DAYS = 30; function pruneSyncLog() { try { const r = db.prepare(`DELETE FROM sync_log WHERE timestamp < datetime('now', '-${SYNC_LOG_RETENTION_DAYS} days')`).run(); if (r.changes) console.log(` 🧹 pruned ${r.changes} sync_log rows older than ${SYNC_LOG_RETENTION_DAYS} days`); } catch (err) { console.error("[prune] failed:", err.message); } } /* Off unless VERSION_RETENTION_DAYS is set — the per-note count is what bounds * growth, and see the note on that constant for why age is a poor second rule. */ function pruneOldVersions() { if (VERSION_RETENTION_DAYS <= 0) return; try { const r = db.prepare(`DELETE FROM note_versions WHERE created_at < datetime('now', '-${VERSION_RETENTION_DAYS} days')`).run(); if (r.changes) console.log(` 🧹 pruned ${r.changes} note versions older than ${VERSION_RETENTION_DAYS} days`); } catch (err) { console.error("[prune] failed:", err.message); } } /* Attachment rows cascade away with their owner, but the files they point at do * not. The database is authoritative: a blob nobody has a row for is garbage. * * Only files that have sat untouched for an hour are considered, so an upload * in flight — written to disk just before its row is inserted — is never hit. */ const ORPHAN_GRACE_MS = 60 * 60 * 1000; /** * Deletes attachments nothing refers to any more. * * An attachment is free once no current note body and no kept version mentions * it. Both halves matter: a picture taken out of a note is still in the versions * from before it was removed, and freeing it then would leave a hole that * restoring cannot fill. * * So a deleted image does not vanish at once. It goes when the last version * holding it is pushed out by newer ones — which is the point at which nothing * can ask for it back. * * Rows are only considered once the note has actually reported its attachments; * a client that has never sent the list would otherwise look like a note using * nothing at all. */ function pruneUnreferencedAttachments(graceMs = ORPHAN_GRACE_MS) { try { const rows = db .prepare( `SELECT a.id, a.sha256 FROM attachments a WHERE a.created_at < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM note_attachments na WHERE na.attachment_id = a.id) AND NOT EXISTS (SELECT 1 FROM version_attachments va WHERE va.attachment_id = a.id) -- Nothing is released while any of this user's notes has yet to say -- what it uses: silence there is not the same as "uses nothing". AND NOT EXISTS ( SELECT 1 FROM notes n WHERE n.user_id = a.user_id AND n.deleted_at IS NULL AND n.attachments_reported = 0 )` ) .all(`-${Math.round(graceMs / 1000)} seconds`); if (rows.length === 0) return; const del = db.prepare("DELETE FROM attachments WHERE id = ?"); for (const row of rows) del.run(row.id); console.log(` 🧹 released ${rows.length} attachment(s) no note or version refers to`); // The files behind them go in the sweep below, which only removes blobs no // row points at — including copies still shared with another user's row. } catch (err) { console.error("[prune] attachment references failed:", err.message); } } function pruneOrphanAttachments(graceMs = ORPHAN_GRACE_MS) { try { if (!fs.existsSync(ATTACHMENT_DIR)) return; const referenced = new Set(db.prepare("SELECT sha256 FROM attachments").all().map((r) => r.sha256)); let removed = 0; for (const name of fs.readdirSync(ATTACHMENT_DIR)) { if (referenced.has(name)) continue; const full = path.join(ATTACHMENT_DIR, name); try { if (Date.now() - fs.statSync(full).mtimeMs < graceMs) continue; fs.unlinkSync(full); removed++; } catch { /* leave anything we cannot inspect alone */ } } if (removed) console.log(` 🧹 removed ${removed} orphaned attachment file(s)`); } catch (err) { console.error("[prune] attachments failed:", err.message); } } const housekeeping = () => { pruneSyncLog(); pruneOldVersions(); pruneUnreferencedAttachments(); pruneOrphanAttachments(); }; housekeeping(); setInterval(housekeeping, 24 * 60 * 60 * 1000).unref(); /* ─── Start server ─── */ server.listen(PORT, () => { console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`); console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`); console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`); console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`); }); /* ─── Graceful shutdown ─── */ let shuttingDown = false; function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log(`\n ${signal} received — shutting down...`); clearInterval(heartbeat); for (const ws of wss.clients) { try { ws.close(1001, "server shutting down"); } catch {} } server.close(() => { try { db.close(); } catch (err) { console.error("[shutdown] db.close:", err.message); } console.log(" ✅ closed cleanly\n"); process.exit(0); }); // Don't hang forever on a stuck connection. setTimeout(() => { try { db.close(); } catch {} process.exit(1); }, 10000).unref(); } process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT"));