collab/server.jsblame
View source
bdb18c91const express = require("express");
bdb18c92const http = require("http");
bdb18c93const { Server } = require("socket.io");
bdb18c94const path = require("path");
bdb18c95const fs = require("fs");
be59e716const fsp = fs.promises;
bdb18c97const Y = require("yjs");
515cc688const { requireAuth, optionalAuth, socketAuth } = require("./auth");
bdb18c99
be59e7110// Sanitize user-supplied IDs to prevent path traversal
be59e7111function safeId(id) {
be59e7112 return String(id).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128);
be59e7113}
be59e7114
bdb18c915const app = express();
bdb18c916const server = http.createServer(app);
bdb18c917const io = new Server(server, { cors: { origin: "*" } });
bdb18c918
bdb18c919const PORT = process.env.PORT || 3333;
2a9592c20const GROVE_API_URL = process.env.GROVE_API_URL || "http://localhost:4000";
bdb18c921
2a9592c22// Serve static files (JS, CSS, etc.)
bdb18c923app.use(express.static(path.join(__dirname, "public")));
bdb18c924
2a9592c25// In-memory store per room (keyed by "owner/repo")
2a9592c26// { roomKey: { notes: { diagramId: [...] }, users: {}, ydocs: { diagramId: Y.Doc } } }
bdb18c927const rooms = {};
bdb18c928
2a9592c29function getRoom(roomKey) {
2a9592c30 if (!rooms[roomKey]) {
2a9592c31 rooms[roomKey] = { notes: {}, users: {}, ydocs: {} };
bdb18c932 }
2a9592c33 return rooms[roomKey];
bdb18c934}
bdb18c935
2a9592c36function roomKey(owner, repo) {
2a9592c37 return `${safeId(owner)}/${safeId(repo)}`;
2a9592c38}
2a9592c39
2a9592c40// Persistence: repo-scoped data on disk
bdb18c941const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
bdb18c942if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
bdb18c943
2a9592c44function repoDataDir(owner, repo) {
2a9592c45 return path.join(DATA_DIR, "repos", safeId(owner), safeId(repo));
2a9592c46}
2a9592c47
2a9592c48// ── Diagram catalog (per-repo) ──
1e755c049const DIAGRAMS_DEFAULT_PATH = path.join(__dirname, "diagrams-default.json");
1e755c050
2a9592c51function loadDiagrams(owner, repo) {
2a9592c52 const dir = repoDataDir(owner, repo);
2a9592c53 const repoPath = path.join(dir, "diagrams.json");
2a9592c54 if (fs.existsSync(repoPath)) {
1e755c055 try {
2a9592c56 return JSON.parse(fs.readFileSync(repoPath, "utf-8"));
1e755c057 } catch (e) {
2a9592c58 console.error(`[diagrams] Failed to parse ${repoPath}:`, e.message);
1e755c059 }
1e755c060 }
2a9592c61 // Seed from default catalog for new repos
1e755c062 if (fs.existsSync(DIAGRAMS_DEFAULT_PATH)) {
1e755c063 const data = fs.readFileSync(DIAGRAMS_DEFAULT_PATH, "utf-8");
2a9592c64 fs.mkdirSync(dir, { recursive: true });
2a9592c65 fs.writeFileSync(repoPath, data);
1e755c066 return JSON.parse(data);
1e755c067 }
1e755c068 return { sections: [], diagrams: [] };
1e755c069}
1e755c070
2a9592c71function persistRoom(owner, repo) {
2a9592c72 const key = roomKey(owner, repo);
2a9592c73 const room = rooms[key];
bdb18c974 if (!room) return;
2a9592c75 const dir = repoDataDir(owner, repo);
2a9592c76 fs.mkdirSync(dir, { recursive: true });
2a9592c77 const filePath = path.join(dir, "notes.json");
be59e7178 fsp.writeFile(filePath, JSON.stringify(room.notes, null, 2)).catch((e) =>
be59e7179 console.error(`[persist] Failed to write ${filePath}:`, e.message)
be59e7180 );
bdb18c981}
bdb18c982
2a9592c83function loadRoom(owner, repo) {
2a9592c84 const filePath = path.join(repoDataDir(owner, repo), "notes.json");
bdb18c985 if (fs.existsSync(filePath)) {
bdb18c986 try {
bdb18c987 return JSON.parse(fs.readFileSync(filePath, "utf-8"));
bdb18c988 } catch {
bdb18c989 return {};
bdb18c990 }
bdb18c991 }
bdb18c992 return {};
bdb18c993}
bdb18c994
bdb18c995// ── Yjs document management ──
2a9592c96function getYDoc(owner, repo, diagramId) {
2a9592c97 const key = roomKey(owner, repo);
2a9592c98 const room = getRoom(key);
bdb18c999 if (!room.ydocs[diagramId]) {
bdb18c9100 const ydoc = new Y.Doc();
bdb18c9101 room.ydocs[diagramId] = ydoc;
2a9592c102 const yFilePath = path.join(repoDataDir(owner, repo), `ydoc_${safeId(diagramId)}.bin`);
bdb18c9103 if (fs.existsSync(yFilePath)) {
bdb18c9104 try {
bdb18c9105 const data = fs.readFileSync(yFilePath);
bdb18c9106 Y.applyUpdate(ydoc, new Uint8Array(data));
bdb18c9107 } catch (e) {
bdb18c9108 console.error(`[yjs] Failed to load ${yFilePath}:`, e.message);
bdb18c9109 }
bdb18c9110 }
bdb18c9111 }
bdb18c9112 return room.ydocs[diagramId];
bdb18c9113}
bdb18c9114
2a9592c115function persistYDoc(owner, repo, diagramId) {
2a9592c116 const key = roomKey(owner, repo);
2a9592c117 const room = rooms[key];
bdb18c9118 if (!room || !room.ydocs[diagramId]) return;
bdb18c9119 const ydoc = room.ydocs[diagramId];
2a9592c120 const dir = repoDataDir(owner, repo);
2a9592c121 fs.mkdirSync(dir, { recursive: true });
2a9592c122 const yFilePath = path.join(dir, `ydoc_${safeId(diagramId)}.bin`);
bdb18c9123 const state = Y.encodeStateAsUpdate(ydoc);
be59e71124 fsp.writeFile(yFilePath, Buffer.from(state)).catch((e) =>
be59e71125 console.error(`[persist] Failed to write ${yFilePath}:`, e.message)
be59e71126 );
bdb18c9127}
bdb18c9128
2a9592c129// ── Repo access control (delegated to grove-api) ──
2a9592c130const accessCache = new Map();
2a9592c131const ACCESS_CACHE_TTL = 60 * 1000;
2a9592c132
2a9592c133async function canAccessRepo(owner, repo, token) {
515cc68134 const cacheKey = `${token ? token.slice(-8) : "anon"}:${owner}/${repo}`;
2a9592c135 const cached = accessCache.get(cacheKey);
2a9592c136 if (cached && cached.expiresAt > Date.now()) return cached.allowed;
2a9592c137 try {
515cc68138 const headers = token ? { Authorization: `Bearer ${token}` } : {};
2a9592c139 const res = await fetch(`${GROVE_API_URL}/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
515cc68140 headers,
2a9592c141 });
2a9592c142 const allowed = res.ok;
2a9592c143 accessCache.set(cacheKey, { allowed, expiresAt: Date.now() + ACCESS_CACHE_TTL });
2a9592c144 return allowed;
2a9592c145 } catch (e) {
2a9592c146 console.error(`[access] Failed to check repo access:`, e.message);
2a9592c147 return false;
2a9592c148 }
2a9592c149}
2a9592c150
2a9592c151// ── REST API (authenticated, repo-scoped) ──
2a9592c152
515cc68153app.get("/api/repos/:owner/:repo/diagrams", optionalAuth, async (req, res) => {
2a9592c154 const { owner, repo } = req.params;
2a9592c155 if (!(await canAccessRepo(owner, repo, req.token))) {
2a9592c156 return res.status(404).json({ error: "Not found" });
2a9592c157 }
2a9592c158 res.json(loadDiagrams(owner, repo));
2a9592c159});
2a9592c160
2a9592c161app.get("/api/repos/:owner/:repo/notes", requireAuth, async (req, res) => {
2a9592c162 const { owner, repo } = req.params;
2a9592c163 if (!(await canAccessRepo(owner, repo, req.token))) {
2a9592c164 return res.status(404).json({ error: "Not found" });
2a9592c165 }
2a9592c166 const room = getRoom(roomKey(owner, repo));
bdb18c9167 res.json(room.notes);
bdb18c9168});
bdb18c9169
2a9592c170app.get("/api/repos/:owner/:repo/notes/llm", requireAuth, async (req, res) => {
2a9592c171 const { owner, repo } = req.params;
2a9592c172 if (!(await canAccessRepo(owner, repo, req.token))) {
2a9592c173 return res.status(404).json({ error: "Not found" });
2a9592c174 }
2a9592c175 const room = getRoom(roomKey(owner, repo));
2a9592c176 const repoName = `${owner}/${repo}`;
bdb18c9177 const timestamp = new Date().toISOString();
2a9592c178 let output = `# Diagram Review Notes\n# Repo: ${repoName}\n# Exported: ${timestamp}\n\n`;
bdb18c9179
bdb18c9180 for (const [diagramId, notes] of Object.entries(room.notes)) {
bdb18c9181 if (notes.length === 0) continue;
bdb18c9182 const diagramTitle = notes[0]?.diagramTitle || diagramId;
bdb18c9183 output += `## ${diagramTitle} (${diagramId})\n\n`;
bdb18c9184 for (const note of notes) {
bdb18c9185 const location = note.targetNode
bdb18c9186 ? ` [on: ${note.targetNode}]`
bdb18c9187 : note.x != null ? ` [pinned at (${Math.round(note.x)}, ${Math.round(note.y)})]` : "";
bdb18c9188 output += `- **${note.author}** (${note.timestamp})${location}: ${note.text}\n`;
bdb18c9189 }
bdb18c9190 output += "\n";
bdb18c9191 }
bdb18c9192
2a9592c193 const filename = `collab-notes-${safeId(owner)}-${safeId(repo)}-${timestamp.slice(0, 10)}.md`;
bdb18c9194 res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
bdb18c9195 res.type("text/markdown").send(output);
bdb18c9196});
bdb18c9197
922dd18198// Proxy repo list from grove-api for the homepage
515cc68199app.get("/api/repos", optionalAuth, async (req, res) => {
922dd18200 try {
515cc68201 const headers = req.token ? { Authorization: `Bearer ${req.token}` } : {};
515cc68202 const apiRes = await fetch(`${GROVE_API_URL}/api/repos`, { headers });
922dd18203 if (!apiRes.ok) return res.status(apiRes.status).json({ repos: [] });
922dd18204 const data = await apiRes.json();
922dd18205 res.json(data);
922dd18206 } catch (e) {
922dd18207 console.error("[proxy] Failed to fetch repos:", e.message);
922dd18208 res.status(502).json({ repos: [] });
922dd18209 }
922dd18210});
922dd18211
922dd18212// Homepage
922dd18213app.get("/", (req, res) => {
922dd18214 res.sendFile(path.join(__dirname, "public", "index.html"));
922dd18215});
922dd18216
2a9592c217// SPA catch-all: serve index.html for /:owner/:repo routes
2a9592c218app.get("/:owner/:repo", (req, res) => {
2a9592c219 res.sendFile(path.join(__dirname, "public", "index.html"));
1e755c0220});
1e755c0221
bf6031c222// 404 catch-all
bf6031c223app.use((req, res) => {
bf6031c224 res.status(404).sendFile(path.join(__dirname, "public", "404.html"));
bf6031c225});
bf6031c226
2a9592c227// ── Socket.IO collab namespace (authenticated) ──
be59e71228const collabNs = io.of("/collab");
2a9592c229collabNs.use(socketAuth);
be59e71230
be59e71231collabNs.on("connection", (socket) => {
2a9592c232 let currentOwner = null;
2a9592c233 let currentRepo = null;
bdb18c9234 let userName = null;
bdb18c9235
2a9592c236 socket.on("join-room", async ({ owner, repo }) => {
2a9592c237 // Verify repo access
2a9592c238 const allowed = await canAccessRepo(owner, repo, socket.token);
2a9592c239 if (!allowed) {
2a9592c240 socket.emit("error", { message: "Access denied" });
2a9592c241 return;
2a9592c242 }
2a9592c243
2a9592c244 currentOwner = owner;
2a9592c245 currentRepo = repo;
2a9592c246 userName = socket.user.display_name || socket.user.username || `User-${socket.id.slice(0, 4)}`;
bdb18c9247
2a9592c248 const key = roomKey(owner, repo);
2a9592c249 socket.join(key);
bdb18c9250
2a9592c251 const room = getRoom(key);
bdb18c9252 // Load persisted notes if room is fresh
bdb18c9253 if (Object.keys(room.notes).length === 0) {
2a9592c254 room.notes = loadRoom(owner, repo);
bdb18c9255 }
bdb18c9256
bdb18c9257 room.users[socket.id] = {
bdb18c9258 id: socket.id,
bdb18c9259 name: userName,
bdb18c9260 color: pickColor(room),
bdb18c9261 cursor: null,
bdb18c9262 activeTab: null,
bdb18c9263 };
bdb18c9264
bdb18c9265 // Send current state to the joining user
bdb18c9266 socket.emit("room-state", {
bdb18c9267 notes: room.notes,
bdb18c9268 users: room.users,
bdb18c9269 });
bdb18c9270
bdb18c9271 // Notify others
2a9592c272 collabNs.to(key).emit("users-updated", room.users);
bdb18c9273 });
bdb18c9274
bdb18c9275 // ── Yjs sync protocol over socket.io ──
bdb18c9276 socket.on("yjs-sync", ({ diagramId }) => {
2a9592c277 if (!currentOwner) return;
2a9592c278 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
bdb18c9279 const state = Y.encodeStateAsUpdate(ydoc);
bdb18c9280 socket.emit("yjs-sync", { diagramId, update: Buffer.from(state).toString("base64") });
bdb18c9281 });
bdb18c9282
bdb18c9283 socket.on("yjs-update", ({ diagramId, update }) => {
2a9592c284 if (!currentOwner) return;
2a9592c285 const key = roomKey(currentOwner, currentRepo);
2a9592c286 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
bdb18c9287 const buf = Buffer.from(update, "base64");
bdb18c9288 Y.applyUpdate(ydoc, new Uint8Array(buf));
2a9592c289 socket.to(key).emit("yjs-update", { diagramId, update });
2a9592c290 // Persist (debounced per diagram)
2a9592c291 const owner = currentOwner, repo = currentRepo;
bdb18c9292 clearTimeout(ydoc._persistTimer);
2a9592c293 ydoc._persistTimer = setTimeout(() => persistYDoc(owner, repo, diagramId), 2000);
bdb18c9294 });
bdb18c9295
bdb18c9296 socket.on("cursor-move", ({ x, y, activeTab }) => {
2a9592c297 if (!currentOwner) return;
2a9592c298 const key = roomKey(currentOwner, currentRepo);
2a9592c299 const room = getRoom(key);
bdb18c9300 if (room.users[socket.id]) {
bdb18c9301 room.users[socket.id].cursor = { x, y };
bdb18c9302 room.users[socket.id].activeTab = activeTab;
bdb18c9303 }
2a9592c304 socket.to(key).emit("cursor-updated", {
bdb18c9305 userId: socket.id,
bdb18c9306 x,
bdb18c9307 y,
bdb18c9308 activeTab,
bdb18c9309 name: userName,
bdb18c9310 color: room.users[socket.id]?.color,
bdb18c9311 });
bdb18c9312 });
bdb18c9313
bdb18c9314 socket.on("add-note", (note) => {
2a9592c315 if (!currentOwner) return;
2a9592c316 const key = roomKey(currentOwner, currentRepo);
2a9592c317 const room = getRoom(key);
bdb18c9318 const diagramId = note.diagramId;
bdb18c9319 if (!room.notes[diagramId]) room.notes[diagramId] = [];
bdb18c9320
bdb18c9321 const fullNote = {
bdb18c9322 id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
bdb18c9323 author: userName,
bdb18c9324 text: note.text,
bdb18c9325 x: note.x,
bdb18c9326 y: note.y,
bdb18c9327 diagramId,
bdb18c9328 diagramTitle: note.diagramTitle || diagramId,
bdb18c9329 targetNode: note.targetNode || null,
bdb18c9330 timestamp: new Date().toISOString(),
bdb18c9331 };
bdb18c9332
bdb18c9333 room.notes[diagramId].push(fullNote);
2a9592c334 persistRoom(currentOwner, currentRepo);
2a9592c335 collabNs.to(key).emit("note-added", fullNote);
bdb18c9336 });
bdb18c9337
bdb18c9338 socket.on("edit-note", ({ noteId, diagramId, text }) => {
2a9592c339 if (!currentOwner) return;
2a9592c340 const key = roomKey(currentOwner, currentRepo);
2a9592c341 const room = getRoom(key);
bdb18c9342 const notes = room.notes[diagramId];
bdb18c9343 if (!notes) return;
bdb18c9344 const note = notes.find((n) => n.id === noteId);
bdb18c9345 if (!note) return;
bdb18c9346 note.text = text;
bdb18c9347 note.editedAt = new Date().toISOString();
2a9592c348 persistRoom(currentOwner, currentRepo);
2a9592c349 collabNs.to(key).emit("note-edited", { noteId, diagramId, text, editedAt: note.editedAt });
bdb18c9350 });
bdb18c9351
bdb18c9352 socket.on("diagram-code", ({ diagramId, code }) => {
2a9592c353 if (!currentOwner) return;
2a9592c354 const key = roomKey(currentOwner, currentRepo);
2a9592c355 socket.to(key).emit("diagram-code", { diagramId, code, userId: socket.id });
bdb18c9356 });
bdb18c9357
bdb18c9358 socket.on("delete-note", ({ noteId, diagramId }) => {
2a9592c359 if (!currentOwner) return;
2a9592c360 const key = roomKey(currentOwner, currentRepo);
2a9592c361 const room = getRoom(key);
bdb18c9362 if (!room.notes[diagramId]) return;
bdb18c9363 room.notes[diagramId] = room.notes[diagramId].filter((n) => n.id !== noteId);
2a9592c364 persistRoom(currentOwner, currentRepo);
2a9592c365 collabNs.to(key).emit("note-deleted", { noteId, diagramId });
bdb18c9366 });
bdb18c9367
bdb18c9368 socket.on("disconnect", () => {
2a9592c369 if (!currentOwner) return;
2a9592c370 const key = roomKey(currentOwner, currentRepo);
2a9592c371 const room = getRoom(key);
bdb18c9372 delete room.users[socket.id];
2a9592c373 collabNs.to(key).emit("users-updated", room.users);
2a9592c374 collabNs.to(key).emit("cursor-removed", { userId: socket.id });
bdb18c9375 });
bdb18c9376});
bdb18c9377
bdb18c9378const USER_COLORS = [
bdb18c9379 "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6",
bdb18c9380 "#1abc9c", "#e67e22", "#e84393", "#00b894", "#6c5ce7",
bdb18c9381];
bdb18c9382
bdb18c9383function pickColor(room) {
bdb18c9384 const used = new Set(Object.values(room.users).map(u => u.color));
bdb18c9385 for (const c of USER_COLORS) {
bdb18c9386 if (!used.has(c)) return c;
bdb18c9387 }
bdb18c9388 const counts = {};
bdb18c9389 for (const c of USER_COLORS) counts[c] = 0;
bdb18c9390 for (const u of Object.values(room.users)) {
bdb18c9391 if (counts[u.color] != null) counts[u.color]++;
bdb18c9392 }
bdb18c9393 return USER_COLORS.reduce((a, b) => counts[a] <= counts[b] ? a : b);
bdb18c9394}
bdb18c9395
bdb18c9396// Live reload: watch public/ for changes and notify all connected clients
bdb18c9397const PUBLIC_DIR = path.join(__dirname, "public");
bdb18c9398fs.watch(PUBLIC_DIR, { recursive: true }, (eventType, filename) => {
bdb18c9399 if (!filename) return;
bdb18c9400 console.log(`[hot-reload] ${filename} changed`);
bdb18c9401 io.emit("hot-reload", { file: filename });
bdb18c9402});
bdb18c9403
bdb18c9404server.listen(PORT, () => {
2a9592c405 console.log(`Grove Collab running on http://localhost:${PORT}`);
bdb18c9406});