13.2 KB407 lines
Blame
1const express = require("express");
2const http = require("http");
3const { Server } = require("socket.io");
4const path = require("path");
5const fs = require("fs");
6const fsp = fs.promises;
7const Y = require("yjs");
8const { requireAuth, optionalAuth, socketAuth } = require("./auth");
9
10// Sanitize user-supplied IDs to prevent path traversal
11function safeId(id) {
12 return String(id).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128);
13}
14
15const app = express();
16const server = http.createServer(app);
17const io = new Server(server, { cors: { origin: "*" } });
18
19const PORT = process.env.PORT || 3333;
20const GROVE_API_URL = process.env.GROVE_API_URL || "http://localhost:4000";
21
22// Serve static files (JS, CSS, etc.)
23app.use(express.static(path.join(__dirname, "public")));
24
25// In-memory store per room (keyed by "owner/repo")
26// { roomKey: { notes: { diagramId: [...] }, users: {}, ydocs: { diagramId: Y.Doc } } }
27const rooms = {};
28
29function getRoom(roomKey) {
30 if (!rooms[roomKey]) {
31 rooms[roomKey] = { notes: {}, users: {}, ydocs: {} };
32 }
33 return rooms[roomKey];
34}
35
36function roomKey(owner, repo) {
37 return `${safeId(owner)}/${safeId(repo)}`;
38}
39
40// Persistence: repo-scoped data on disk
41const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
42if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
43
44function repoDataDir(owner, repo) {
45 return path.join(DATA_DIR, "repos", safeId(owner), safeId(repo));
46}
47
48// ── Diagram catalog (per-repo) ──
49const DIAGRAMS_DEFAULT_PATH = path.join(__dirname, "diagrams-default.json");
50
51function loadDiagrams(owner, repo) {
52 const dir = repoDataDir(owner, repo);
53 const repoPath = path.join(dir, "diagrams.json");
54 if (fs.existsSync(repoPath)) {
55 try {
56 return JSON.parse(fs.readFileSync(repoPath, "utf-8"));
57 } catch (e) {
58 console.error(`[diagrams] Failed to parse ${repoPath}:`, e.message);
59 }
60 }
61 // Seed from default catalog for new repos
62 if (fs.existsSync(DIAGRAMS_DEFAULT_PATH)) {
63 const data = fs.readFileSync(DIAGRAMS_DEFAULT_PATH, "utf-8");
64 fs.mkdirSync(dir, { recursive: true });
65 fs.writeFileSync(repoPath, data);
66 return JSON.parse(data);
67 }
68 return { sections: [], diagrams: [] };
69}
70
71function persistRoom(owner, repo) {
72 const key = roomKey(owner, repo);
73 const room = rooms[key];
74 if (!room) return;
75 const dir = repoDataDir(owner, repo);
76 fs.mkdirSync(dir, { recursive: true });
77 const filePath = path.join(dir, "notes.json");
78 fsp.writeFile(filePath, JSON.stringify(room.notes, null, 2)).catch((e) =>
79 console.error(`[persist] Failed to write ${filePath}:`, e.message)
80 );
81}
82
83function loadRoom(owner, repo) {
84 const filePath = path.join(repoDataDir(owner, repo), "notes.json");
85 if (fs.existsSync(filePath)) {
86 try {
87 return JSON.parse(fs.readFileSync(filePath, "utf-8"));
88 } catch {
89 return {};
90 }
91 }
92 return {};
93}
94
95// ── Yjs document management ──
96function getYDoc(owner, repo, diagramId) {
97 const key = roomKey(owner, repo);
98 const room = getRoom(key);
99 if (!room.ydocs[diagramId]) {
100 const ydoc = new Y.Doc();
101 room.ydocs[diagramId] = ydoc;
102 const yFilePath = path.join(repoDataDir(owner, repo), `ydoc_${safeId(diagramId)}.bin`);
103 if (fs.existsSync(yFilePath)) {
104 try {
105 const data = fs.readFileSync(yFilePath);
106 Y.applyUpdate(ydoc, new Uint8Array(data));
107 } catch (e) {
108 console.error(`[yjs] Failed to load ${yFilePath}:`, e.message);
109 }
110 }
111 }
112 return room.ydocs[diagramId];
113}
114
115function persistYDoc(owner, repo, diagramId) {
116 const key = roomKey(owner, repo);
117 const room = rooms[key];
118 if (!room || !room.ydocs[diagramId]) return;
119 const ydoc = room.ydocs[diagramId];
120 const dir = repoDataDir(owner, repo);
121 fs.mkdirSync(dir, { recursive: true });
122 const yFilePath = path.join(dir, `ydoc_${safeId(diagramId)}.bin`);
123 const state = Y.encodeStateAsUpdate(ydoc);
124 fsp.writeFile(yFilePath, Buffer.from(state)).catch((e) =>
125 console.error(`[persist] Failed to write ${yFilePath}:`, e.message)
126 );
127}
128
129// ── Repo access control (delegated to grove-api) ──
130const accessCache = new Map();
131const ACCESS_CACHE_TTL = 60 * 1000;
132
133async function canAccessRepo(owner, repo, token) {
134 const cacheKey = `${token ? token.slice(-8) : "anon"}:${owner}/${repo}`;
135 const cached = accessCache.get(cacheKey);
136 if (cached && cached.expiresAt > Date.now()) return cached.allowed;
137 try {
138 const headers = token ? { Authorization: `Bearer ${token}` } : {};
139 const res = await fetch(`${GROVE_API_URL}/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
140 headers,
141 });
142 const allowed = res.ok;
143 accessCache.set(cacheKey, { allowed, expiresAt: Date.now() + ACCESS_CACHE_TTL });
144 return allowed;
145 } catch (e) {
146 console.error(`[access] Failed to check repo access:`, e.message);
147 return false;
148 }
149}
150
151// ── REST API (authenticated, repo-scoped) ──
152
153app.get("/api/repos/:owner/:repo/diagrams", optionalAuth, async (req, res) => {
154 const { owner, repo } = req.params;
155 if (!(await canAccessRepo(owner, repo, req.token))) {
156 return res.status(404).json({ error: "Not found" });
157 }
158 res.json(loadDiagrams(owner, repo));
159});
160
161app.get("/api/repos/:owner/:repo/notes", requireAuth, async (req, res) => {
162 const { owner, repo } = req.params;
163 if (!(await canAccessRepo(owner, repo, req.token))) {
164 return res.status(404).json({ error: "Not found" });
165 }
166 const room = getRoom(roomKey(owner, repo));
167 res.json(room.notes);
168});
169
170app.get("/api/repos/:owner/:repo/notes/llm", requireAuth, async (req, res) => {
171 const { owner, repo } = req.params;
172 if (!(await canAccessRepo(owner, repo, req.token))) {
173 return res.status(404).json({ error: "Not found" });
174 }
175 const room = getRoom(roomKey(owner, repo));
176 const repoName = `${owner}/${repo}`;
177 const timestamp = new Date().toISOString();
178 let output = `# Diagram Review Notes\n# Repo: ${repoName}\n# Exported: ${timestamp}\n\n`;
179
180 for (const [diagramId, notes] of Object.entries(room.notes)) {
181 if (notes.length === 0) continue;
182 const diagramTitle = notes[0]?.diagramTitle || diagramId;
183 output += `## ${diagramTitle} (${diagramId})\n\n`;
184 for (const note of notes) {
185 const location = note.targetNode
186 ? ` [on: ${note.targetNode}]`
187 : note.x != null ? ` [pinned at (${Math.round(note.x)}, ${Math.round(note.y)})]` : "";
188 output += `- **${note.author}** (${note.timestamp})${location}: ${note.text}\n`;
189 }
190 output += "\n";
191 }
192
193 const filename = `collab-notes-${safeId(owner)}-${safeId(repo)}-${timestamp.slice(0, 10)}.md`;
194 res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
195 res.type("text/markdown").send(output);
196});
197
198// Proxy repo list from grove-api for the homepage
199app.get("/api/repos", optionalAuth, async (req, res) => {
200 try {
201 const headers = req.token ? { Authorization: `Bearer ${req.token}` } : {};
202 const apiRes = await fetch(`${GROVE_API_URL}/api/repos`, { headers });
203 if (!apiRes.ok) return res.status(apiRes.status).json({ repos: [] });
204 const data = await apiRes.json();
205 res.json(data);
206 } catch (e) {
207 console.error("[proxy] Failed to fetch repos:", e.message);
208 res.status(502).json({ repos: [] });
209 }
210});
211
212// Homepage
213app.get("/", (req, res) => {
214 res.sendFile(path.join(__dirname, "public", "index.html"));
215});
216
217// SPA catch-all: serve index.html for /:owner/:repo routes
218app.get("/:owner/:repo", (req, res) => {
219 res.sendFile(path.join(__dirname, "public", "index.html"));
220});
221
222// 404 catch-all
223app.use((req, res) => {
224 res.status(404).sendFile(path.join(__dirname, "public", "404.html"));
225});
226
227// ── Socket.IO collab namespace (authenticated) ──
228const collabNs = io.of("/collab");
229collabNs.use(socketAuth);
230
231collabNs.on("connection", (socket) => {
232 let currentOwner = null;
233 let currentRepo = null;
234 let userName = null;
235
236 socket.on("join-room", async ({ owner, repo }) => {
237 // Verify repo access
238 const allowed = await canAccessRepo(owner, repo, socket.token);
239 if (!allowed) {
240 socket.emit("error", { message: "Access denied" });
241 return;
242 }
243
244 currentOwner = owner;
245 currentRepo = repo;
246 userName = socket.user.display_name || socket.user.username || `User-${socket.id.slice(0, 4)}`;
247
248 const key = roomKey(owner, repo);
249 socket.join(key);
250
251 const room = getRoom(key);
252 // Load persisted notes if room is fresh
253 if (Object.keys(room.notes).length === 0) {
254 room.notes = loadRoom(owner, repo);
255 }
256
257 room.users[socket.id] = {
258 id: socket.id,
259 name: userName,
260 color: pickColor(room),
261 cursor: null,
262 activeTab: null,
263 };
264
265 // Send current state to the joining user
266 socket.emit("room-state", {
267 notes: room.notes,
268 users: room.users,
269 });
270
271 // Notify others
272 collabNs.to(key).emit("users-updated", room.users);
273 });
274
275 // ── Yjs sync protocol over socket.io ──
276 socket.on("yjs-sync", ({ diagramId }) => {
277 if (!currentOwner) return;
278 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
279 const state = Y.encodeStateAsUpdate(ydoc);
280 socket.emit("yjs-sync", { diagramId, update: Buffer.from(state).toString("base64") });
281 });
282
283 socket.on("yjs-update", ({ diagramId, update }) => {
284 if (!currentOwner) return;
285 const key = roomKey(currentOwner, currentRepo);
286 const ydoc = getYDoc(currentOwner, currentRepo, diagramId);
287 const buf = Buffer.from(update, "base64");
288 Y.applyUpdate(ydoc, new Uint8Array(buf));
289 socket.to(key).emit("yjs-update", { diagramId, update });
290 // Persist (debounced per diagram)
291 const owner = currentOwner, repo = currentRepo;
292 clearTimeout(ydoc._persistTimer);
293 ydoc._persistTimer = setTimeout(() => persistYDoc(owner, repo, diagramId), 2000);
294 });
295
296 socket.on("cursor-move", ({ x, y, activeTab }) => {
297 if (!currentOwner) return;
298 const key = roomKey(currentOwner, currentRepo);
299 const room = getRoom(key);
300 if (room.users[socket.id]) {
301 room.users[socket.id].cursor = { x, y };
302 room.users[socket.id].activeTab = activeTab;
303 }
304 socket.to(key).emit("cursor-updated", {
305 userId: socket.id,
306 x,
307 y,
308 activeTab,
309 name: userName,
310 color: room.users[socket.id]?.color,
311 });
312 });
313
314 socket.on("add-note", (note) => {
315 if (!currentOwner) return;
316 const key = roomKey(currentOwner, currentRepo);
317 const room = getRoom(key);
318 const diagramId = note.diagramId;
319 if (!room.notes[diagramId]) room.notes[diagramId] = [];
320
321 const fullNote = {
322 id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
323 author: userName,
324 text: note.text,
325 x: note.x,
326 y: note.y,
327 diagramId,
328 diagramTitle: note.diagramTitle || diagramId,
329 targetNode: note.targetNode || null,
330 timestamp: new Date().toISOString(),
331 };
332
333 room.notes[diagramId].push(fullNote);
334 persistRoom(currentOwner, currentRepo);
335 collabNs.to(key).emit("note-added", fullNote);
336 });
337
338 socket.on("edit-note", ({ noteId, diagramId, text }) => {
339 if (!currentOwner) return;
340 const key = roomKey(currentOwner, currentRepo);
341 const room = getRoom(key);
342 const notes = room.notes[diagramId];
343 if (!notes) return;
344 const note = notes.find((n) => n.id === noteId);
345 if (!note) return;
346 note.text = text;
347 note.editedAt = new Date().toISOString();
348 persistRoom(currentOwner, currentRepo);
349 collabNs.to(key).emit("note-edited", { noteId, diagramId, text, editedAt: note.editedAt });
350 });
351
352 socket.on("diagram-code", ({ diagramId, code }) => {
353 if (!currentOwner) return;
354 const key = roomKey(currentOwner, currentRepo);
355 socket.to(key).emit("diagram-code", { diagramId, code, userId: socket.id });
356 });
357
358 socket.on("delete-note", ({ noteId, diagramId }) => {
359 if (!currentOwner) return;
360 const key = roomKey(currentOwner, currentRepo);
361 const room = getRoom(key);
362 if (!room.notes[diagramId]) return;
363 room.notes[diagramId] = room.notes[diagramId].filter((n) => n.id !== noteId);
364 persistRoom(currentOwner, currentRepo);
365 collabNs.to(key).emit("note-deleted", { noteId, diagramId });
366 });
367
368 socket.on("disconnect", () => {
369 if (!currentOwner) return;
370 const key = roomKey(currentOwner, currentRepo);
371 const room = getRoom(key);
372 delete room.users[socket.id];
373 collabNs.to(key).emit("users-updated", room.users);
374 collabNs.to(key).emit("cursor-removed", { userId: socket.id });
375 });
376});
377
378const USER_COLORS = [
379 "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6",
380 "#1abc9c", "#e67e22", "#e84393", "#00b894", "#6c5ce7",
381];
382
383function pickColor(room) {
384 const used = new Set(Object.values(room.users).map(u => u.color));
385 for (const c of USER_COLORS) {
386 if (!used.has(c)) return c;
387 }
388 const counts = {};
389 for (const c of USER_COLORS) counts[c] = 0;
390 for (const u of Object.values(room.users)) {
391 if (counts[u.color] != null) counts[u.color]++;
392 }
393 return USER_COLORS.reduce((a, b) => counts[a] <= counts[b] ? a : b);
394}
395
396// Live reload: watch public/ for changes and notify all connected clients
397const PUBLIC_DIR = path.join(__dirname, "public");
398fs.watch(PUBLIC_DIR, { recursive: true }, (eventType, filename) => {
399 if (!filename) return;
400 console.log(`[hot-reload] ${filename} changed`);
401 io.emit("hot-reload", { file: filename });
402});
403
404server.listen(PORT, () => {
405 console.log(`Grove Collab running on http://localhost:${PORT}`);
406});
407