First Commit
This commit is contained in:
1 parent
fa15d48a52
commit
4709096ab3
8 files changed
+2208
No files matched your search
File renamed without changes.
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data /app/uploads
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "inknote-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "InkNote — Cross-platform note-taking backend with sync",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
* 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`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/*
|
||||||
|
* InkNote — Client Sync Engine
|
||||||
|
* ─────────────────────────────
|
||||||
|
* Handles offline-first sync with conflict resolution.
|
||||||
|
* Works in React Native, Flutter (via JS bridge), Electron, and web.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { SyncEngine } from './sync-client';
|
||||||
|
* const sync = new SyncEngine('https://your-server.com', deviceId);
|
||||||
|
* await sync.authenticate(email, password);
|
||||||
|
* sync.start(); // begins periodic sync + WebSocket listener
|
||||||
|
* sync.stop(); // stops sync
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SyncEngine {
|
||||||
|
constructor(serverUrl, deviceId) {
|
||||||
|
this.serverUrl = serverUrl.replace(/\/$/, "");
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.token = null;
|
||||||
|
this.ws = null;
|
||||||
|
this.syncInterval = null;
|
||||||
|
this.pendingChanges = [];
|
||||||
|
this.lastSyncTimestamp = null;
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.isOnline = true;
|
||||||
|
this.isSyncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Authentication ─── */
|
||||||
|
async authenticate(email, password) {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Authentication failed");
|
||||||
|
const data = await res.json();
|
||||||
|
this.token = data.token;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(email, password, displayName) {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, displayName }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Registration failed");
|
||||||
|
const data = await res.json();
|
||||||
|
this.token = data.token;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Event system ─── */
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) this.listeners.set(event, []);
|
||||||
|
this.listeners.get(event).push(callback);
|
||||||
|
return () => {
|
||||||
|
const cbs = this.listeners.get(event);
|
||||||
|
const idx = cbs.indexOf(callback);
|
||||||
|
if (idx > -1) cbs.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
const cbs = this.listeners.get(event) || [];
|
||||||
|
cbs.forEach((cb) => cb(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Start / Stop ─── */
|
||||||
|
start(intervalMs = 30000) {
|
||||||
|
this.connectWebSocket();
|
||||||
|
this.syncInterval = setInterval(() => this.sync(), intervalMs);
|
||||||
|
this.sync(); // immediate first sync
|
||||||
|
|
||||||
|
// Listen for online/offline
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
this.isOnline = true;
|
||||||
|
this.emit("status", "online");
|
||||||
|
this.sync();
|
||||||
|
});
|
||||||
|
window.addEventListener("offline", () => {
|
||||||
|
this.isOnline = false;
|
||||||
|
this.emit("status", "offline");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.syncInterval) clearInterval(this.syncInterval);
|
||||||
|
if (this.ws) this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── WebSocket ─── */
|
||||||
|
connectWebSocket() {
|
||||||
|
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/ws";
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
type: "auth",
|
||||||
|
token: this.token,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "auth_ok") {
|
||||||
|
this.emit("status", "connected");
|
||||||
|
} else if (msg.type.endsWith("_created") || msg.type.endsWith("_updated") || msg.type.endsWith("_deleted")) {
|
||||||
|
this.emit("remote_change", msg);
|
||||||
|
this.sync(); // re-sync on remote change
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.emit("status", "disconnected");
|
||||||
|
// Auto-reconnect after 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.token) this.connectWebSocket();
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.emit("status", "error");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Queue changes for sync ─── */
|
||||||
|
queueChange(entityType, entityId, action, data) {
|
||||||
|
const change = {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
action,
|
||||||
|
data: { ...data, id: entityId },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
};
|
||||||
|
this.pendingChanges.push(change);
|
||||||
|
|
||||||
|
// Persist to local storage for crash recovery
|
||||||
|
this.savePendingChanges();
|
||||||
|
|
||||||
|
// Try immediate sync if online
|
||||||
|
if (this.isOnline && !this.isSyncing) {
|
||||||
|
this.sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full sync cycle ─── */
|
||||||
|
async sync() {
|
||||||
|
if (!this.token || !this.isOnline || this.isSyncing) return;
|
||||||
|
this.isSyncing = true;
|
||||||
|
this.emit("status", "syncing");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${this.token}`,
|
||||||
|
"X-Device-Id": this.deviceId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
lastSyncTimestamp: this.lastSyncTimestamp,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
changes: this.pendingChanges,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Sync failed: ${res.status}`);
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
// Process results
|
||||||
|
for (const pushed of result.pushed) {
|
||||||
|
if (pushed.status === "ok") {
|
||||||
|
this.pendingChanges = this.pendingChanges.filter((c) => c.data?.id !== pushed.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle conflicts
|
||||||
|
for (const conflict of result.conflicts) {
|
||||||
|
this.emit("conflict", conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pulled changes from server
|
||||||
|
for (const change of result.pulled) {
|
||||||
|
this.emit("remote_change", change);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSyncTimestamp = result.syncTimestamp;
|
||||||
|
this.savePendingChanges();
|
||||||
|
this.emit("status", "synced");
|
||||||
|
} catch (err) {
|
||||||
|
this.emit("status", "error");
|
||||||
|
console.error("Sync error:", err);
|
||||||
|
} finally {
|
||||||
|
this.isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Local persistence for pending changes ─── */
|
||||||
|
savePendingChanges() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
localStorage.setItem(`inknote_pending_${this.deviceId}`, JSON.stringify(this.pendingChanges));
|
||||||
|
localStorage.setItem(`inknote_last_sync_${this.deviceId}`, this.lastSyncTimestamp || "");
|
||||||
|
}
|
||||||
|
} catch { /* ignore storage errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPendingChanges() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
const pending = localStorage.getItem(`inknote_pending_${this.deviceId}`);
|
||||||
|
if (pending) this.pendingChanges = JSON.parse(pending);
|
||||||
|
this.lastSyncTimestamp = localStorage.getItem(`inknote_last_sync_${this.deviceId}`) || null;
|
||||||
|
}
|
||||||
|
} catch { /* ignore storage errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── API helpers ─── */
|
||||||
|
async fetchNotes() {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/notes`, {
|
||||||
|
headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchNote(id) {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/notes/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNote(note) {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/notes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"X-Device-Id": this.deviceId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(note),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
this.queueChange("note", data.id, "create", note);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id, updates) {
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/notes/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"X-Device-Id": this.deviceId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
const conflict = await res.json();
|
||||||
|
this.emit("conflict", conflict);
|
||||||
|
return conflict;
|
||||||
|
}
|
||||||
|
this.queueChange("note", id, "update", updates);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNote(id) {
|
||||||
|
await fetch(`${this.serverUrl}/api/notes/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId },
|
||||||
|
});
|
||||||
|
this.queueChange("note", id, "delete", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPDF(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("pdf", file);
|
||||||
|
const res = await fetch(`${this.serverUrl}/api/notes/pdf`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"X-Device-Id": this.deviceId,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDrawing(noteId, drawingDataUrl) {
|
||||||
|
await fetch(`${this.serverUrl}/api/notes/${noteId}/drawing`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"X-Device-Id": this.deviceId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ drawingData: drawingDataUrl }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SyncEngine };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── Backend API + WebSocket Server ───
|
||||||
|
inknote-server:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: inknote-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
- PORT=4000
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-please-change-this-secret-key}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
- inknote-data:/app/data
|
||||||
|
- inknote-uploads:/app/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:4000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ─── Frontend (Production Build) ───
|
||||||
|
inknote-web:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: inknote-web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- inknote-server
|
||||||
|
|
||||||
|
# ─── Optional: PostgreSQL for production ───
|
||||||
|
# Uncomment to use PostgreSQL instead of SQLite
|
||||||
|
# inknote-db:
|
||||||
|
# image: postgres:16-alpine
|
||||||
|
# container_name: inknote-db
|
||||||
|
# restart: unless-stopped
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_DB: inknote
|
||||||
|
# POSTGRES_USER: inknote
|
||||||
|
# POSTGRES_PASSWORD: ${DB_PASSWORD:-change-me}
|
||||||
|
# volumes:
|
||||||
|
# - postgres-data:/var/lib/postgresql/data
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
inknote-data:
|
||||||
|
inknote-uploads:
|
||||||
|
# postgres-data:
|
||||||
+1116
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,212 @@
|
|||||||
|
# ✏️ InkNote
|
||||||
|
|
||||||
|
A cross-platform note-taking application with rich text editing, freehand drawing, PDF annotation, and encrypted sync — inspired by Samsung Notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, checkboxes
|
||||||
|
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
||||||
|
- **Undo/Redo** — Full history stack for drawing operations
|
||||||
|
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
|
||||||
|
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||||
|
- **Star/Favorite** — Quick access to important notes
|
||||||
|
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||||
|
|
||||||
|
### Sync & Storage
|
||||||
|
- **Offline-First** — Works fully offline, syncs when connection returns
|
||||||
|
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
|
||||||
|
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
|
||||||
|
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
|
||||||
|
- **JWT Authentication** — Secure user accounts with bcrypt password hashing
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL)
|
||||||
|
- **Docker Ready** — docker-compose up and you're running
|
||||||
|
- **Client Sync Engine** — Portable JS library for any frontend (React Native, Flutter JS bridge, Electron, web)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
inknote/
|
||||||
|
├── note-app.jsx # Full React UI (artifact / web)
|
||||||
|
├── docker-compose.yml # Container orchestration
|
||||||
|
├── backend/
|
||||||
|
│ ├── server.js # Express API + WebSocket server
|
||||||
|
│ ├── sync-client.js # Client-side sync engine library
|
||||||
|
│ ├── package.json # Node dependencies
|
||||||
|
│ ├── Dockerfile # Server container
|
||||||
|
│ └── .env.example # Environment variable template
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Run the UI (Web / Artifact)
|
||||||
|
|
||||||
|
The `note-app.jsx` file is a self-contained React component. It runs directly as a Claude artifact, or you can embed it in any React project:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import NoteApp from './note-app';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <NoteApp />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # Edit secrets!
|
||||||
|
npm install
|
||||||
|
npm start # → http://localhost:4000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Docker Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure encryption key
|
||||||
|
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||||
|
export JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The server runs on port `4000`, the web UI on port `3000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Method | Endpoint | Body | Response |
|
||||||
|
|--------|-----------------------|-------------------------------|---------------------|
|
||||||
|
| POST | `/api/auth/register` | `{email, password, displayName}` | `{token, user}` |
|
||||||
|
| POST | `/api/auth/login` | `{email, password}` | `{token, user}` |
|
||||||
|
|
||||||
|
### Notebooks
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|-----------------------|--------------------------|
|
||||||
|
| GET | `/api/notebooks` | List all notebooks |
|
||||||
|
| POST | `/api/notebooks` | Create notebook |
|
||||||
|
| PUT | `/api/notebooks/:id` | Update notebook |
|
||||||
|
| DELETE | `/api/notebooks/:id` | Soft-delete notebook |
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|---------------------------|-------------------------------|
|
||||||
|
| GET | `/api/notes` | List all notes (metadata) |
|
||||||
|
| GET | `/api/notes/:id` | Get note with decrypted content |
|
||||||
|
| POST | `/api/notes` | Create note |
|
||||||
|
| PUT | `/api/notes/:id` | Update note (conflict-aware) |
|
||||||
|
| DELETE | `/api/notes/:id` | Soft-delete note |
|
||||||
|
| PUT | `/api/notes/:id/drawing` | Save drawing data (base64) |
|
||||||
|
| POST | `/api/notes/pdf` | Upload PDF (multipart/form) |
|
||||||
|
|
||||||
|
### Sync
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|------------- |------------------------------------------|
|
||||||
|
| POST | `/api/sync` | Bidirectional sync with conflict reports |
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
|
||||||
|
Connect to `ws://localhost:4000/ws`, then send:
|
||||||
|
```json
|
||||||
|
{"type": "auth", "token": "JWT_TOKEN", "deviceId": "device-123"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Engine (Client)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { SyncEngine } from './sync-client';
|
||||||
|
|
||||||
|
const sync = new SyncEngine('https://your-server.com', 'device-abc');
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
await sync.authenticate('user@example.com', 'password');
|
||||||
|
|
||||||
|
// Listen for events
|
||||||
|
sync.on('status', (status) => console.log('Sync:', status));
|
||||||
|
sync.on('conflict', (conflict) => {
|
||||||
|
// Present both versions to user, let them choose
|
||||||
|
console.log('Conflict:', conflict);
|
||||||
|
});
|
||||||
|
sync.on('remote_change', (change) => {
|
||||||
|
// Update local state with remote changes
|
||||||
|
console.log('Remote change:', change);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start syncing (every 30s + WebSocket push)
|
||||||
|
sync.start();
|
||||||
|
|
||||||
|
// CRUD operations auto-queue for sync
|
||||||
|
await sync.createNote({ type: 'text', title: 'Hello', content: '<p>World</p>' });
|
||||||
|
await sync.updateNote('note-id', { title: 'Updated' });
|
||||||
|
await sync.deleteNote('note-id');
|
||||||
|
await sync.uploadPDF(fileBlob);
|
||||||
|
await sync.saveDrawing('note-id', canvasDataUrl);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conflict Resolution
|
||||||
|
|
||||||
|
InkNote uses **vector clocks** to detect conflicts:
|
||||||
|
|
||||||
|
1. Each device maintains a logical clock counter
|
||||||
|
2. On save, the device increments its counter and includes the full vector clock
|
||||||
|
3. The server compares clocks — if the client clock is strictly newer, the update applies
|
||||||
|
4. If clocks are concurrent (both have changes the other doesn't), a **409 Conflict** is returned
|
||||||
|
5. The client receives both versions and can present a merge UI to the user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extending to Native Platforms
|
||||||
|
|
||||||
|
### React Native
|
||||||
|
Use `note-app.jsx` components with React Native adaptations (replace HTML canvas with `react-native-canvas` or `@shopify/react-native-skia` for drawing). The sync engine works as-is.
|
||||||
|
|
||||||
|
### Flutter
|
||||||
|
Use the sync engine via a JavaScript bridge, or port the API calls to Dart. For drawing, use Flutter's `CustomPainter`. For PDF, use `flutter_pdfview`.
|
||||||
|
|
||||||
|
### Electron
|
||||||
|
Wrap the React app directly in Electron for desktop. Add native file system access for PDF import/export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- All note content is encrypted with AES-256-CBC before database storage
|
||||||
|
- Passwords are hashed with bcrypt (12 rounds)
|
||||||
|
- JWT tokens expire after 30 days
|
||||||
|
- HTTPS should be enforced in production (use a reverse proxy like Nginx or Pangolin)
|
||||||
|
- The encryption key should be stored securely (e.g., environment variable, secrets manager)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-----------|-------------------------------------|
|
||||||
|
| Frontend | React (web), adaptable to RN/Flutter |
|
||||||
|
| Backend | Node.js, Express |
|
||||||
|
| Database | SQLite (dev), PostgreSQL (prod) |
|
||||||
|
| Real-time | WebSocket (ws library) |
|
||||||
|
| Auth | JWT + bcrypt |
|
||||||
|
| Encryption| AES-256-CBC |
|
||||||
|
| Container | Docker + docker-compose |
|
||||||
Reference in new issue
Block a user