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) {
|
||||
|
||||
@@ -540,6 +540,44 @@ export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
|
||||
return deserializeDrawing(response.data);
|
||||
};
|
||||
|
||||
export const updateDrawingPreview = async (
|
||||
id: string,
|
||||
preview: string | null
|
||||
): Promise<{ success: true }> => {
|
||||
const response = await api.put<{ success: true }>(`/drawings/${id}/preview`, { preview });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDrawingSceneMeta = async (id: string): Promise<{
|
||||
drawingId: string;
|
||||
seq: number;
|
||||
dbVersion: number;
|
||||
updatedAt: string | number;
|
||||
}> => {
|
||||
const response = await api.get<{
|
||||
drawingId: string;
|
||||
seq: number;
|
||||
dbVersion: number;
|
||||
updatedAt: string | number;
|
||||
}>(`/drawings/${id}/scene-meta`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const flushDrawingScene = async (id: string): Promise<{
|
||||
success: true;
|
||||
drawingId: string;
|
||||
seq: number | null;
|
||||
dbVersion: number | null;
|
||||
}> => {
|
||||
const response = await api.post<{
|
||||
success: true;
|
||||
drawingId: string;
|
||||
seq: number | null;
|
||||
dbVersion: number | null;
|
||||
}>(`/drawings/${id}/flush`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteDrawing = async (id: string) => {
|
||||
const response = await api.delete<{ success: true }>(`/drawings/${id}`);
|
||||
return response.data;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
// import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead
|
||||
import { exportDrawingToFile } from '../utils/exportUtils';
|
||||
import { previewHasEmbeddedImages } from '../utils/previewSvg';
|
||||
import { isPreviewImageDataUrl, previewHasEmbeddedImages } from '../utils/previewSvg';
|
||||
|
||||
import * as api from '../api';
|
||||
|
||||
@@ -89,6 +89,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
|
||||
const hasEmbeddedImages = previewHasEmbeddedImages(previewSvg);
|
||||
const isImagePreview = isPreviewImageDataUrl(previewSvg);
|
||||
|
||||
const fullDataRef = React.useRef(fullData);
|
||||
fullDataRef.current = fullData;
|
||||
@@ -161,7 +162,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
setPreviewSvg(previewHtml);
|
||||
|
||||
// Save to backend and notify parent
|
||||
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
|
||||
api.updateDrawingPreview(drawing.id, previewHtml).catch(console.error);
|
||||
onPreviewGenerated?.(drawing.id, previewHtml);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
@@ -277,6 +278,16 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
|
||||
|
||||
{previewSvg ? (
|
||||
isImagePreview ? (
|
||||
<img
|
||||
src={previewSvg}
|
||||
alt=""
|
||||
className="w-full h-full object-contain p-2 sm:p-3 lg:p-4 relative z-10"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm transition-transform duration-500",
|
||||
@@ -284,6 +295,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: previewSvg }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 lg:w-24 lg:h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
|
||||
<PenTool size={32} strokeWidth={1.5} className="sm:w-9 sm:h-9 lg:w-10 lg:h-10" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { useUpload } from '../context/UploadContext';
|
||||
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
|
||||
import { useDashboardData } from './dashboard/useDashboardData';
|
||||
import { isPreviewImageDataUrl } from '../utils/previewSvg';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -699,10 +700,21 @@ export const Dashboard: React.FC = () => {
|
||||
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
|
||||
|
||||
{d.preview ? (
|
||||
isPreviewImageDataUrl(d.preview) ? (
|
||||
<img
|
||||
src={d.preview}
|
||||
alt=""
|
||||
className="w-full h-full p-2 object-contain relative z-10"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm relative z-10"
|
||||
dangerouslySetInnerHTML={{ __html: d.preview }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-slate-300 relative z-10"><Folder size={24} /></div>
|
||||
)}
|
||||
|
||||
+228
-257
File diff suppressed because it is too large
Load Diff
@@ -65,11 +65,29 @@ export const previewHasEmbeddedImages = (
|
||||
preview: string | null | undefined
|
||||
): boolean => typeof preview === "string" && /<image[\s>]/i.test(preview);
|
||||
|
||||
export const isPreviewImageDataUrl = (
|
||||
preview: string | null | undefined
|
||||
): boolean =>
|
||||
typeof preview === "string" &&
|
||||
/^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(preview.trim());
|
||||
|
||||
export const isPreviewSvgMarkup = (
|
||||
preview: string | null | undefined
|
||||
): boolean => typeof preview === "string" && /^\s*<svg[\s>]/i.test(preview);
|
||||
|
||||
export const normalizePreviewSvg = (preview: string | null | undefined): string | null => {
|
||||
if (typeof preview !== "string" || preview.trim().length === 0) {
|
||||
return preview ?? null;
|
||||
}
|
||||
|
||||
if (isPreviewImageDataUrl(preview)) {
|
||||
return preview.trim();
|
||||
}
|
||||
|
||||
if (!isPreviewSvgMarkup(preview)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return preview;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user