Files
InkNote/backend/server.js
T
marcandClaude Opus 5 7c1bbc8176 refactor(honeybar): HONEYBAR_ORIGIN statt INKNOTE_HONEYBAR_ORIGIN
Dieselben Namen wie in Aether. Die Leiste gehoert keiner App, sie ist ueberall
dieselbe Sache — dann soll sie auch ueberall gleich heissen. Ein zweiter Name
fuer dieselbe Einstellung ist eine Fehlerquelle beim Umziehen und beim Suchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:00:15 +02:00

2416 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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");
const oidc = require("./oidc");
/* ─────────── 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 <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,
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. */
/** What a client is allowed to know about a user. */
const publicUser = (row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
isAdmin: !!row.is_admin,
preferences: readPreferences(row.preferences),
/* Two different things, and the interface needs both.
*
* `linkedToProvider` — this account has an identity at the provider and can
* sign in through it. `signsInWithProvider` — it has *no password here at
* all*, which is true of accounts the provider created and not of an existing
* account that was later linked. The first decides whether to say so; the
* second decides whether there is a password to offer changing. */
linkedToProvider: !!row.oidc_sub,
signsInWithProvider: isProviderOnly(row),
/* Whether there is a picture to ask for. The bytes are fetched separately —
* this only decides whether anything asks at all, so a row without one draws
* its initial instead of firing a request that answers 404. */
hasAvatar: !!hasAvatar.get(row.id),
});
/**
* The account as it is handed to whoever just signed in as it.
*
* Carries one thing that is not about the account at all: how many versions a
* notebook keeps when it names no number of its own. It is sent rather than
* written into the interface because it is configurable —
* `MAX_VERSIONS_PER_NOTE` — and a dialog that said "50" would quietly start
* lying the day that is changed. It rides here because this is the one payload
* every client already fetches, on login and on restoring a session.
*
* `publicUser` stays as it is: the admin list shows *other* people's accounts,
* and a server-wide setting has no business being repeated on each row.
*/
const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
/* Interface settings the client may store. Anything else is dropped rather than
* kept: this column is written by the browser, so it is an input like any other,
* and a fixed shape is what stops it becoming a place to put arbitrary data. */
const NOTE_FONTS = ["serif", "sans", "mono"];
/* How a chapter opens: as a further column beside the notes, the way OneNote
* lays out its pages, or unfolded in place under its own row. A phone has room
* for neither, and falls back to filling the screen with a way back. */
const CHAPTER_VIEWS = ["column", "inline"];
/* Light or dark. On the account rather than in the browser, like the typeface —
* it is a choice about how your own notes look, and making it again on every
* device is making it badly. The browser keeps a copy of its own so a reload
* does not flash the other one while the account is being fetched. */
const THEMES = ["dark", "light"];
function readPreferences(raw) {
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = null;
}
const font = parsed?.noteFont;
const view = parsed?.chapterView;
const theme = parsed?.theme;
return {
noteFont: NOTE_FONTS.includes(font) ? font : "serif",
chapterView: CHAPTER_VIEWS.includes(view) ? view : "column",
theme: THEMES.includes(theme) ? theme : "dark",
};
}
// 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");
}
/* Per-account interface settings, as JSON.
*
* On the account rather than in the browser's storage: a choice about how your
* own notes are set should follow you to the phone, not be made again on every
* device. Only presentation lives here — nothing the server acts on. */
if (!tableColumns("users").includes("preferences")) {
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT");
}
/* The text of an imported PDF, extracted by whichever device imported it.
*
* The server never opens a PDF. It has no PDF library and no way to read one:
* the bytes arrive encrypted and are stored that way, and this column arrives
* the same — already extracted, encrypted at rest like everything else, and
* opaque here.
*
* It exists so a PDF is findable on a device that has never opened it. The text
* used to be extracted on first open and kept in that browser's IndexedDB, which
* meant a document imported on the desktop was invisible to a search on the
* phone until the phone had opened it. Doing the work once, where the file
* already is, and letting the result sync is what makes an archive of scanned
* invoices searchable from anywhere.
*
* On `attachments` rather than in a table of its own: it is a property of the
* file, it is deduplicated with the file, and it is freed with the file. */
/**
* Chapters — folders inside a notebook.
*
* A note sits in exactly one chapter, or in none, which is the notebook itself.
* `ON DELETE SET NULL` is the whole design in one clause: removing a chapter
* must never take notes with it. They come back to the notebook, where they can
* be seen and filed again.
*
* Separate from notebooks rather than a self-reference on them: a notebook has a
* cover, a sort order and separators, none of which a chapter has or wants.
*/
db.exec(`
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
device_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
`);
/* A mark of its own, so a row can be found by its shape rather than by reading
* every title. One short string — an emoji is one or two code points, and
* anything longer than a handful is not a mark. */
if (!tableColumns("notes").includes("icon")) {
db.exec("ALTER TABLE notes ADD COLUMN icon TEXT");
}
if (!tableColumns("chapters").includes("icon")) {
db.exec("ALTER TABLE chapters ADD COLUMN icon TEXT");
}
if (!tableColumns("notes").includes("icon_color")) {
db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
}
/* A profile picture, kept out of the users table on purpose: every query that
* reads a user does `SELECT *`, and a blob in that row would be carried through
* the admin list, every sign-in and every `/auth/me` for the sake of something
* shown in one corner. Encrypted at rest like every other file here — it comes
* from outside and is a picture of a person. */
db.exec(`
CREATE TABLE IF NOT EXISTS user_avatars (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
bytes BLOB NOT NULL,
content_type TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
`);
/* Which identity at the provider this account is, once it has signed in that
* way. Kept on the account rather than in a table of its own: a person has one
* provider here, and the column is what makes the link survive a rename — the
* subject is the provider's permanent name for someone, the username is not.
* Unique where it is set, so two accounts can never claim the same identity. */
if (!tableColumns("users").includes("oidc_sub")) {
db.exec("ALTER TABLE users ADD COLUMN oidc_sub TEXT");
}
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL");
/* Added after chapters shipped, so an existing table needs it too. */
if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
}
/* A chapter orders its own notes.
*
* Its own column rather than the notebook's setting, because a chapter is a
* place someone made and filled: a reference chapter can stay alphabetical
* while the notebook around it stays in the order it was arranged. Null means
* the hand-made order, which is what a chapter is in until it is told otherwise. */
if (!tableColumns("chapters").includes("sort_mode")) {
db.exec("ALTER TABLE chapters ADD COLUMN sort_mode TEXT");
}
if (!tableColumns("notes").includes("chapter_id")) {
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
}
/* The pattern laid over a notebook's cover, chosen apart from its colour.
* Its own column rather than more values in `cover_index`: they are two
* decisions, and folding them into one number would mean renumbering every
* cover the day a pattern is added. */
if (!tableColumns("notebooks").includes("cover_pattern")) {
db.exec("ALTER TABLE notebooks ADD COLUMN cover_pattern INTEGER NOT NULL DEFAULT 0");
}
if (!tableColumns("attachments").includes("text_index")) {
db.exec("ALTER TABLE attachments ADD COLUMN text_index BLOB");
}
/**
* 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();
const hasAvatar = db.prepare("SELECT 1 FROM user_avatars WHERE user_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 —
// and an account that has no password of its own is compared against the
// dummy too, so it is exactly as slow and says exactly as little.
const usable = user && !isProviderOnly(user);
const ok = await bcrypt.compare(String(password ?? ""), usable ? user.password_hash : DUMMY_HASH);
if (!usable || !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: signedInUser(user) });
} catch (err) { next(err); }
});
/* ─── Signing in through an identity provider ─── */
/**
* An account with no password of its own.
*
* Made by the provider, so there is nothing to compare against. The column is
* NOT NULL and bcrypt would happily compare against nonsense forever, so the
* marker is explicit rather than "some string that cannot match": a guard that
* can be read is worth more than one that merely happens to hold.
*/
const PROVIDER_ONLY = "!oidc:";
const isProviderOnly = (user) => String(user?.password_hash || "").startsWith(PROVIDER_ONLY);
/** A username this instance will accept, built from what the provider says. */
function usernameFromIdentity(identity) {
const raw = String(identity.username || (identity.email || "").split("@")[0] || identity.displayName || "user");
// The provider's rules are not this one's: anything outside the alphabet
// becomes a dash, and what is left is padded rather than rejected, because a
// name is a handle here and the identity is the subject.
let base = raw.trim().replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
while (base.length < 3) base += "0";
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(base)) return base;
// Taken by somebody else. Number it rather than fail: two people called "marc"
// at the provider is not an error, it is Tuesday.
for (let n = 2; n < 1000; n++) {
const candidate = `${base.slice(0, 28)}-${n}`;
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(candidate)) return candidate;
}
throw new Error("could not find a free username");
}
/**
* Which account an arriving identity is.
*
* Three cases, in this order: known subject, an account of the same name that
* has not been claimed yet, or nobody — in which case one is made. Binding by
* name happens once and only for an unclaimed account; from then on the subject
* is the link, so renaming either side changes nothing.
*
* Administrator rights are never taken from the provider. A group over there
* would otherwise be a way to grant them here, and in this app an administrator
* cannot even appoint another one.
*/
function accountForIdentity(identity) {
const known = db.prepare("SELECT * FROM users WHERE oidc_sub = ?").get(identity.sub);
if (known) return known;
const wanted = normalizeUsername(identity.username) || normalizeUsername(String(identity.email || "").split("@")[0]);
const sameName = wanted ? db.prepare("SELECT * FROM users WHERE username = ?").get(wanted) : null;
if (sameName) {
// Already somebody else's identity: refuse rather than move the link, or a
// renamed account at the provider could take over an account here.
if (sameName.oidc_sub && sameName.oidc_sub !== identity.sub) {
const err = new Error("account_taken");
err.code = "account_taken";
throw err;
}
db.prepare("UPDATE users SET oidc_sub = ?, updated_at = datetime('now') WHERE id = ?").run(identity.sub, sameName.id);
console.log(` 🔗 linked account "${sameName.username}" to its identity at the provider`);
return { ...sameName, oidc_sub: identity.sub };
}
const id = uuid();
const username = usernameFromIdentity(identity);
db.prepare(
"INSERT INTO users (id, username, password_hash, display_name, is_admin, oidc_sub) VALUES (?, ?, ?, ?, 0, ?)"
).run(id, username, PROVIDER_ONLY + crypto.randomBytes(24).toString("hex"), identity.displayName || username, identity.sub);
console.log(` ✅ created account "${username}" for a new identity at the provider`);
return db.prepare("SELECT * FROM users WHERE id = ?").get(id);
}
/* What the sign-in screen may know before anyone has signed in: whether there is
* a second way in, and what to call it. Deliberately says nothing else — no
* issuer, no client id, nothing that describes the provider to a stranger. */
app.get("/api/auth/methods", (req, res) => {
res.json({
password: true,
oidc: oidc.enabled ? { enabled: true, label: oidc.label } : { enabled: false },
});
});
/* Where the HoneyOS bar lives, if it is wanted at all.
*
* Served at runtime rather than baked into the build for two reasons. The image
* is pulled ready-made, so a value fixed at build time could never be changed
* from the .env afterwards. And a service worker caches whole responses,
* headers included — anything frozen into index.html stays frozen in the
* browser long after the server has moved on.
*
* The key is only handed out together with an origin: without a bar it has
* nothing to unlock, and it should not sit in the page for no reason. It buys
* the list of apps and nothing else. */
app.get("/api/public/config", (req, res) => {
const origin = String(process.env.HONEYBAR_ORIGIN || "").replace(/\/+$/, "");
res.json({
honeybar: origin
? { origin, key: String(process.env.HONEYBAR_KEY || "") }
: null,
});
});
/* The browser is sent to the provider from here rather than from the app, so
* the app never has to know the issuer, the scopes or the redirect. */
app.get("/api/auth/oidc/start", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
try {
res.redirect(await oidc.beginLogin());
} catch (err) {
console.warn("[oidc] start failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "start_failed" }));
}
});
/** Home again, whatever happened — the app is the only place to report to. */
const backToApp = (params) => `${oidc.appUrl || ""}/?${new URLSearchParams(params)}`;
app.get("/api/auth/oidc/callback", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
if (req.query.error) {
console.warn(`[oidc] provider refused: ${req.query.error} ${req.query.error_description || ""}`);
return res.redirect(backToApp({ oidc_error: "provider_refused" }));
}
try {
const identity = await oidc.completeLogin({ code: req.query.code, state: req.query.state });
const user = accountForIdentity(identity);
await rememberPicture(user.id, identity.picture, identity.accessToken);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
// The token itself never rides in the URL: a redirect lands in the history,
// in the proxy's log, and in the next request's referrer.
res.redirect(backToApp({ oidc: oidc.issueTicket({ token, user: signedInUser(user) }) }));
} catch (err) {
console.warn("[oidc] sign-in failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "sign_in_failed" }));
}
});
/**
* Keeps the picture the provider has of somebody, or clears it when they take
* theirs away.
*
* On every sign-in rather than only the first: a picture that changed over there
* and never here is worse than none, and this is the one moment we are talking
* to the provider anyway. Failure is silence — the account is already signed in
* by the time this runs, and a portrait is not worth a failed sign-in.
*/
async function rememberPicture(userId, url, accessToken) {
try {
if (!url) {
db.prepare("DELETE FROM user_avatars WHERE user_id = ?").run(userId);
console.log(" ℹ️ the provider sent no picture for this account");
return;
}
const picture = await oidc.fetchPicture(url, { accessToken });
if (!picture) return; // unreachable or not an image: keep whatever is stored
db.prepare(
`INSERT INTO user_avatars (user_id, bytes, content_type, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET bytes = excluded.bytes,
content_type = excluded.content_type,
updated_at = excluded.updated_at`
).run(userId, encryptBuffer(picture.bytes), picture.type);
console.log(` 🖼️ stored a profile picture from the provider (${picture.type}, ${picture.bytes.length} bytes)`);
} catch (err) {
console.warn("[oidc] could not store the profile picture:", err.message);
}
}
/**
* The picture itself.
*
* Yourself always; anybody else only for an administrator, who already sees the
* list of accounts. It is not a public avatar service — everything in this app
* is behind the same door.
*/
app.get("/api/users/:id/avatar", authMiddleware, (req, res) => {
const mine = req.params.id === req.userId;
if (!mine && !db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId)?.is_admin) {
return res.status(403).json({ error: "Not allowed" });
}
const row = db.prepare("SELECT bytes, content_type, updated_at FROM user_avatars WHERE user_id = ?").get(req.params.id);
if (!row) return res.status(404).json({ error: "No picture" });
try {
res.set("Content-Type", row.content_type);
// Private: it is fetched with a bearer token and belongs to one person.
res.set("Cache-Control", "private, max-age=300");
// It came from somewhere else, and one of the types kept here (SVG) is a
// document when a browser is allowed to think so. It never is.
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.send(decryptBuffer(row.bytes));
} catch (err) {
console.error("[avatar] could not be read:", err.message);
res.status(500).json({ error: "Could not read the picture" });
}
});
/* The ticket is swapped for the token by the app itself, once, within a minute. */
app.post("/api/auth/oidc/claim", (req, res) => {
const payload = oidc.redeemTicket(req.body?.ticket);
if (!payload) return res.status(401).json({ error: "This sign-in has expired. Try again." });
res.json(payload);
});
/* ─── 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(signedInUser(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/preferences", authMiddleware, (req, res) => {
const { noteFont, chapterView, theme } = req.body || {};
if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) {
return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` });
}
if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) {
return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` });
}
if (theme !== undefined && !THEMES.includes(theme)) {
return res.status(400).json({ error: `theme must be one of ${THEMES.join(", ")}` });
}
const current = readPreferences(
db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences
);
const next = {
...current,
...(noteFont !== undefined ? { noteFont } : {}),
...(chapterView !== undefined ? { chapterView } : {}),
...(theme !== undefined ? { theme } : {}),
};
db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?")
.run(JSON.stringify(next), 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) 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" });
/* An account made by the provider has no password to confirm with, and asking
* for one it cannot have would simply lock the name. The signed-in token is
* the only credential such an account has, and it is the same one that could
* already read every note. */
if (!isProviderOnly(user) && !(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;
const me = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(req.userId);
/* Nothing to change, and nothing to change it with. An administrator can
* still set one — that is the way back in if the provider is ever gone. */
if (isProviderOnly(me)) {
return res.status(400).json({ error: `This account signs in through ${oidc.label} and has no password.` });
}
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, coverPattern } = req.body;
const id = uuid();
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, cover_pattern, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, name, coverIndex || 0, coverPattern || 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;
/** The ways a separator's rule can be drawn. Anything else is left off. */
const SEPARATOR_STYLES = ["solid", "dashed", "dotted", "double", "thick"];
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,
/* Which list it was drawn in — a chapter of the notebook, or the notebook
* itself. `order` counts the notes before it, and that only means anything
* within one list. */
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
// Presentation, and it comes from a browser: a colour has to look like one
// and a style has to be one we draw, or it is simply left off.
color: cleanColor(s.color),
style: SEPARATOR_STYLES.includes(s.style) ? s.style : null,
}));
}
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
const { name, coverIndex, coverPattern, 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), cover_pattern = COALESCE(?, cover_pattern), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
.run(name, coverIndex, coverPattern, 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, coverPattern } },
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, chapter_id, type, title, icon, icon_color, 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) => {
// `chapter_id` travels with it: a note restored from the bin belongs back in
// the chapter it was filed in, not loose in the notebook.
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, 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) => {
/* Two shapes. `ids` numbers them by their place in the array, which is all
* that was ever needed while notes were the only rows. `positions` names the
* number for each — needed once chapters share the sequence, where a note's
* place in its own array is no longer its position in the list. */
const positions = Array.isArray(req.body?.positions)
? req.body.positions.slice(0, MAX_REORDER)
: Array.isArray(req.body?.ids)
? req.body.ids.slice(0, MAX_REORDER).map((id, index) => ({ id, order: index }))
: null;
if (!positions) return res.status(400).json({ error: "ids or positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?");
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
const ids = positions.map((p) => p?.id);
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, chapterId, starred, pdfAttachmentId } = req.body;
const id = uuid();
const encContent = content ? encrypt(content) : null;
/* A note made while a chapter is open belongs to that chapter. Checked
* against the notebook, so it cannot be filed into a chapter of another. */
let chapter = null;
if (chapterId) {
const found = db.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?").get(chapterId, req.userId);
if (!found) return res.status(404).json({ error: "Chapter not found" });
if (found.notebook_id !== notebookId) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapter = chapterId;
}
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, chapter_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, chapter, 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, chapterId, starred, icon, iconColor, 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" });
/* Which chapter the note is filed in. `null` means the notebook itself, so it
* is meaningful and has to travel — which is why this cannot ride on COALESCE
* like the fields above, where undefined and null mean the same thing.
*
* A chapter belongs to a notebook, so the two have to agree: filing a note
* into a chapter of a book it is not in would leave it in a place that cannot
* be reached from either. */
let chapterUpdate;
if (chapterId !== undefined) {
if (chapterId === null) chapterUpdate = null;
else {
const chapter = db
.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?")
.get(chapterId, req.userId);
if (!chapter) return res.status(404).json({ error: "Chapter not found" });
const target = notebookId !== undefined && notebookId !== null ? notebookId : note.notebook_id;
if (chapter.notebook_id !== target) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapterUpdate = chapterId;
}
}
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);
/* Cleared as deliberately as it is set: null means "take the mark off", which
COALESCE above cannot express. */
if (icon !== undefined) {
db.prepare("UPDATE notes SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
if (iconColor !== undefined) {
db.prepare("UPDATE notes SET icon_color = ? WHERE id = ?").run(cleanColor(iconColor), req.params.id);
}
if (chapterId !== undefined) {
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
// Moved to another notebook without naming a chapter: the old chapter is in
// the book it left, so keeping it would file the note somewhere invisible.
db.prepare("UPDATE notes SET chapter_id = NULL WHERE id = ?").run(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;
/* A note drawn over several pages: page number → strokes. Flatter than the
* PDF's shape below, because every page of a note is the same size and there
* is nothing per page to record besides the ink. */
if (doc.sheets && typeof doc.sheets === "object") {
let n = 0;
for (const sheet of Object.values(doc.sheets)) {
if (!Array.isArray(sheet)) return null;
n += sheet.length;
}
return n;
}
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);
});
/* ─── Chapters ───
*
* Folders inside a notebook. A note is in one or in none; deleting a chapter
* hands its notes back to the notebook rather than taking them with it.
*/
const MAX_CHAPTER_NAME = 120;
/* A colour is written by a browser, so it is an input like any other: only a
* plain `#rrggbb` gets through, and anything else becomes "no colour". */
const cleanColor = (value) =>
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null;
/**
* A row's own mark: one emoji, or nothing.
*
* Length is counted in code points, not in UTF-16 units — a single emoji is
* routinely two of the latter, and a flag or a family several more. Cut at eight
* so one grapheme with its modifiers fits and a pasted sentence does not.
*/
const cleanIcon = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return [...trimmed].slice(0, 8).join("");
};
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name, color, icon, sortMode } = req.body || {};
const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
// The notebook has to be this user's — otherwise a chapter could be hung off
// someone else's, and every note filed into it would follow.
const nb = db.prepare("SELECT id FROM notebooks WHERE id = ? AND user_id = ?").get(notebookId, req.userId);
if (!nb) return res.status(404).json({ error: "Notebook not found" });
const order = db
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
db.prepare(
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, sort_mode, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id,
req.userId,
notebookId,
label,
cleanColor(color),
cleanIcon(icon),
order,
sortMode ?? null,
req.deviceId
);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order, sortMode: sortMode ?? null });
});
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
const positions = Array.isArray(req.body?.positions) ? req.body.positions.slice(0, MAX_REORDER) : null;
if (!positions) return res.status(400).json({ error: "positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND user_id = ?");
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
broadcastToUser(req.userId, { type: "chapters_reordered", data: { count: positions.length } }, req.deviceId);
res.json({ success: true });
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder, color, icon, sortMode } = req.body || {};
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
let label;
if (name !== undefined) {
label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
}
db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
.run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
/* Colour and mark are set and *cleared* by the same field, so neither can ride
* on COALESCE the way the name does: null means "take it off", and COALESCE
* reads null as "leave it alone" — so choosing "None" quietly did nothing. */
if (color !== undefined) {
db.prepare("UPDATE chapters SET color = ? WHERE id = ?").run(cleanColor(color), req.params.id);
}
if (icon !== undefined) {
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
// Also outside the COALESCE: null here means the hand-made order, not "leave
// it as it was".
if (sortMode !== undefined) {
db.prepare("UPDATE chapters SET sort_mode = ? WHERE id = ?").run(sortMode ?? null, req.params.id);
}
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
app.delete("/api/chapters/:id", authMiddleware, (req, res) => {
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
/* The notes are not deleted with it. Written out rather than left to the
* foreign key, because it is the promise the feature rests on and it should
* be visible here. */
const freed = db.prepare("UPDATE notes SET chapter_id = NULL WHERE chapter_id = ? AND user_id = ?")
.run(req.params.id, req.userId).changes;
db.prepare("DELETE FROM chapters WHERE id = ?").run(req.params.id);
broadcastToUser(req.userId, { type: "chapter_deleted", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true, notesFreed: freed });
});
/* ─── The text inside a PDF ───
*
* Extracted by the device that has the file, never here. The server has no PDF
* library, and adding one would mean it could read documents it currently only
* stores — which is the opposite of the point. What arrives is a list of page
* texts; what is stored is that list, encrypted, and it is handed back to any
* other device of the same account so it can search a document it has never
* opened.
*/
/* A cap, so one enormous scan cannot fill the database. The client caps its own
* extraction at the same size; this is the second lock, on the side that cannot
* be edited by whoever is holding the browser. */
const MAX_PDF_TEXT_CHARS = 2_000_000;
app.put("/api/attachments/:id/text", authMiddleware, (req, res) => {
const a = db.prepare("SELECT id FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!a) return res.status(404).json({ error: "Not found" });
const { pages } = req.body || {};
if (!Array.isArray(pages) || pages.some((p) => typeof p !== "string")) {
return res.status(400).json({ error: "pages must be a list of strings" });
}
const total = pages.reduce((n, p) => n + p.length, 0);
if (total > MAX_PDF_TEXT_CHARS) return res.status(413).json({ error: "Extracted text is too large" });
db.prepare("UPDATE attachments SET text_index = ? WHERE id = ? AND user_id = ?")
.run(Buffer.from(encrypt(JSON.stringify({ pages }))), req.params.id, req.userId);
res.json({ ok: true, pages: pages.length });
});
app.get("/api/attachments/:id/text", authMiddleware, (req, res) => {
const a = db
.prepare("SELECT text_index FROM attachments WHERE id = ? AND user_id = ?")
.get(req.params.id, req.userId);
if (!a) return res.status(404).json({ error: "Not found" });
// No text yet is not an error: it means no device has read this file. The
// client answers that by reading it, if it has the bytes.
if (!a.text_index) return res.json({ pages: null });
try {
res.json(JSON.parse(decrypt(a.text_index.toString())));
} catch (err) {
console.error(` ❌ could not decrypt the text of attachment ${req.params.id}: ${err.message}`);
res.json({ pages: null }); // it can be extracted again; a 500 helps nobody
}
});
/* 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<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); }
}
/* 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"}`);
const sso = oidc.describeConfiguration();
if (sso) console.log(sso);
console.log("");
});
/* ─── 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"));