6.1 KB221 lines
Blame
1import fs from "fs";
2import fsp from "fs/promises";
3import path from "path";
4import * as Y from "yjs";
5
6// Sanitize user-supplied IDs to prevent path traversal
7export function safeId(id: string): string {
8 return String(id)
9 .replace(/[^a-zA-Z0-9_-]/g, "_")
10 .slice(0, 128);
11}
12
13export interface CollabNote {
14 id: string;
15 author: string;
16 text: string;
17 x?: number;
18 y?: number;
19 diagramId: string;
20 diagramTitle?: string;
21 targetNode?: string | null;
22 timestamp: string;
23 editedAt?: string;
24}
25
26export interface CollabUser {
27 id: string;
28 name: string;
29 color: string;
30 cursor: { x: number; y: number } | null;
31 activeTab: string | null;
32}
33
34export interface Room {
35 notes: Record<string, CollabNote[]>;
36 users: Record<string, CollabUser>;
37 ydocs: Record<string, Y.Doc & { _persistTimer?: ReturnType<typeof setTimeout> }>;
38}
39
40// In-memory store per room (keyed by "owner/repo")
41const rooms: Record<string, Room> = {};
42
43export function getRoom(key: string): Room {
44 if (!rooms[key]) {
45 rooms[key] = { notes: {}, users: {}, ydocs: {} };
46 }
47 return rooms[key];
48}
49
50export function roomKey(owner: string, repo: string): string {
51 return `${safeId(owner)}/${safeId(repo)}`;
52}
53
54// Persistence: repo-scoped data on disk
55const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "collab-data");
56if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
57
58function repoDataDir(owner: string, repo: string): string {
59 return path.join(DATA_DIR, "repos", safeId(owner), safeId(repo));
60}
61
62// ── Diagram catalog (per-repo) ──
63const DIAGRAMS_DEFAULT_PATH = path.join(process.cwd(), "server", "diagrams-default.json");
64
65export function loadDiagrams(
66 owner: string,
67 repo: string
68): { sections: any[]; diagrams: any[] } {
69 const dir = repoDataDir(owner, repo);
70 const repoPath = path.join(dir, "diagrams.json");
71 if (fs.existsSync(repoPath)) {
72 try {
73 return JSON.parse(fs.readFileSync(repoPath, "utf-8"));
74 } catch (e: any) {
75 console.error(`[diagrams] Failed to parse ${repoPath}:`, e.message);
76 }
77 }
78 // Seed from default catalog for new repos
79 if (fs.existsSync(DIAGRAMS_DEFAULT_PATH)) {
80 const data = fs.readFileSync(DIAGRAMS_DEFAULT_PATH, "utf-8");
81 fs.mkdirSync(dir, { recursive: true });
82 fs.writeFileSync(repoPath, data);
83 return JSON.parse(data);
84 }
85 return { sections: [], diagrams: [] };
86}
87
88export function persistRoom(owner: string, repo: string): void {
89 const key = roomKey(owner, repo);
90 const room = rooms[key];
91 if (!room) return;
92 const dir = repoDataDir(owner, repo);
93 fs.mkdirSync(dir, { recursive: true });
94 const filePath = path.join(dir, "notes.json");
95 fsp
96 .writeFile(filePath, JSON.stringify(room.notes, null, 2))
97 .catch((e) =>
98 console.error(`[persist] Failed to write ${filePath}:`, e.message)
99 );
100}
101
102export function loadRoom(owner: string, repo: string): Record<string, CollabNote[]> {
103 const filePath = path.join(repoDataDir(owner, repo), "notes.json");
104 if (fs.existsSync(filePath)) {
105 try {
106 return JSON.parse(fs.readFileSync(filePath, "utf-8"));
107 } catch {
108 return {};
109 }
110 }
111 return {};
112}
113
114// ── Yjs document management ──
115export function getYDoc(owner: string, repo: string, diagramId: string): Y.Doc {
116 const key = roomKey(owner, repo);
117 const room = getRoom(key);
118 if (!room.ydocs[diagramId]) {
119 const ydoc = new Y.Doc() as Y.Doc & { _persistTimer?: ReturnType<typeof setTimeout> };
120 room.ydocs[diagramId] = ydoc;
121 const yFilePath = path.join(
122 repoDataDir(owner, repo),
123 `ydoc_${safeId(diagramId)}.bin`
124 );
125 if (fs.existsSync(yFilePath)) {
126 try {
127 const data = fs.readFileSync(yFilePath);
128 Y.applyUpdate(ydoc, new Uint8Array(data));
129 } catch (e: any) {
130 console.error(`[yjs] Failed to load ${yFilePath}:`, e.message);
131 }
132 }
133 }
134 return room.ydocs[diagramId];
135}
136
137export function persistYDoc(
138 owner: string,
139 repo: string,
140 diagramId: string
141): void {
142 const key = roomKey(owner, repo);
143 const room = rooms[key];
144 if (!room || !room.ydocs[diagramId]) return;
145 const ydoc = room.ydocs[diagramId];
146 const dir = repoDataDir(owner, repo);
147 fs.mkdirSync(dir, { recursive: true });
148 const yFilePath = path.join(dir, `ydoc_${safeId(diagramId)}.bin`);
149 const state = Y.encodeStateAsUpdate(ydoc);
150 fsp
151 .writeFile(yFilePath, Buffer.from(state))
152 .catch((e) =>
153 console.error(`[persist] Failed to write ${yFilePath}:`, e.message)
154 );
155}
156
157// ── Repo access control (delegated to grove-api) ──
158const GROVE_API_URL =
159 process.env.GROVE_API_URL || "http://localhost:4000";
160
161const accessCache = new Map<
162 string,
163 { allowed: boolean; expiresAt: number }
164>();
165const ACCESS_CACHE_TTL = 60 * 1000;
166
167export async function canAccessRepo(
168 owner: string,
169 repo: string,
170 token: string | null
171): Promise<boolean> {
172 const cacheKey = `${token ? token.slice(-8) : "anon"}:${owner}/${repo}`;
173 const cached = accessCache.get(cacheKey);
174 if (cached && cached.expiresAt > Date.now()) return cached.allowed;
175 try {
176 const headers: Record<string, string> = token
177 ? { Authorization: `Bearer ${token}` }
178 : {};
179 const res = await fetch(
180 `${GROVE_API_URL}/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
181 { headers }
182 );
183 const allowed = res.ok;
184 accessCache.set(cacheKey, {
185 allowed,
186 expiresAt: Date.now() + ACCESS_CACHE_TTL,
187 });
188 return allowed;
189 } catch (e: any) {
190 console.error(`[access] Failed to check repo access:`, e.message);
191 return false;
192 }
193}
194
195// ── User colors ──
196export const USER_COLORS = [
197 "#e74c3c",
198 "#3498db",
199 "#2ecc71",
200 "#f39c12",
201 "#9b59b6",
202 "#1abc9c",
203 "#e67e22",
204 "#e84393",
205 "#00b894",
206 "#6c5ce7",
207];
208
209export function pickColor(room: Room): string {
210 const used = new Set(Object.values(room.users).map((u) => u.color));
211 for (const c of USER_COLORS) {
212 if (!used.has(c)) return c;
213 }
214 const counts: Record<string, number> = {};
215 for (const c of USER_COLORS) counts[c] = 0;
216 for (const u of Object.values(room.users)) {
217 if (counts[u.color] != null) counts[u.color]++;
218 }
219 return USER_COLORS.reduce((a, b) => (counts[a] <= counts[b] ? a : b));
220}
221