476 lines
19 KiB
JavaScript
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`);
|
|
}); |