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) {
+38
View File
@@ -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;
+14 -2
View File
@@ -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" />
+16 -4
View File
@@ -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 ? (
<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 }}
/>
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>
)}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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;
}