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