/* * 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 DATA_DIR = path.join(__dirname, "data"); const UPLOAD_DIR = path.join(__dirname, "uploads"); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); /* ─────────── 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, 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 } }); }); /* ─── 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); if (!user) return res.status(404).json({ error: "User not found" }); res.json({ id: user.id, email: user.email, 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 }); }); 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" }); 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 }); }); 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 }); }); /* ─── 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/trash", authMiddleware, (req, res) => { const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId); res.json(notes); }); app.get("/api/notes/:id", authMiddleware, (req, res) => { const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); // 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 }); }); /* ─── 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 ─── */ 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 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(DATA_DIR, "inknote.db")}\n`); });