feat(collab): add server-authoritative sync and preview-only updates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user