feat(collab): add server-authoritative sync and preview-only updates

This commit is contained in:
2026-02-13 20:47:05 +01:00
parent fd5470ada5
commit 0ffe410eeb
11 changed files with 792 additions and 273 deletions
+6 -2
View File
@@ -16,7 +16,7 @@ import {
sanitizeDrawingData,
validateImportedDrawing,
sanitizeText,
sanitizeSvg,
sanitizePreview,
elementSchema,
appStateSchema,
} from "./security";
@@ -29,6 +29,7 @@ import { registerDashboardRoutes } from "./routes/dashboard";
import { registerImportExportRoutes } from "./routes/importExport";
import { prisma } from "./db/prisma";
import { createDrawingsCacheStore } from "./server/drawingsCache";
import { createCollabSessionManager } from "./server/collabSession";
import { registerCsrfProtection } from "./server/csrf";
import { registerSocketHandlers } from "./server/socket";
import { getClientIp } from "./utils/clientIp";
@@ -173,6 +174,7 @@ const {
cacheDrawingsResponse,
invalidateDrawingsCache,
} = createDrawingsCacheStore(DRAWINGS_CACHE_TTL_MS);
const collabSessionManager = createCollabSessionManager({ prisma });
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
@@ -430,7 +432,7 @@ export const sanitizeDrawingUpdateData = (
Object.assign(data, sanitizedData);
} else if (hasPreviewField && typeof data.preview === "string") {
// Preview-only updates must not inject default scene fields.
data.preview = sanitizeSvg(data.preview);
data.preview = sanitizePreview(data.preview);
Object.assign(data, { ...data, preview: data.preview });
} else if (hasPreviewField && data.preview === null) {
// Explicitly allow clearing preview without touching scene data.
@@ -564,6 +566,7 @@ registerSocketHandlers({
prisma,
authModeService,
jwtSecret: config.jwtSecret,
collabSessionManager,
});
apiApp.get("/health", (req, res) => {
@@ -589,6 +592,7 @@ registerDashboardRoutes(apiApp, {
buildDrawingsCacheKey,
getCachedDrawingsBody,
cacheDrawingsResponse,
collabSessionManager,
MAX_PAGE_SIZE,
config,
logAuditEvent,
+96
View File
@@ -58,6 +58,7 @@ export const registerDrawingRoutes = (
buildDrawingsCacheKey,
getCachedDrawingsBody,
cacheDrawingsResponse,
collabSessionManager,
MAX_PAGE_SIZE,
config,
logAuditEvent,
@@ -446,6 +447,57 @@ export const registerDrawingRoutes = (
});
}));
app.get("/drawings/:id/scene-meta", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
});
if (!access) return res.status(404).json({ error: "Drawing not found" });
const meta = await collabSessionManager.getMeta(id);
if (!meta) {
return res.status(404).json({ error: "Drawing not found" });
}
return res.json({
drawingId: id,
seq: meta.seq,
dbVersion: meta.dbVersion,
updatedAt: access.drawing.updatedAt,
});
}));
app.post("/drawings/:id/flush", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
});
if (!access) return res.status(404).json({ error: "Drawing not found" });
if (!isAtLeastRole(access.role, "editor")) {
return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" });
}
await collabSessionManager.flushSession(id);
const meta = await collabSessionManager.getMeta(id);
return res.json({
success: true,
drawingId: id,
seq: meta?.seq ?? null,
dbVersion: meta?.dbVersion ?? null,
});
}));
app.post("/drawings", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
@@ -630,6 +682,50 @@ export const registerDrawingRoutes = (
});
}));
app.put("/drawings/:id/preview", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
if (!id) {
return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
}
const access = await resolveDrawingAccess({
prisma,
drawingId: id,
userId: req.user.id,
});
if (!access) return res.status(404).json({ error: "Drawing not found" });
if (!isAtLeastRole(access.role, "editor")) {
return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" });
}
const parsed = drawingUpdateSchema.safeParse({ preview: req.body?.preview });
if (!parsed.success) {
if (config.nodeEnv === "development") {
console.error("[API] Preview validation failed", { id, errors: parsed.error.issues });
}
return respondWithValidationErrors(res, parsed.error.issues);
}
const payload = parsed.data as { preview?: string | null };
if (payload.preview === undefined) {
return res.status(400).json({ error: "Validation error", message: "Missing preview field" });
}
const updated = await prisma.drawing.updateMany({
where: access.role === "owner" ? { id, userId: req.user.id } : { id },
data: { preview: payload.preview },
});
if (updated.count === 0) {
return res.status(404).json({ error: "Drawing not found" });
}
invalidateDrawingsCache();
return res.json({ success: true });
}));
app.delete("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const id = getRouteIdParam(req.params.id);
+2
View File
@@ -2,6 +2,7 @@ import express from "express";
import { z } from "zod";
import { Prisma, PrismaClient } from "../../generated/client";
import { AuthModeService } from "../../auth/authMode";
import { CollabSessionManager } from "../../server/collabSession";
export type SortField = "name" | "createdAt" | "updatedAt";
export type SortDirection = "asc" | "desc";
@@ -48,6 +49,7 @@ export type DashboardRouteDeps = {
buildDrawingsCacheKey: BuildDrawingsCacheKey;
getCachedDrawingsBody: (key: string) => Buffer | null;
cacheDrawingsResponse: (key: string, payload: unknown) => Buffer;
collabSessionManager: CollabSessionManager;
MAX_PAGE_SIZE: number;
config: {
nodeEnv: string;
+26 -4
View File
@@ -231,6 +231,31 @@ export const sanitizeSvg = (svgContent: string): string => {
return sanitizeSvgImageTags(sanitized).trim();
};
const SAFE_PREVIEW_DATA_URL_PATTERN =
/^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i;
const MAX_PREVIEW_SIZE = 300_000;
export const sanitizePreview = (
previewContent: string | null | undefined
): string | null => {
if (previewContent === null || previewContent === undefined) return null;
if (typeof previewContent !== "string") return null;
const trimmed = previewContent.trim();
if (trimmed.length === 0) return null;
if (trimmed.length > MAX_PREVIEW_SIZE) return null;
if (SAFE_PREVIEW_DATA_URL_PATTERN.test(trimmed)) {
return trimmed;
}
if (/^<svg[\s>]/i.test(trimmed)) {
const sanitized = sanitizeSvg(trimmed);
return sanitized.length > 0 && sanitized.length <= MAX_PREVIEW_SIZE ? sanitized : null;
}
return null;
};
export const sanitizeText = (
input: unknown,
maxLength: number = 1000
@@ -463,10 +488,7 @@ export const sanitizeDrawingData = (data: {
const sanitizedElements = elementSchema.array().parse(data.elements);
const sanitizedAppState = appStateSchema.parse(data.appState);
let sanitizedPreview = data.preview;
if (typeof sanitizedPreview === "string") {
sanitizedPreview = sanitizeSvg(sanitizedPreview);
}
const sanitizedPreview = sanitizePreview(data.preview);
// Sanitize files object with special handling for dataURL
let sanitizedFiles = data.files;
+293
View File
@@ -0,0 +1,293 @@
import { PrismaClient } from "../generated/client";
type SceneOp = {
upsertElements?: any[];
deleteElementIds?: string[];
filesDelta?: Record<string, any>;
appStatePatch?: Record<string, unknown>;
};
type DrawingSession = {
drawingId: string;
seq: number;
dbVersion: number;
elementsById: Map<string, any>;
filesById: Record<string, any>;
appState: Record<string, unknown>;
dirty: boolean;
lastTouchedAt: number;
participants: Set<string>;
pendingFlushTimer: NodeJS.Timeout | null;
};
type CreateCollabSessionManagerDeps = {
prisma: PrismaClient;
flushDebounceMs?: number;
idleEvictMs?: number;
};
const ALLOWED_APPSTATE_KEYS = new Set([
"viewBackgroundColor",
"gridSize",
"zoom",
"scrollX",
"scrollY",
"theme",
]);
const parseJsonSafely = <T>(rawValue: string | null | undefined, fallback: T): T => {
if (!rawValue) return fallback;
try {
return JSON.parse(rawValue) as T;
} catch {
return fallback;
}
};
const sanitizeElements = (elements: unknown): any[] => {
if (!Array.isArray(elements)) return [];
return elements.filter((element) => element && typeof element === "object");
};
const sanitizeDeleteIds = (deleteElementIds: unknown): string[] => {
if (!Array.isArray(deleteElementIds)) return [];
return deleteElementIds.filter((value): value is string => typeof value === "string");
};
const sanitizeFilesDelta = (filesDelta: unknown): Record<string, any> => {
if (!filesDelta || typeof filesDelta !== "object") return {};
const next: Record<string, any> = {};
for (const [key, value] of Object.entries(filesDelta as Record<string, unknown>)) {
if (typeof key !== "string" || key.length === 0) continue;
next[key] = value;
}
return next;
};
const sanitizeAppStatePatch = (appStatePatch: unknown): Record<string, unknown> => {
if (!appStatePatch || typeof appStatePatch !== "object") return {};
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(appStatePatch as Record<string, unknown>)) {
if (!ALLOWED_APPSTATE_KEYS.has(key)) continue;
next[key] = value;
}
return next;
};
export const createCollabSessionManager = ({
prisma,
flushDebounceMs = 2000,
idleEvictMs = 5 * 60 * 1000,
}: CreateCollabSessionManagerDeps) => {
const sessions = new Map<string, DrawingSession>();
const seenClientOps = new Map<string, number>();
const ensureSession = async (drawingId: string): Promise<DrawingSession | null> => {
const existing = sessions.get(drawingId);
if (existing) {
existing.lastTouchedAt = Date.now();
return existing;
}
const drawing = await prisma.drawing.findUnique({ where: { id: drawingId } });
if (!drawing) return null;
const elements = sanitizeElements(parseJsonSafely(drawing.elements, []));
const session: DrawingSession = {
drawingId,
seq: 0,
dbVersion: drawing.version,
elementsById: new Map(elements.map((element) => [String(element.id), element])),
filesById: parseJsonSafely(drawing.files, {}),
appState: parseJsonSafely(drawing.appState, {}),
dirty: false,
lastTouchedAt: Date.now(),
participants: new Set(),
pendingFlushTimer: null,
};
sessions.set(drawingId, session);
return session;
};
const getSceneSnapshot = async (drawingId: string) => {
const session = await ensureSession(drawingId);
if (!session) return null;
return {
drawingId,
seq: session.seq,
dbVersion: session.dbVersion,
elements: Array.from(session.elementsById.values()),
appState: session.appState,
files: session.filesById,
};
};
const flushSession = async (drawingId: string): Promise<boolean> => {
const session = sessions.get(drawingId);
if (!session) return false;
if (!session.dirty) return true;
if (session.pendingFlushTimer) {
clearTimeout(session.pendingFlushTimer);
session.pendingFlushTimer = null;
}
const elements = Array.from(session.elementsById.values());
await prisma.drawing.update({
where: { id: drawingId },
data: {
elements: JSON.stringify(elements),
appState: JSON.stringify(session.appState || {}),
files: JSON.stringify(session.filesById || {}),
version: { increment: 1 },
},
});
session.dbVersion += 1;
session.dirty = false;
session.lastTouchedAt = Date.now();
return true;
};
const scheduleFlush = (session: DrawingSession) => {
if (session.pendingFlushTimer) {
clearTimeout(session.pendingFlushTimer);
}
session.pendingFlushTimer = setTimeout(() => {
void flushSession(session.drawingId).catch((error) => {
console.error("[collab] failed to flush drawing session", {
drawingId: session.drawingId,
error,
});
});
}, flushDebounceMs);
};
const applyOps = async (params: {
drawingId: string;
clientOpId?: string;
ops: SceneOp[];
}): Promise<{ seq: number; duplicate: boolean } | null> => {
const session = await ensureSession(params.drawingId);
if (!session) return null;
const opKey = params.clientOpId ? `${params.drawingId}:${params.clientOpId}` : null;
if (opKey && seenClientOps.has(opKey)) {
return { seq: session.seq, duplicate: true };
}
for (const op of params.ops) {
const upsertElements = sanitizeElements(op?.upsertElements);
for (const element of upsertElements) {
const id = typeof element.id === "string" ? element.id : null;
if (!id) continue;
session.elementsById.set(id, element);
}
const deleteElementIds = sanitizeDeleteIds(op?.deleteElementIds);
for (const deleteId of deleteElementIds) {
const existing = session.elementsById.get(deleteId);
if (existing) {
session.elementsById.set(deleteId, { ...existing, isDeleted: true });
}
}
const filesDelta = sanitizeFilesDelta(op?.filesDelta);
if (Object.keys(filesDelta).length > 0) {
session.filesById = {
...session.filesById,
...filesDelta,
};
}
const appStatePatch = sanitizeAppStatePatch(op?.appStatePatch);
if (Object.keys(appStatePatch).length > 0) {
session.appState = {
...session.appState,
...appStatePatch,
};
}
}
session.seq += 1;
session.dirty = true;
session.lastTouchedAt = Date.now();
scheduleFlush(session);
if (opKey) {
seenClientOps.set(opKey, Date.now());
}
return { seq: session.seq, duplicate: false };
};
const joinSession = async (drawingId: string, socketId: string) => {
const session = await ensureSession(drawingId);
if (!session) return null;
session.participants.add(socketId);
session.lastTouchedAt = Date.now();
return session;
};
const leaveSession = async (drawingId: string, socketId: string): Promise<void> => {
const session = sessions.get(drawingId);
if (!session) return;
session.participants.delete(socketId);
session.lastTouchedAt = Date.now();
if (session.participants.size === 0) {
await flushSession(drawingId).catch((error) => {
console.error("[collab] failed to flush on last participant leave", {
drawingId,
error,
});
});
}
};
const getMeta = async (drawingId: string) => {
const session = await ensureSession(drawingId);
if (!session) return null;
return {
drawingId,
seq: session.seq,
dbVersion: session.dbVersion,
dirty: session.dirty,
lastTouchedAt: session.lastTouchedAt,
};
};
setInterval(() => {
const now = Date.now();
for (const [key, seenAt] of seenClientOps.entries()) {
if (now - seenAt > 10 * 60 * 1000) {
seenClientOps.delete(key);
}
}
for (const [drawingId, session] of sessions.entries()) {
const isIdle = now - session.lastTouchedAt > idleEvictMs;
if (!isIdle || session.participants.size > 0) continue;
void flushSession(drawingId)
.catch((error) => {
console.error("[collab] failed to flush idle session", { drawingId, error });
})
.finally(() => {
sessions.delete(drawingId);
});
}
}, 30_000).unref();
return {
getSceneSnapshot,
applyOps,
joinSession,
leaveSession,
flushSession,
getMeta,
};
};
export type CollabSessionManager = ReturnType<typeof createCollabSessionManager>;
+51
View File
@@ -4,6 +4,7 @@ import { PrismaClient } from "../generated/client";
import { AuthModeService } from "../auth/authMode";
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess";
import { CollabSessionManager } from "./collabSession";
interface User {
id: string;
@@ -19,6 +20,7 @@ type RegisterSocketHandlersDeps = {
prisma: PrismaClient;
authModeService: AuthModeService;
jwtSecret: string;
collabSessionManager: CollabSessionManager;
};
export const registerSocketHandlers = ({
@@ -26,6 +28,7 @@ export const registerSocketHandlers = ({
prisma,
authModeService,
jwtSecret,
collabSessionManager,
}: RegisterSocketHandlersDeps) => {
const roomUsers = new Map<string, User[]>();
const socketUserMap = new Map<string, string>();
@@ -140,6 +143,7 @@ export const registerSocketHandlers = ({
const roomId = `drawing_${drawingId}`;
socket.join(roomId);
await collabSessionManager.joinSession(drawingId, socket.id);
let trustedUserId =
typeof user?.id === "string" && user.id.trim().length > 0
@@ -173,6 +177,11 @@ export const registerSocketHandlers = ({
roomUsers.set(roomId, filteredUsers);
io.to(roomId).emit("presence-update", filteredUsers);
const snapshot = await collabSessionManager.getSceneSnapshot(drawingId);
if (snapshot) {
socket.emit("scene-snapshot", snapshot);
}
} catch (err) {
console.error("Error in join-room handler:", err);
socket.emit("error", { message: "Failed to join room" });
@@ -202,6 +211,45 @@ export const registerSocketHandlers = ({
socket.to(roomId).emit("element-update", data);
});
socket.on("scene-op", async (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
if (!drawingId) return;
const role = authorizedDrawingRoles.get(drawingId);
if (!role || !isAtLeastRole(role, "editor")) {
return;
}
const baseSeq = typeof data?.baseSeq === "number" ? data.baseSeq : 0;
const clientOpId = typeof data?.clientOpId === "string" ? data.clientOpId : undefined;
const incomingOps = Array.isArray(data?.ops) ? data.ops : [];
if (incomingOps.length === 0) return;
const applied = await collabSessionManager.applyOps({
drawingId,
clientOpId,
ops: incomingOps,
});
if (!applied) return;
const roomId = `drawing_${drawingId}`;
io.to(roomId).emit("scene-op-applied", {
drawingId,
seq: applied.seq,
baseSeq,
ops: incomingOps,
authorUserId: authenticatedUserId ?? socket.id,
clientOpId,
});
});
socket.on("scene-flush", async (data) => {
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
if (!drawingId) return;
if (!authorizedDrawingRoles.has(drawingId)) return;
await collabSessionManager.flushSession(drawingId);
});
socket.on(
"user-activity",
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
@@ -222,6 +270,9 @@ export const registerSocketHandlers = ({
socket.on("disconnect", () => {
socketUserMap.delete(socket.id);
for (const drawingId of authorizedDrawingRoles.keys()) {
void collabSessionManager.leaveSession(drawingId, socket.id);
}
roomUsers.forEach((users, roomId) => {
const index = users.findIndex((u) => u.socketId === socket.id);
if (index !== -1) {