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 <noreply@anthropic.com>
1331 lines
58 KiB
JavaScript
1331 lines
58 KiB
JavaScript
/*
|
|
* 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 multer
|
|
*
|
|
* 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 multer = require("multer");
|
|
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");
|
|
|
|
// 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 <INKNOTE_DATA_DIR>/{db,uploads}\n` +
|
|
` chown -R ${uid}:${gid} <INKNOTE_DATA_DIR>\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,
|
|
tags TEXT DEFAULT '[]',
|
|
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,
|
|
vector_clock TEXT DEFAULT '{}'
|
|
);
|
|
|
|
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.
|
|
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_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");
|
|
}
|
|
|
|
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")}`;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
/* ─────────── Vector clock conflict resolution ─────────── */
|
|
function mergeVectorClocks(clockA, clockB) {
|
|
const merged = { ...clockA };
|
|
for (const [device, time] of Object.entries(clockB)) {
|
|
merged[device] = Math.max(merged[device] || 0, time);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function isClockNewer(clockA, clockB) {
|
|
// Returns true if clockA is strictly newer than clockB
|
|
let aHasNewer = false;
|
|
for (const [device, time] of Object.entries(clockA)) {
|
|
if (time > (clockB[device] || 0)) aHasNewer = true;
|
|
}
|
|
for (const [device, time] of Object.entries(clockB)) {
|
|
if (time > (clockA[device] || 0)) return false; // conflict or older
|
|
}
|
|
return aHasNewer;
|
|
}
|
|
|
|
/* ─────────── 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 }));
|
|
app.use(express.json({ limit: "25mb" }));
|
|
|
|
/* 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);
|
|
|
|
const upload = multer({
|
|
dest: UPLOAD_DIR,
|
|
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
|
fileFilter: (req, file, cb) => {
|
|
if (file.mimetype === "application/pdf") cb(null, true);
|
|
else cb(new Error("Only PDF files allowed"));
|
|
},
|
|
});
|
|
|
|
/* ─── 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);
|
|
}
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
|
|
const { name, coverIndex, sortOrder } = 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" });
|
|
|
|
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);
|
|
|
|
broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
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);
|
|
const VERSION_RETENTION_DAYS = num("VERSION_RETENTION_DAYS", 90);
|
|
|
|
const sqliteTimeToMs = (t) => (t ? Date.parse(`${String(t).replace(" ", "T")}Z`) : 0);
|
|
|
|
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, MAX_VERSIONS_PER_NOTE);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
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, tags, sort_order, created_at, updated_at, version, vector_clock 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, tags, 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);
|
|
});
|
|
|
|
app.post("/api/notes", authMiddleware, (req, res) => {
|
|
const { type, title, content, notebookId, starred, tags } = req.body;
|
|
const id = uuid();
|
|
const encContent = content ? encrypt(content) : null;
|
|
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
|
|
|
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, tags, device_id, vector_clock)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, JSON.stringify(tags || []), req.deviceId, clock);
|
|
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
|
const { title, content, notebookId, starred, tags, vectorClock, links } = 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" });
|
|
|
|
// Conflict detection
|
|
const serverClock = JSON.parse(note.vector_clock || "{}");
|
|
const clientClock = vectorClock ? JSON.parse(vectorClock) : {};
|
|
|
|
if (Object.keys(clientClock).length > 0 && !isClockNewer(clientClock, serverClock)) {
|
|
// Conflict! Return both versions
|
|
return res.status(409).json({
|
|
error: "Conflict detected",
|
|
serverVersion: { ...note, vectorClock: serverClock },
|
|
clientVersion: req.body,
|
|
mergedClock: mergeVectorClocks(serverClock, clientClock),
|
|
});
|
|
}
|
|
|
|
const encContent = content ? encrypt(content) : note.content_encrypted;
|
|
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
|
|
|
|
// Keep the state we are about to replace.
|
|
if (content !== undefined) {
|
|
const shrank = isBigShrink(note.content_encrypted, encContent);
|
|
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
|
force: shrank,
|
|
reason: shrank ? "large-deletion" : "edit",
|
|
});
|
|
}
|
|
|
|
db.prepare(`UPDATE notes SET
|
|
title = COALESCE(?, title),
|
|
content_encrypted = COALESCE(?, content_encrypted),
|
|
notebook_id = COALESCE(?, notebook_id),
|
|
starred = COALESCE(?, starred),
|
|
tags = COALESCE(?, tags),
|
|
updated_at = datetime('now'),
|
|
version = version + 1,
|
|
vector_clock = ?,
|
|
device_id = ?
|
|
WHERE id = ?`)
|
|
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
|
|
|
|
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
|
|
|
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
|
|
res.json({ success: true, vectorClock: newClock });
|
|
});
|
|
|
|
/* ─── 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;
|
|
|
|
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.
|
|
if (drawingStrokes !== null) {
|
|
if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
|
|
return res.status(400).json({ error: "Invalid strokes format" });
|
|
if (drawingStrokes.strokes.length > MAX_STROKES)
|
|
return res.status(413).json({ error: "Too many strokes" });
|
|
}
|
|
const empty = !drawingStrokes || drawingStrokes.strokes.length === 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"]);
|
|
const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
|
|
|
|
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_ATTACHMENT_BYTES) return res.status(413).json({ error: "File too large" });
|
|
|
|
// 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 });
|
|
|
|
const filePath = path.join(ATTACHMENT_DIR, sha256);
|
|
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, 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 });
|
|
});
|
|
|
|
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" });
|
|
|
|
res.setHeader("Content-Type", a.mime);
|
|
res.setHeader("Content-Length", a.size);
|
|
res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
fs.createReadStream(filePath).pipe(res);
|
|
});
|
|
|
|
/* ─── PDF upload route ─── */
|
|
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
|
|
|
const id = uuid();
|
|
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
|
|
|
db.prepare(`INSERT INTO notes (id, user_id, type, title, pdf_path, device_id, vector_clock)
|
|
VALUES (?, ?, 'pdf', ?, ?, ?, ?)`)
|
|
.run(id, req.userId, req.file.originalname, req.file.path, req.deviceId, clock);
|
|
|
|
res.json({ id, title: req.file.originalname });
|
|
});
|
|
|
|
/* ─── Sync endpoint ─── */
|
|
app.post("/api/sync", authMiddleware, (req, res) => {
|
|
const { lastSyncTimestamp, deviceId, changes } = req.body;
|
|
const results = { pushed: [], pulled: [], conflicts: [] };
|
|
|
|
// Apply client changes
|
|
if (changes && changes.length > 0) {
|
|
for (const change of changes) {
|
|
try {
|
|
if (change.entityType === "note") {
|
|
if (change.action === "create") {
|
|
const encContent = change.data.content ? encrypt(change.data.content) : null;
|
|
db.prepare(`INSERT OR REPLACE INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id, vector_clock, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
.run(change.data.id, req.userId, change.data.notebookId, change.data.type, change.data.title, encContent, change.data.starred ? 1 : 0, deviceId, JSON.stringify(change.data.vectorClock || {}));
|
|
results.pushed.push({ id: change.data.id, status: "ok" });
|
|
} else if (change.action === "update") {
|
|
const existing = db.prepare("SELECT vector_clock FROM notes WHERE id = ? AND user_id = ?").get(change.data.id, req.userId);
|
|
if (existing) {
|
|
const serverClock = JSON.parse(existing.vector_clock || "{}");
|
|
const clientClock = change.data.vectorClock || {};
|
|
if (!isClockNewer(clientClock, serverClock)) {
|
|
results.conflicts.push({ id: change.data.id, serverClock, clientClock });
|
|
continue;
|
|
}
|
|
}
|
|
results.pushed.push({ id: change.data.id, status: "ok" });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
results.pushed.push({ id: change.data?.id, status: "error", error: err.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get server changes since last sync
|
|
const serverChanges = db.prepare(`
|
|
SELECT s.*, n.title, n.type, n.updated_at
|
|
FROM sync_log s
|
|
LEFT JOIN notes n ON s.entity_id = n.id
|
|
WHERE s.user_id = ? AND s.timestamp > COALESCE(?, '1970-01-01') AND s.device_id != ?
|
|
ORDER BY s.timestamp
|
|
`).all(req.userId, lastSyncTimestamp, deviceId);
|
|
|
|
results.pulled = serverChanges;
|
|
results.syncTimestamp = new Date().toISOString();
|
|
|
|
// Update device last sync
|
|
db.prepare("INSERT OR REPLACE INTO devices (id, user_id, last_sync) VALUES (?, ?, datetime('now'))").run(deviceId, req.userId);
|
|
|
|
res.json(results);
|
|
});
|
|
|
|
/* ─── WebSocket for real-time sync ─── */
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
const userConnections = new Map(); // userId -> Set<ws>
|
|
|
|
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); }
|
|
}
|
|
|
|
/* Per-note count is capped on write; this only clears out versions of notes
|
|
* that stopped being edited long ago. */
|
|
function pruneOldVersions() {
|
|
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;
|
|
function pruneOrphanAttachments() {
|
|
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 < ORPHAN_GRACE_MS) 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(); 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"));
|