web/server/collab-rooms.tsblame
View source
0b4b5821import fs from "fs";
0b4b5822import fsp from "fs/promises";
0b4b5823import path from "path";
0b4b5824import * as Y from "yjs";
0b4b5825
0b4b5826// Sanitize user-supplied IDs to prevent path traversal
0b4b5827export function safeId(id: string): string {
0b4b5828 return String(id)
0b4b5829 .replace(/[^a-zA-Z0-9_-]/g, "_")
0b4b58210 .slice(0, 128);
0b4b58211}
0b4b58212
0b4b58213export interface CollabNote {
0b4b58214 id: string;
0b4b58215 author: string;
0b4b58216 text: string;
0b4b58217 x?: number;
0b4b58218 y?: number;
0b4b58219 diagramId: string;
0b4b58220 diagramTitle?: string;
0b4b58221 targetNode?: string | null;
0b4b58222 timestamp: string;
0b4b58223 editedAt?: string;
0b4b58224}
0b4b58225
0b4b58226export interface CollabUser {
0b4b58227 id: string;
0b4b58228 name: string;
0b4b58229 color: string;
0b4b58230 cursor: { x: number; y: number } | null;
0b4b58231 activeTab: string | null;
0b4b58232}
0b4b58233
0b4b58234export interface Room {
0b4b58235 notes: Record<string, CollabNote[]>;
0b4b58236 users: Record<string, CollabUser>;
0b4b58237 ydocs: Record<string, Y.Doc & { _persistTimer?: ReturnType<typeof setTimeout> }>;
0b4b58238}
0b4b58239
0b4b58240// In-memory store per room (keyed by "owner/repo")
0b4b58241const rooms: Record<string, Room> = {};
0b4b58242
0b4b58243export function getRoom(key: string): Room {
0b4b58244 if (!rooms[key]) {
0b4b58245 rooms[key] = { notes: {}, users: {}, ydocs: {} };
0b4b58246 }
0b4b58247 return rooms[key];
0b4b58248}
0b4b58249
0b4b58250export function roomKey(owner: string, repo: string): string {
0b4b58251 return `${safeId(owner)}/${safeId(repo)}`;
0b4b58252}
0b4b58253
0b4b58254// Persistence: repo-scoped data on disk
0b4b58255const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "collab-data");
0b4b58256if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
0b4b58257
0b4b58258function repoDataDir(owner: string, repo: string): string {
0b4b58259 return path.join(DATA_DIR, "repos", safeId(owner), safeId(repo));
0b4b58260}
0b4b58261
0b4b58262// ── Diagram catalog (per-repo) ──
0b4b58263const DIAGRAMS_DEFAULT_PATH = path.join(process.cwd(), "server", "diagrams-default.json");
0b4b58264
0b4b58265export function loadDiagrams(
0b4b58266 owner: string,
0b4b58267 repo: string
0b4b58268): { sections: any[]; diagrams: any[] } {
0b4b58269 const dir = repoDataDir(owner, repo);
0b4b58270 const repoPath = path.join(dir, "diagrams.json");
0b4b58271 if (fs.existsSync(repoPath)) {
0b4b58272 try {
0b4b58273 return JSON.parse(fs.readFileSync(repoPath, "utf-8"));
0b4b58274 } catch (e: any) {
0b4b58275 console.error(`[diagrams] Failed to parse ${repoPath}:`, e.message);
0b4b58276 }
0b4b58277 }
0b4b58278 // Seed from default catalog for new repos
0b4b58279 if (fs.existsSync(DIAGRAMS_DEFAULT_PATH)) {
0b4b58280 const data = fs.readFileSync(DIAGRAMS_DEFAULT_PATH, "utf-8");
0b4b58281 fs.mkdirSync(dir, { recursive: true });
0b4b58282 fs.writeFileSync(repoPath, data);
0b4b58283 return JSON.parse(data);
0b4b58284 }
0b4b58285 return { sections: [], diagrams: [] };
0b4b58286}
0b4b58287
0b4b58288export function persistRoom(owner: string, repo: string): void {
0b4b58289 const key = roomKey(owner, repo);
0b4b58290 const room = rooms[key];
0b4b58291 if (!room) return;
0b4b58292 const dir = repoDataDir(owner, repo);
0b4b58293 fs.mkdirSync(dir, { recursive: true });
0b4b58294 const filePath = path.join(dir, "notes.json");
0b4b58295 fsp
0b4b58296 .writeFile(filePath, JSON.stringify(room.notes, null, 2))
0b4b58297 .catch((e) =>
0b4b58298 console.error(`[persist] Failed to write ${filePath}:`, e.message)
0b4b58299 );
0b4b582100}
0b4b582101
0b4b582102export function loadRoom(owner: string, repo: string): Record<string, CollabNote[]> {
0b4b582103 const filePath = path.join(repoDataDir(owner, repo), "notes.json");
0b4b582104 if (fs.existsSync(filePath)) {
0b4b582105 try {
0b4b582106 return JSON.parse(fs.readFileSync(filePath, "utf-8"));
0b4b582107 } catch {
0b4b582108 return {};
0b4b582109 }
0b4b582110 }
0b4b582111 return {};
0b4b582112}
0b4b582113
0b4b582114// ── Yjs document management ──
0b4b582115export function getYDoc(owner: string, repo: string, diagramId: string): Y.Doc {
0b4b582116 const key = roomKey(owner, repo);
0b4b582117 const room = getRoom(key);
0b4b582118 if (!room.ydocs[diagramId]) {
0b4b582119 const ydoc = new Y.Doc() as Y.Doc & { _persistTimer?: ReturnType<typeof setTimeout> };
0b4b582120 room.ydocs[diagramId] = ydoc;
0b4b582121 const yFilePath = path.join(
0b4b582122 repoDataDir(owner, repo),
0b4b582123 `ydoc_${safeId(diagramId)}.bin`
0b4b582124 );
0b4b582125 if (fs.existsSync(yFilePath)) {
0b4b582126 try {
0b4b582127 const data = fs.readFileSync(yFilePath);
0b4b582128 Y.applyUpdate(ydoc, new Uint8Array(data));
0b4b582129 } catch (e: any) {
0b4b582130 console.error(`[yjs] Failed to load ${yFilePath}:`, e.message);
0b4b582131 }
0b4b582132 }
0b4b582133 }
0b4b582134 return room.ydocs[diagramId];
0b4b582135}
0b4b582136
0b4b582137export function persistYDoc(
0b4b582138 owner: string,
0b4b582139 repo: string,
0b4b582140 diagramId: string
0b4b582141): void {
0b4b582142 const key = roomKey(owner, repo);
0b4b582143 const room = rooms[key];
0b4b582144 if (!room || !room.ydocs[diagramId]) return;
0b4b582145 const ydoc = room.ydocs[diagramId];
0b4b582146 const dir = repoDataDir(owner, repo);
0b4b582147 fs.mkdirSync(dir, { recursive: true });
0b4b582148 const yFilePath = path.join(dir, `ydoc_${safeId(diagramId)}.bin`);
0b4b582149 const state = Y.encodeStateAsUpdate(ydoc);
0b4b582150 fsp
0b4b582151 .writeFile(yFilePath, Buffer.from(state))
0b4b582152 .catch((e) =>
0b4b582153 console.error(`[persist] Failed to write ${yFilePath}:`, e.message)
0b4b582154 );
0b4b582155}
0b4b582156
0b4b582157// ── Repo access control (delegated to grove-api) ──
0b4b582158const GROVE_API_URL =
0b4b582159 process.env.GROVE_API_URL || "http://localhost:4000";
0b4b582160
0b4b582161const accessCache = new Map<
0b4b582162 string,
0b4b582163 { allowed: boolean; expiresAt: number }
0b4b582164>();
0b4b582165const ACCESS_CACHE_TTL = 60 * 1000;
0b4b582166
0b4b582167export async function canAccessRepo(
0b4b582168 owner: string,
0b4b582169 repo: string,
0b4b582170 token: string | null
0b4b582171): Promise<boolean> {
0b4b582172 const cacheKey = `${token ? token.slice(-8) : "anon"}:${owner}/${repo}`;
0b4b582173 const cached = accessCache.get(cacheKey);
0b4b582174 if (cached && cached.expiresAt > Date.now()) return cached.allowed;
0b4b582175 try {
0b4b582176 const headers: Record<string, string> = token
0b4b582177 ? { Authorization: `Bearer ${token}` }
0b4b582178 : {};
0b4b582179 const res = await fetch(
0b4b582180 `${GROVE_API_URL}/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
0b4b582181 { headers }
0b4b582182 );
0b4b582183 const allowed = res.ok;
0b4b582184 accessCache.set(cacheKey, {
0b4b582185 allowed,
0b4b582186 expiresAt: Date.now() + ACCESS_CACHE_TTL,
0b4b582187 });
0b4b582188 return allowed;
0b4b582189 } catch (e: any) {
0b4b582190 console.error(`[access] Failed to check repo access:`, e.message);
0b4b582191 return false;
0b4b582192 }
0b4b582193}
0b4b582194
0b4b582195// ── User colors ──
0b4b582196export const USER_COLORS = [
0b4b582197 "#e74c3c",
0b4b582198 "#3498db",
0b4b582199 "#2ecc71",
0b4b582200 "#f39c12",
0b4b582201 "#9b59b6",
0b4b582202 "#1abc9c",
0b4b582203 "#e67e22",
0b4b582204 "#e84393",
0b4b582205 "#00b894",
0b4b582206 "#6c5ce7",
0b4b582207];
0b4b582208
0b4b582209export function pickColor(room: Room): string {
0b4b582210 const used = new Set(Object.values(room.users).map((u) => u.color));
0b4b582211 for (const c of USER_COLORS) {
0b4b582212 if (!used.has(c)) return c;
0b4b582213 }
0b4b582214 const counts: Record<string, number> = {};
0b4b582215 for (const c of USER_COLORS) counts[c] = 0;
0b4b582216 for (const u of Object.values(room.users)) {
0b4b582217 if (counts[u.color] != null) counts[u.color]++;
0b4b582218 }
0b4b582219 return USER_COLORS.reduce((a, b) => (counts[a] <= counts[b] ? a : b));
0b4b582220}