TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
+672
-102
@@ -35,8 +35,36 @@ const http = require("http");
|
||||
|
||||
/* ─────────── Config ─────────── */
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex");
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString("hex");
|
||||
|
||||
/* Secrets must be supplied. They used to fall back to a fresh random value,
|
||||
* which meant a restart without them silently made every stored note
|
||||
* undecryptable — the data was still there, just lost forever. Refuse to boot
|
||||
* instead, and say exactly how to generate what is missing. */
|
||||
function requireSecret(name, { hexBytes } = {}) {
|
||||
const v = process.env[name];
|
||||
if (!v) {
|
||||
const how = hexBytes ? `openssl rand -hex ${hexBytes}` : "openssl rand -base64 32";
|
||||
console.error(`\n ✖ ${name} is not set. Refusing to start.`);
|
||||
console.error(` Generate one with: ${how}`);
|
||||
console.error(` Then put it in .env or pass it as an environment variable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (hexBytes && !new RegExp(`^[0-9a-fA-F]{${hexBytes * 2}}$`).test(v)) {
|
||||
console.error(`\n ✖ ${name} must be ${hexBytes * 2} hex characters (${hexBytes} bytes).`);
|
||||
console.error(` Generate one with: openssl rand -hex ${hexBytes}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const JWT_SECRET = requireSecret("JWT_SECRET");
|
||||
const ENCRYPTION_KEY = requireSecret("ENCRYPTION_KEY", { hexBytes: 32 });
|
||||
const ENCRYPTION_KEY_BUF = Buffer.from(ENCRYPTION_KEY, "hex");
|
||||
|
||||
// Which origins may call the API. Same-origin deployments (nginx in front) need
|
||||
// none; set a comma-separated list only if you serve the UI from another host.
|
||||
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const DATA_DIR = path.join(__dirname, "data");
|
||||
const UPLOAD_DIR = path.join(__dirname, "uploads");
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -50,7 +78,7 @@ db.pragma("foreign_keys = ON");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
@@ -110,30 +138,128 @@ db.exec(`
|
||||
last_sync TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Which note references which. Note bodies are encrypted, so the server
|
||||
-- cannot derive this itself; the client sends the link list when it saves.
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL,
|
||||
target_kind TEXT NOT NULL DEFAULT 'note',
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (source_id, target_id, target_kind)
|
||||
);
|
||||
|
||||
-- Past states of a note, so an accidental overwrite can be undone after the
|
||||
-- fact. Content is encrypted here exactly as it is in the notes table.
|
||||
CREATE TABLE IF NOT EXISTS note_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
content_encrypted BLOB,
|
||||
drawing_strokes BLOB,
|
||||
device_id TEXT,
|
||||
reason TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Binary blobs referenced from note bodies. Stored on disk under a content
|
||||
-- hash, so the same image pasted into ten notes is kept once.
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
sha256 TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
filename TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_owner_hash ON attachments(user_id, sha256);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_versions ON note_versions(note_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(user_id, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_links_source ON note_links(source_id);
|
||||
`);
|
||||
|
||||
/* ─────────── Migrations ─────────── */
|
||||
const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
|
||||
function normalizeUsername(raw) {
|
||||
const v = String(raw ?? "").trim();
|
||||
return USERNAME_RE.test(v) ? v : null;
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
/* ─────────── 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(16);
|
||||
const key = Buffer.from(ENCRYPTION_KEY, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
let encrypted = cipher.update(text, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
return iv.toString("hex") + ":" + encrypted;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv);
|
||||
const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]);
|
||||
return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`;
|
||||
}
|
||||
|
||||
function decrypt(data) {
|
||||
const [ivHex, encryptedHex] = data.split(":");
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const key = Buffer.from(ENCRYPTION_KEY, "hex");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
return decrypted;
|
||||
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 = "") {
|
||||
@@ -188,9 +314,44 @@ function authMiddleware(req, res, next) {
|
||||
|
||||
/* ─────────── 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());
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false }));
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
/* Password checks are deliberately slow, which makes them both a brute-force
|
||||
* defence and a denial-of-service lever. Cap attempts per username+IP. */
|
||||
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
||||
const RATE_MAX_ATTEMPTS = 10;
|
||||
const loginAttempts = new Map(); // key -> { count, resetAt }
|
||||
|
||||
function rateLimitLogin(req, res, next) {
|
||||
const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`;
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
loginAttempts.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
||||
return next();
|
||||
}
|
||||
if (entry.count >= RATE_MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
res.set("Retry-After", String(retryAfter));
|
||||
return res.status(429).json({ error: "Too many attempts. Try again later.", retryAfter });
|
||||
}
|
||||
entry.count++;
|
||||
next();
|
||||
}
|
||||
const clearLoginAttempts = (req) => {
|
||||
loginAttempts.delete(`${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`);
|
||||
};
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of loginAttempts) if (now > v.resetAt) loginAttempts.delete(k);
|
||||
}, RATE_WINDOW_MS).unref();
|
||||
|
||||
// A dummy hash keeps "no such user" and "wrong password" equally slow, so the
|
||||
// response time does not reveal which usernames exist.
|
||||
const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12);
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_DIR,
|
||||
@@ -202,66 +363,88 @@ const upload = multer({
|
||||
});
|
||||
|
||||
/* ─── Auth routes ─── */
|
||||
app.post("/api/auth/register", (req, res) => {
|
||||
const { email, password, displayName } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: "Email and password required" });
|
||||
app.post("/api/auth/register", async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, displayName } = req.body;
|
||||
const name = normalizeUsername(username);
|
||||
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
|
||||
if (String(password).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
||||
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) return res.status(409).json({ error: "Email already registered" });
|
||||
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name);
|
||||
if (existing) return res.status(409).json({ error: "Username already taken" });
|
||||
|
||||
const id = uuid();
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]);
|
||||
const id = uuid();
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
try {
|
||||
db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, name, hash, displayName || name);
|
||||
} catch (err) {
|
||||
// Someone claimed the name between the check and the insert.
|
||||
if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } });
|
||||
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id, username: name, displayName: displayName || name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } });
|
||||
app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const name = String(username ?? "").trim();
|
||||
const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null;
|
||||
// Always run a comparison so timing does not leak whether the user exists.
|
||||
const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH);
|
||||
if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" });
|
||||
|
||||
clearLoginAttempts(req);
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
||||
res.json({ token, user: { id: user.id, username: user.username, displayName: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/* ─── Auth profile routes ─── */
|
||||
app.get("/api/auth/me", authMiddleware, (req, res) => {
|
||||
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
res.json({ id: user.id, email: user.email, displayName: user.display_name });
|
||||
res.json({ id: user.id, username: user.username, displayName: user.display_name });
|
||||
});
|
||||
|
||||
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);
|
||||
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
res.json({ id: user.id, email: user.email, displayName: user.display_name });
|
||||
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
|
||||
res.json({ id: user.id, username: user.username, displayName: user.display_name });
|
||||
});
|
||||
|
||||
app.put("/api/auth/email", authMiddleware, (req, res) => {
|
||||
const { newEmail, password } = req.body;
|
||||
if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" });
|
||||
app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
|
||||
try {
|
||||
const { newUsername, password } = req.body;
|
||||
const name = normalizeUsername(newUsername);
|
||||
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: "Wrong password" });
|
||||
const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId);
|
||||
if (exists) return res.status(409).json({ error: "Email already in use" });
|
||||
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId);
|
||||
res.json({ id: user.id, email: newEmail, displayName: user.display_name });
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" });
|
||||
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
|
||||
if (exists) return res.status(409).json({ error: "Username already taken" });
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
|
||||
res.json({ id: user.id, username: name, displayName: user.display_name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
app.put("/api/auth/password", authMiddleware, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
|
||||
if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" });
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId);
|
||||
res.json({ success: true });
|
||||
app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
|
||||
if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 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); }
|
||||
});
|
||||
|
||||
/* ─── Notebook routes ─── */
|
||||
@@ -301,6 +484,61 @@ app.delete("/api/notebooks/:id", authMiddleware, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/* ─────────── Version history ───────────
|
||||
*
|
||||
* A snapshot records what a note looked like *before* a write, so the previous
|
||||
* state is always recoverable.
|
||||
*
|
||||
* Saves arrive roughly once per second while typing, so snapshotting every one
|
||||
* would be useless noise. Instead they coalesce: within the window below, the
|
||||
* existing newest version already represents "before this editing session".
|
||||
* A write that shrinks a note sharply bypasses the window, because that is
|
||||
* exactly the case worth being able to undo. */
|
||||
const num = (name, fallback) => {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) && v > 0 ? v : fallback;
|
||||
};
|
||||
const VERSION_COALESCE_MS = num("VERSION_COALESCE_MINUTES", 5) * 60 * 1000;
|
||||
const MAX_VERSIONS_PER_NOTE = num("MAX_VERSIONS_PER_NOTE", 50);
|
||||
const VERSION_RETENTION_DAYS = num("VERSION_RETENTION_DAYS", 90);
|
||||
|
||||
const sqliteTimeToMs = (t) => (t ? Date.parse(`${String(t).replace(" ", "T")}Z`) : 0);
|
||||
|
||||
function pruneNoteVersions(noteId) {
|
||||
db.prepare(`
|
||||
DELETE FROM note_versions
|
||||
WHERE note_id = ? AND id NOT IN (
|
||||
SELECT id FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT ?
|
||||
)`).run(noteId, noteId, MAX_VERSIONS_PER_NOTE);
|
||||
}
|
||||
|
||||
function snapshotNote(userId, noteId, deviceId, { force = false, reason = "edit" } = {}) {
|
||||
const note = db.prepare(
|
||||
"SELECT title, content_encrypted, drawing_strokes FROM notes WHERE id = ? AND user_id = ?"
|
||||
).get(noteId, userId);
|
||||
if (!note) return;
|
||||
// An empty note has nothing worth going back to.
|
||||
if (!note.content_encrypted && !note.drawing_strokes) return;
|
||||
|
||||
if (!force) {
|
||||
const last = db.prepare("SELECT created_at FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT 1").get(noteId);
|
||||
if (last && Date.now() - sqliteTimeToMs(last.created_at) < VERSION_COALESCE_MS) return;
|
||||
}
|
||||
|
||||
db.prepare(`INSERT INTO note_versions (note_id, user_id, title, content_encrypted, drawing_strokes, device_id, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(noteId, userId, note.title, note.content_encrypted, note.drawing_strokes, deviceId, reason);
|
||||
pruneNoteVersions(noteId);
|
||||
}
|
||||
|
||||
/* Ciphertext length tracks plaintext length closely enough to spot a write that
|
||||
* throws most of a note away, without decrypting to find out. */
|
||||
function isBigShrink(previous, next) {
|
||||
const before = previous ? previous.length : 0;
|
||||
const after = next ? next.length : 0;
|
||||
return before > 200 && after < before * 0.5;
|
||||
}
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
@@ -316,17 +554,33 @@ 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" });
|
||||
|
||||
// Decrypt content
|
||||
/* 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 {
|
||||
note.content = "";
|
||||
} 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;
|
||||
|
||||
// Read drawing data from file if exists
|
||||
// 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);
|
||||
@@ -359,8 +613,24 @@ app.post("/api/notes", authMiddleware, (req, res) => {
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
/* Replaces a note's outgoing links. Called whenever its body is saved. */
|
||||
const MAX_LINKS_PER_NOTE = 500;
|
||||
const replaceNoteLinks = db.transaction((userId, sourceId, links) => {
|
||||
db.prepare("DELETE FROM note_links WHERE source_id = ?").run(sourceId);
|
||||
const insert = db.prepare(
|
||||
"INSERT OR IGNORE INTO note_links (source_id, target_id, target_kind, user_id) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
for (const link of links.slice(0, MAX_LINKS_PER_NOTE)) {
|
||||
const targetId = typeof link === "string" ? link : link?.id;
|
||||
const kind = (typeof link === "object" && link?.kind) === "notebook" ? "notebook" : "note";
|
||||
if (typeof targetId === "string" && targetId && targetId !== sourceId) {
|
||||
insert.run(sourceId, targetId, kind, userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, starred, tags, vectorClock } = req.body;
|
||||
const { title, content, notebookId, starred, tags, vectorClock, links } = req.body;
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
@@ -381,6 +651,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
|
||||
|
||||
// Keep the state we are about to replace.
|
||||
if (content !== undefined) {
|
||||
const shrank = isBigShrink(note.content_encrypted, encContent);
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
force: shrank,
|
||||
reason: shrank ? "large-deletion" : "edit",
|
||||
});
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notes SET
|
||||
title = COALESCE(?, title),
|
||||
content_encrypted = COALESCE(?, content_encrypted),
|
||||
@@ -394,10 +673,106 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
WHERE id = ?`)
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
|
||||
|
||||
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||
|
||||
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
|
||||
res.json({ success: true, vectorClock: newClock });
|
||||
});
|
||||
|
||||
/* ─── Version history ─── */
|
||||
app.get("/api/notes/:id/versions", authMiddleware, (req, res) => {
|
||||
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
// Sizes only — sending every past body would be a large, mostly unread payload.
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, device_id, reason, created_at,
|
||||
LENGTH(content_encrypted) AS content_size,
|
||||
drawing_strokes IS NOT NULL AS has_drawing
|
||||
FROM note_versions WHERE note_id = ? ORDER BY id DESC
|
||||
`).all(req.params.id);
|
||||
res.json(rows.map((r) => ({ ...r, has_drawing: !!r.has_drawing })));
|
||||
});
|
||||
|
||||
app.get("/api/notes/:id/versions/:versionId", authMiddleware, (req, res) => {
|
||||
const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?")
|
||||
.get(req.params.versionId, req.params.id, req.userId);
|
||||
if (!v) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const out = { id: v.id, title: v.title, created_at: v.created_at, device_id: v.device_id, reason: v.reason };
|
||||
try {
|
||||
out.content = v.content_encrypted ? decrypt(v.content_encrypted.toString()) : "";
|
||||
out.drawingStrokes = v.drawing_strokes ? JSON.parse(decrypt(v.drawing_strokes.toString())) : null;
|
||||
} catch (err) {
|
||||
console.error(`[decrypt] version ${v.id} unreadable:`, err.message);
|
||||
return res.status(500).json({ error: "This version could not be decrypted" });
|
||||
}
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
/* Restoring is itself an edit, so the state being replaced is snapshotted first
|
||||
* — restoring the wrong version is never a one-way door. */
|
||||
app.post("/api/notes/:id/versions/:versionId/restore", authMiddleware, (req, res) => {
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?")
|
||||
.get(req.params.versionId, req.params.id, req.userId);
|
||||
if (!v) return res.status(404).json({ error: "Version not found" });
|
||||
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, { force: true, reason: "before-restore" });
|
||||
|
||||
db.prepare(`UPDATE notes SET content_encrypted = ?, drawing_strokes = ?,
|
||||
updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?`)
|
||||
.run(v.content_encrypted, v.drawing_strokes, req.deviceId, req.params.id);
|
||||
|
||||
// Links live in the restored body, so the index has to follow it back.
|
||||
let links = [];
|
||||
try {
|
||||
if (v.content_encrypted) {
|
||||
const doc = JSON.parse(decrypt(v.content_encrypted.toString()))?.doc;
|
||||
const found = new Set();
|
||||
(function walk(n) {
|
||||
if (!n) return;
|
||||
if (n.type === "noteLink" && n.attrs?.noteId) found.add(n.attrs.noteId);
|
||||
if (Array.isArray(n.content)) n.content.forEach(walk);
|
||||
})(doc);
|
||||
links = [...found];
|
||||
}
|
||||
} catch { /* a v1 body has no links to recover */ }
|
||||
replaceNoteLinks(req.userId, req.params.id, links);
|
||||
|
||||
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)")
|
||||
.run(req.userId, req.params.id, JSON.stringify({ restored: v.id }), req.deviceId);
|
||||
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, restored: true } }, req.deviceId);
|
||||
|
||||
res.json({ success: true, restoredFrom: v.created_at });
|
||||
});
|
||||
|
||||
/* Notes that reference this one. Trashed sources are excluded — a backlink from
|
||||
* something in the bin is noise. */
|
||||
app.get("/api/notes/:id/backlinks", authMiddleware, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT n.id, n.title, n.notebook_id, n.updated_at, nb.name AS notebook_name
|
||||
FROM note_links l
|
||||
JOIN notes n ON n.id = l.source_id
|
||||
LEFT JOIN notebooks nb ON nb.id = n.notebook_id
|
||||
WHERE l.target_id = ? AND l.user_id = ? AND n.deleted_at IS NULL
|
||||
ORDER BY n.updated_at DESC
|
||||
`).all(req.params.id, req.userId);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
/* Every link in one round trip, so the client can render backlink counts in a
|
||||
* list without asking per note. */
|
||||
app.get("/api/links", authMiddleware, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT l.source_id, l.target_id, l.target_kind
|
||||
FROM note_links l
|
||||
JOIN notes n ON n.id = l.source_id
|
||||
WHERE l.user_id = ? AND n.deleted_at IS NULL
|
||||
`).all(req.userId);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
/* ─── Trash batch endpoint (MUST be before :id routes) ─── */
|
||||
app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => {
|
||||
db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId);
|
||||
@@ -425,30 +800,55 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
/* ─── Drawing data route ─── */
|
||||
const MAX_STROKES = 20000;
|
||||
|
||||
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
const { drawingData } = req.body;
|
||||
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 (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "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 {}
|
||||
if (drawingStrokes !== undefined) {
|
||||
// Vector strokes in the note's canonical coordinate space — the current format.
|
||||
if (drawingStrokes !== null) {
|
||||
if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
|
||||
return res.status(400).json({ error: "Invalid strokes format" });
|
||||
if (drawingStrokes.strokes.length > MAX_STROKES)
|
||||
return res.status(413).json({ error: "Too many strokes" });
|
||||
}
|
||||
const empty = !drawingStrokes || drawingStrokes.strokes.length === 0;
|
||||
const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id);
|
||||
// Clearing a drawing is always worth being able to undo.
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
force: empty && !!previous?.drawing_strokes,
|
||||
reason: empty ? "drawing-cleared" : "drawing",
|
||||
});
|
||||
// A note's legacy raster is left untouched — it renders underneath the strokes
|
||||
// on the client, so migrating an old drawing never loses anything.
|
||||
db.prepare("UPDATE notes SET drawing_strokes = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
|
||||
.run(empty ? null : Buffer.from(encrypt(JSON.stringify(drawingStrokes))), req.deviceId, req.params.id);
|
||||
} else {
|
||||
// Legacy raster path, kept so older clients keep working.
|
||||
if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingStrokes or drawingData required" });
|
||||
const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/);
|
||||
if (!m) return res.status(400).json({ error: "Invalid drawing format" });
|
||||
const mime = m[1];
|
||||
const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg";
|
||||
const base64Data = m[2];
|
||||
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`);
|
||||
|
||||
// Remove older file variants from previous format writes.
|
||||
for (const oldExt of ["png", "jpg", "jpeg", "webp"]) {
|
||||
const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`);
|
||||
if (oldPath !== filePath && fs.existsSync(oldPath)) {
|
||||
try { fs.unlinkSync(oldPath); } catch {}
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
||||
|
||||
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?")
|
||||
.run(filePath, req.deviceId, req.params.id);
|
||||
}
|
||||
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);
|
||||
@@ -456,6 +856,69 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/* ─── Attachments ───
|
||||
*
|
||||
* Images used to be inlined into the note body as base64 data URIs: a few
|
||||
* screenshots turned a note into megabytes that were re-encrypted and re-sent on
|
||||
* every keystroke. They are now separate blobs the body only references.
|
||||
*
|
||||
* Files are named by SHA-256 of their bytes, so the same image pasted into
|
||||
* several notes is stored once. */
|
||||
const ATTACHMENT_DIR = path.join(UPLOAD_DIR, "attachments");
|
||||
if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: true });
|
||||
|
||||
const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]);
|
||||
const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
app.post("/api/attachments", authMiddleware, (req, res) => {
|
||||
const { data, filename } = req.body || {};
|
||||
if (typeof data !== "string") return res.status(400).json({ error: "data required" });
|
||||
|
||||
const m = data.match(/^data:([a-z0-9.+/-]+);base64,(.+)$/i);
|
||||
if (!m) return res.status(400).json({ error: "Expected a base64 data URL" });
|
||||
const mime = m[1].toLowerCase();
|
||||
if (!ALLOWED_ATTACHMENT_MIME.has(mime)) return res.status(415).json({ error: `Unsupported type: ${mime}` });
|
||||
|
||||
const buf = Buffer.from(m[2], "base64");
|
||||
if (buf.length === 0) return res.status(400).json({ error: "Empty file" });
|
||||
if (buf.length > MAX_ATTACHMENT_BYTES) return res.status(413).json({ error: "File too large" });
|
||||
|
||||
// Trust the bytes, not the declared type.
|
||||
const sniffed = detectImageMime(buf);
|
||||
if (mime !== "image/gif" && mime !== "image/avif" && sniffed !== mime) {
|
||||
return res.status(415).json({ error: "File content does not match its type" });
|
||||
}
|
||||
|
||||
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
|
||||
const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256);
|
||||
if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true });
|
||||
|
||||
const filePath = path.join(ATTACHMENT_DIR, sha256);
|
||||
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buf);
|
||||
|
||||
const id = uuid();
|
||||
db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run(id, req.userId, sha256, mime, buf.length, typeof filename === "string" ? filename.slice(0, 200) : null);
|
||||
|
||||
res.json({ id, mime, size: buf.length, deduplicated: false });
|
||||
});
|
||||
|
||||
app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
const a = db.prepare("SELECT * FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!a) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
// The hash comes from our own database, never from the request, so this path
|
||||
// cannot be steered outside the attachment directory.
|
||||
const filePath = path.join(ATTACHMENT_DIR, a.sha256);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" });
|
||||
|
||||
res.setHeader("Content-Type", a.mime);
|
||||
res.setHeader("Content-Length", a.size);
|
||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
/* ─── PDF upload route ─── */
|
||||
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
||||
@@ -528,42 +991,68 @@ const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||
const userConnections = new Map(); // userId -> Set<ws>
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
let userId = null;
|
||||
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") {
|
||||
const payload = jwt.verify(msg.token, JWT_SECRET);
|
||||
userId = payload.userId;
|
||||
const deviceId = msg.deviceId || "unknown";
|
||||
if (msg.type !== "auth") return;
|
||||
const payload = jwt.verify(msg.token, JWT_SECRET);
|
||||
ws._userId = payload.userId;
|
||||
ws._deviceId = msg.deviceId || "unknown";
|
||||
clearTimeout(authTimer);
|
||||
|
||||
if (!userConnections.has(userId)) userConnections.set(userId, new Map());
|
||||
userConnections.get(userId).set(deviceId, ws);
|
||||
ws._deviceId = deviceId;
|
||||
// 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" }));
|
||||
}
|
||||
ws.send(JSON.stringify({ type: "auth_ok" }));
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }));
|
||||
try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (userId && userConnections.has(userId)) {
|
||||
userConnections.get(userId).delete(ws._deviceId);
|
||||
if (userConnections.get(userId).size === 0) userConnections.delete(userId);
|
||||
}
|
||||
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) {
|
||||
if (!userConnections.has(userId)) return;
|
||||
const connections = userConnections.get(userId);
|
||||
for (const [deviceId, ws] of connections) {
|
||||
if (deviceId !== excludeDeviceId && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(message));
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,10 +1062,91 @@ app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() });
|
||||
});
|
||||
|
||||
/* ─── Error handling ─── */
|
||||
app.use((req, res) => res.status(404).json({ error: "Not found" }));
|
||||
|
||||
// Without this, a throw inside a route hangs the request until the client times
|
||||
// out, and the stack trace goes to the client in Express' default handler.
|
||||
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
||||
console.error("[error]", req.method, req.path, err);
|
||||
if (res.headersSent) return;
|
||||
if (err?.type === "entity.too.large") return res.status(413).json({ error: "Payload too large" });
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err) => console.error("[unhandledRejection]", err));
|
||||
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
|
||||
|
||||
/* ─── Housekeeping ─── */
|
||||
// sync_log is append-only and grew without bound; every note edit added a row.
|
||||
const SYNC_LOG_RETENTION_DAYS = 30;
|
||||
function pruneSyncLog() {
|
||||
try {
|
||||
const r = db.prepare(`DELETE FROM sync_log WHERE timestamp < datetime('now', '-${SYNC_LOG_RETENTION_DAYS} days')`).run();
|
||||
if (r.changes) console.log(` 🧹 pruned ${r.changes} sync_log rows older than ${SYNC_LOG_RETENTION_DAYS} days`);
|
||||
} catch (err) { console.error("[prune] failed:", err.message); }
|
||||
}
|
||||
|
||||
/* Per-note count is capped on write; this only clears out versions of notes
|
||||
* that stopped being edited long ago. */
|
||||
function pruneOldVersions() {
|
||||
try {
|
||||
const r = db.prepare(`DELETE FROM note_versions WHERE created_at < datetime('now', '-${VERSION_RETENTION_DAYS} days')`).run();
|
||||
if (r.changes) console.log(` 🧹 pruned ${r.changes} note versions older than ${VERSION_RETENTION_DAYS} days`);
|
||||
} catch (err) { console.error("[prune] failed:", err.message); }
|
||||
}
|
||||
|
||||
/* Attachment rows cascade away with their owner, but the files they point at do
|
||||
* not. The database is authoritative: a blob nobody has a row for is garbage.
|
||||
*
|
||||
* Only files that have sat untouched for an hour are considered, so an upload
|
||||
* in flight — written to disk just before its row is inserted — is never hit. */
|
||||
const ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
||||
function pruneOrphanAttachments() {
|
||||
try {
|
||||
if (!fs.existsSync(ATTACHMENT_DIR)) return;
|
||||
const referenced = new Set(db.prepare("SELECT sha256 FROM attachments").all().map((r) => r.sha256));
|
||||
let removed = 0;
|
||||
for (const name of fs.readdirSync(ATTACHMENT_DIR)) {
|
||||
if (referenced.has(name)) continue;
|
||||
const full = path.join(ATTACHMENT_DIR, name);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(full).mtimeMs < ORPHAN_GRACE_MS) continue;
|
||||
fs.unlinkSync(full);
|
||||
removed++;
|
||||
} catch { /* leave anything we cannot inspect alone */ }
|
||||
}
|
||||
if (removed) console.log(` 🧹 removed ${removed} orphaned attachment file(s)`);
|
||||
} catch (err) { console.error("[prune] attachments failed:", err.message); }
|
||||
}
|
||||
|
||||
const housekeeping = () => { pruneSyncLog(); pruneOldVersions(); pruneOrphanAttachments(); };
|
||||
housekeeping();
|
||||
setInterval(housekeeping, 24 * 60 * 60 * 1000).unref();
|
||||
|
||||
/* ─── Start server ─── */
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
|
||||
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
|
||||
console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`);
|
||||
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`);
|
||||
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`);
|
||||
console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`);
|
||||
});
|
||||
|
||||
/* ─── Graceful shutdown ─── */
|
||||
let shuttingDown = false;
|
||||
function shutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`\n ${signal} received — shutting down...`);
|
||||
clearInterval(heartbeat);
|
||||
for (const ws of wss.clients) { try { ws.close(1001, "server shutting down"); } catch {} }
|
||||
server.close(() => {
|
||||
try { db.close(); } catch (err) { console.error("[shutdown] db.close:", err.message); }
|
||||
console.log(" ✅ closed cleanly\n");
|
||||
process.exit(0);
|
||||
});
|
||||
// Don't hang forever on a stuck connection.
|
||||
setTimeout(() => { try { db.close(); } catch {} process.exit(1); }, 10000).unref();
|
||||
}
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
Reference in new issue
Block a user