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>
2416 lines
108 KiB
JavaScript
2416 lines
108 KiB
JavaScript
/*
|
||
* InkNote — Backend Sync Server
|
||
* ─────────────────────────────
|
||
* Node.js + Express + SQLite
|
||
*
|
||
* Features:
|
||
* • User registration / login (JWT)
|
||
* • CRUD for notes, notebooks, PDFs
|
||
* • Conflict-aware sync with vector clocks
|
||
* • WebSocket real-time push
|
||
* • Encrypted storage at rest (AES-256)
|
||
*
|
||
* Install:
|
||
* npm init -y
|
||
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv
|
||
*
|
||
* 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"));
|