First Commit

This commit is contained in:
Honey committed 2026-02-17 19:25:52 +01:00
1 parent fa15d48a52
commit 4709096ab3
8 files changed
+2208

No files matched your search

View File
File renamed without changes.
+14
View File
@@ -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"]
+22
View File
@@ -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"
}
}
+476
View File
@@ -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`);
});
+311
View File
@@ -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 };
+57
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+212
View File
@@ -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 |