Files
InkNote/backend/server.js
T
2026-02-17 19:25:52 +01:00

476 lines
19 KiB
JavaScript

/*
* InkNote — Backend Sync Server
* ─────────────────────────────
* Node.js + Express + SQLite
*
* Features:
* • User registration / login (JWT)
* • CRUD for notes, notebooks, PDFs
* • Conflict-aware sync with vector clocks
* • WebSocket real-time push
* • Encrypted storage at rest (AES-256)
*
* Install:
* npm init -y
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv multer
*
* Run:
* node server.js
*/
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { WebSocketServer } = require("ws");
const Database = require("better-sqlite3");
const { v4: uuid } = require("uuid");
const crypto = require("crypto");
const multer = require("multer");
const path = require("path");
const fs = require("fs");
const http = require("http");
/* ─────────── Config ─────────── */
const PORT = process.env.PORT || 4000;
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");
const UPLOAD_DIR = path.join(__dirname, "uploads");
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
/* ─────────── Database ─────────── */
const db = new Database(path.join(__dirname, "inknote.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS notebooks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
cover_index INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT,
version INTEGER DEFAULT 1,
device_id TEXT
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notebook_id TEXT REFERENCES notebooks(id) ON DELETE SET NULL,
type TEXT NOT NULL DEFAULT 'text', -- text | drawing | pdf
title TEXT NOT NULL DEFAULT 'Untitled',
content_encrypted BLOB,
drawing_data_path TEXT,
pdf_path TEXT,
annotations_path TEXT,
starred INTEGER DEFAULT 0,
tags TEXT DEFAULT '[]',
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT,
version INTEGER DEFAULT 1,
device_id TEXT,
vector_clock TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL, -- note | notebook
entity_id TEXT NOT NULL,
action TEXT NOT NULL, -- create | update | delete
payload TEXT,
device_id TEXT,
timestamp TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
platform TEXT,
last_sync TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
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);
`);
/* ─────────── Encryption helpers ─────────── */
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;
}
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;
}
/* ─────────── Vector clock conflict resolution ─────────── */
function mergeVectorClocks(clockA, clockB) {
const merged = { ...clockA };
for (const [device, time] of Object.entries(clockB)) {
merged[device] = Math.max(merged[device] || 0, time);
}
return merged;
}
function isClockNewer(clockA, clockB) {
// Returns true if clockA is strictly newer than clockB
let aHasNewer = false;
for (const [device, time] of Object.entries(clockA)) {
if (time > (clockB[device] || 0)) aHasNewer = true;
}
for (const [device, time] of Object.entries(clockB)) {
if (time > (clockA[device] || 0)) return false; // conflict or older
}
return aHasNewer;
}
/* ─────────── Auth middleware ─────────── */
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required" });
}
try {
const token = header.slice(7);
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.userId;
req.deviceId = req.headers["x-device-id"] || "unknown";
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
/* ─────────── Express app ─────────── */
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "50mb" }));
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
fileFilter: (req, file, cb) => {
if (file.mimetype === "application/pdf") cb(null, true);
else cb(new Error("Only PDF files allowed"));
},
});
/* ─── Auth routes ─── */
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" });
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (existing) return res.status(409).json({ error: "Email already registered" });
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 token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } });
});
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 } });
});
/* ─── Notebook routes ─── */
app.get("/api/notebooks", authMiddleware, (req, res) => {
const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId);
res.json(notebooks);
});
app.post("/api/notebooks", authMiddleware, (req, res) => {
const { name, coverIndex } = req.body;
const id = uuid();
db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)")
.run(id, req.userId, name, coverIndex || 0, req.deviceId);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)")
.run(req.userId, id, req.deviceId);
broadcastToUser(req.userId, { type: "notebook_created", data: { id, name, coverIndex } }, req.deviceId);
res.json({ id, name, coverIndex });
});
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
const { name, coverIndex, sortOrder } = req.body;
const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!nb) return res.status(404).json({ error: "Not found" });
db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
.run(name, coverIndex, sortOrder, req.params.id);
broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId);
res.json({ success: true });
});
app.delete("/api/notebooks/:id", authMiddleware, (req, res) => {
db.prepare("UPDATE notebooks SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId);
broadcastToUser(req.userId, { type: "notebook_deleted", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true });
});
/* ─── Note routes ─── */
app.get("/api/notes", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
res.json(notes);
});
app.get("/api/notes/: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
if (note.content_encrypted) {
try {
note.content = decrypt(note.content_encrypted.toString());
} catch {
note.content = "";
}
}
delete note.content_encrypted;
res.json(note);
});
app.post("/api/notes", authMiddleware, (req, res) => {
const { type, title, content, notebookId, starred, tags } = req.body;
const id = uuid();
const encContent = content ? encrypt(content) : null;
const clock = JSON.stringify({ [req.deviceId]: 1 });
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, tags, device_id, vector_clock)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, JSON.stringify(tags || []), req.deviceId, clock);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
.run(req.userId, id, req.deviceId);
broadcastToUser(req.userId, { type: "note_created", data: { id, type, title } }, req.deviceId);
res.json({ id });
});
app.put("/api/notes/:id", authMiddleware, (req, res) => {
const { title, content, notebookId, starred, tags, vectorClock } = req.body;
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
// Conflict detection
const serverClock = JSON.parse(note.vector_clock || "{}");
const clientClock = vectorClock ? JSON.parse(vectorClock) : {};
if (Object.keys(clientClock).length > 0 && !isClockNewer(clientClock, serverClock)) {
// Conflict! Return both versions
return res.status(409).json({
error: "Conflict detected",
serverVersion: { ...note, vectorClock: serverClock },
clientVersion: req.body,
mergedClock: mergeVectorClocks(serverClock, clientClock),
});
}
const encContent = content ? encrypt(content) : note.content_encrypted;
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
db.prepare(`UPDATE notes SET
title = COALESCE(?, title),
content_encrypted = COALESCE(?, content_encrypted),
notebook_id = COALESCE(?, notebook_id),
starred = COALESCE(?, starred),
tags = COALESCE(?, tags),
updated_at = datetime('now'),
version = version + 1,
vector_clock = ?,
device_id = ?
WHERE id = ?`)
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
res.json({ success: true, vectorClock: newClock });
});
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 });
});
/* ─── Drawing data route ─── */
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const { 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" });
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.png`);
const base64Data = drawingData.replace(/^data:image\/\w+;base64,/, "");
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1 WHERE id = ?")
.run(filePath, req.params.id);
res.json({ success: true });
});
/* ─── PDF upload route ─── */
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
const id = uuid();
const clock = JSON.stringify({ [req.deviceId]: 1 });
db.prepare(`INSERT INTO notes (id, user_id, type, title, pdf_path, device_id, vector_clock)
VALUES (?, ?, 'pdf', ?, ?, ?, ?)`)
.run(id, req.userId, req.file.originalname, req.file.path, req.deviceId, clock);
res.json({ id, title: req.file.originalname });
});
/* ─── Sync endpoint ─── */
app.post("/api/sync", authMiddleware, (req, res) => {
const { lastSyncTimestamp, deviceId, changes } = req.body;
const results = { pushed: [], pulled: [], conflicts: [] };
// Apply client changes
if (changes && changes.length > 0) {
for (const change of changes) {
try {
if (change.entityType === "note") {
if (change.action === "create") {
const encContent = change.data.content ? encrypt(change.data.content) : null;
db.prepare(`INSERT OR REPLACE INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id, vector_clock, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
.run(change.data.id, req.userId, change.data.notebookId, change.data.type, change.data.title, encContent, change.data.starred ? 1 : 0, deviceId, JSON.stringify(change.data.vectorClock || {}));
results.pushed.push({ id: change.data.id, status: "ok" });
} else if (change.action === "update") {
const existing = db.prepare("SELECT vector_clock FROM notes WHERE id = ? AND user_id = ?").get(change.data.id, req.userId);
if (existing) {
const serverClock = JSON.parse(existing.vector_clock || "{}");
const clientClock = change.data.vectorClock || {};
if (!isClockNewer(clientClock, serverClock)) {
results.conflicts.push({ id: change.data.id, serverClock, clientClock });
continue;
}
}
results.pushed.push({ id: change.data.id, status: "ok" });
}
}
} catch (err) {
results.pushed.push({ id: change.data?.id, status: "error", error: err.message });
}
}
}
// Get server changes since last sync
const serverChanges = db.prepare(`
SELECT s.*, n.title, n.type, n.updated_at
FROM sync_log s
LEFT JOIN notes n ON s.entity_id = n.id
WHERE s.user_id = ? AND s.timestamp > COALESCE(?, '1970-01-01') AND s.device_id != ?
ORDER BY s.timestamp
`).all(req.userId, lastSyncTimestamp, deviceId);
results.pulled = serverChanges;
results.syncTimestamp = new Date().toISOString();
// Update device last sync
db.prepare("INSERT OR REPLACE INTO devices (id, user_id, last_sync) VALUES (?, ?, datetime('now'))").run(deviceId, req.userId);
res.json(results);
});
/* ─── WebSocket for real-time sync ─── */
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/ws" });
const userConnections = new Map(); // userId -> Set<ws>
wss.on("connection", (ws, req) => {
let userId = null;
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 (!userConnections.has(userId)) userConnections.set(userId, new Map());
userConnections.get(userId).set(deviceId, ws);
ws._deviceId = deviceId;
ws.send(JSON.stringify({ type: "auth_ok" }));
}
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }));
}
});
ws.on("close", () => {
if (userId && userConnections.has(userId)) {
userConnections.get(userId).delete(ws._deviceId);
if (userConnections.get(userId).size === 0) userConnections.delete(userId);
}
});
});
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));
}
}
}
/* ─── Health check ─── */
app.get("/api/health", (req, res) => {
res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() });
});
/* ─── 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(__dirname, "inknote.db")}\n`);
});