Files
ExcaliDash/backend/src/routes/dashboard.ts
T
Zimeng Xiong de254d46f2 concurrency
2026-02-07 12:45:33 -08:00

565 lines
19 KiB
TypeScript

import express from "express";
import { z } from "zod";
import { Prisma, PrismaClient } from "../generated/client";
type SortField = "name" | "createdAt" | "updatedAt";
type SortDirection = "asc" | "desc";
type BuildDrawingsCacheKey = (keyParts: {
userId: string;
searchTerm: string;
collectionFilter: string;
includeData: boolean;
sortField: SortField;
sortDirection: SortDirection;
}) => string;
type EnsureTrashCollection = (
db: Prisma.TransactionClient | PrismaClient,
userId: string
) => Promise<void>;
type LogAuditEvent = (params: {
userId: string;
action: string;
resource?: string;
ipAddress?: string;
userAgent?: string;
details?: Record<string, unknown>;
}) => Promise<void>;
type DashboardRouteDeps = {
prisma: PrismaClient;
requireAuth: express.RequestHandler;
asyncHandler: <T = void>(
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
) => express.RequestHandler;
parseJsonField: <T>(rawValue: string | null | undefined, fallback: T) => T;
sanitizeText: (input: unknown, maxLength?: number) => string;
validateImportedDrawing: (data: unknown) => boolean;
drawingCreateSchema: z.ZodTypeAny;
drawingUpdateSchema: z.ZodTypeAny;
respondWithValidationErrors: (res: express.Response, issues: z.ZodIssue[]) => void;
collectionNameSchema: z.ZodTypeAny;
ensureTrashCollection: EnsureTrashCollection;
invalidateDrawingsCache: () => void;
buildDrawingsCacheKey: BuildDrawingsCacheKey;
getCachedDrawingsBody: (key: string) => Buffer | null;
cacheDrawingsResponse: (key: string, payload: unknown) => Buffer;
MAX_PAGE_SIZE: number;
config: {
nodeEnv: string;
enableAuditLogging: boolean;
};
logAuditEvent: LogAuditEvent;
};
export const registerDashboardRoutes = (
app: express.Express,
deps: DashboardRouteDeps
) => {
const {
prisma,
requireAuth,
asyncHandler,
parseJsonField,
sanitizeText,
validateImportedDrawing,
drawingCreateSchema,
drawingUpdateSchema,
respondWithValidationErrors,
collectionNameSchema,
ensureTrashCollection,
invalidateDrawingsCache,
buildDrawingsCacheKey,
getCachedDrawingsBody,
cacheDrawingsResponse,
MAX_PAGE_SIZE,
config,
logAuditEvent,
} = deps;
app.get("/drawings", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { search, collectionId, includeData, limit, offset, sortField, sortDirection } = req.query;
const where: Prisma.DrawingWhereInput = { userId: req.user.id };
const searchTerm =
typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined;
if (searchTerm) {
where.name = { contains: searchTerm };
}
let collectionFilterKey = "default";
if (collectionId === "null") {
where.collectionId = null;
collectionFilterKey = "null";
} else if (collectionId) {
const normalizedCollectionId = String(collectionId);
if (normalizedCollectionId === "trash") {
where.collectionId = "trash";
collectionFilterKey = "trash";
} else {
const collection = await prisma.collection.findFirst({
where: { id: normalizedCollectionId, userId: req.user.id },
});
if (!collection) {
return res.status(404).json({ error: "Collection not found" });
}
where.collectionId = normalizedCollectionId;
collectionFilterKey = `id:${normalizedCollectionId}`;
}
} else {
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
}
const shouldIncludeData =
typeof includeData === "string"
? includeData.toLowerCase() === "true" || includeData === "1"
: false;
const parsedSortField: SortField =
sortField === "name" || sortField === "createdAt" || sortField === "updatedAt"
? sortField
: "updatedAt";
const parsedSortDirection: SortDirection =
sortDirection === "asc" || sortDirection === "desc"
? sortDirection
: parsedSortField === "name"
? "asc"
: "desc";
const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined;
const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined;
const parsedLimit =
rawLimit !== undefined && Number.isFinite(rawLimit)
? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE)
: undefined;
const parsedOffset =
rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined;
const cacheKey =
buildDrawingsCacheKey({
userId: req.user.id,
searchTerm: searchTerm ?? "",
collectionFilter: collectionFilterKey,
includeData: shouldIncludeData,
sortField: parsedSortField,
sortDirection: parsedSortDirection,
}) + `:${parsedLimit}:${parsedOffset}`;
const cachedBody = getCachedDrawingsBody(cacheKey);
if (cachedBody) {
res.setHeader("X-Cache", "HIT");
res.setHeader("Content-Type", "application/json");
return res.send(cachedBody);
}
const summarySelect: Prisma.DrawingSelect = {
id: true,
name: true,
collectionId: true,
preview: true,
version: true,
createdAt: true,
updatedAt: true,
};
const orderBy: Prisma.DrawingOrderByWithRelationInput =
parsedSortField === "name"
? { name: parsedSortDirection }
: parsedSortField === "createdAt"
? { createdAt: parsedSortDirection }
: { updatedAt: parsedSortDirection };
const queryOptions: Prisma.DrawingFindManyArgs = { where, orderBy };
if (parsedLimit !== undefined) queryOptions.take = parsedLimit;
if (parsedOffset !== undefined) queryOptions.skip = parsedOffset;
if (!shouldIncludeData) queryOptions.select = summarySelect;
const [drawings, totalCount] = await Promise.all([
prisma.drawing.findMany(queryOptions),
prisma.drawing.count({ where }),
]);
let responsePayload: any[] = drawings as any[];
if (shouldIncludeData) {
responsePayload = (drawings as any[]).map((d: any) => ({
...d,
elements: parseJsonField(d.elements, []),
appState: parseJsonField(d.appState, {}),
files: parseJsonField(d.files, {}),
}));
}
const finalResponse = {
drawings: responsePayload,
totalCount,
limit: parsedLimit,
offset: parsedOffset,
};
const body = cacheDrawingsResponse(cacheKey, finalResponse);
res.setHeader("X-Cache", "MISS");
res.setHeader("Content-Type", "application/json");
return res.send(body);
}));
app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const drawing = await prisma.drawing.findFirst({
where: {
id,
userId: req.user.id,
},
});
if (!drawing) {
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
}
return res.json({
...drawing,
elements: parseJsonField(drawing.elements, []),
appState: parseJsonField(drawing.appState, {}),
files: parseJsonField(drawing.files, {}),
});
}));
app.post("/drawings", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const isImportedDrawing = req.headers["x-imported-file"] === "true";
if (isImportedDrawing && !validateImportedDrawing(req.body)) {
return res.status(400).json({
error: "Invalid imported drawing file",
message: "The imported file contains potentially malicious content or invalid structure",
});
}
const parsed = drawingCreateSchema.safeParse(req.body);
if (!parsed.success) {
return respondWithValidationErrors(res, parsed.error.issues);
}
const payload = parsed.data as {
name?: string;
collectionId?: string | null;
elements: unknown[];
appState: Record<string, unknown>;
preview?: string | null;
files?: Record<string, unknown>;
};
const drawingName = payload.name ?? "Untitled Drawing";
const targetCollectionId = payload.collectionId === undefined ? null : payload.collectionId;
if (targetCollectionId && targetCollectionId !== "trash") {
const collection = await prisma.collection.findFirst({
where: { id: targetCollectionId, userId: req.user.id },
});
if (!collection) return res.status(404).json({ error: "Collection not found" });
} else if (targetCollectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
}
const newDrawing = await prisma.drawing.create({
data: {
name: drawingName,
elements: JSON.stringify(payload.elements),
appState: JSON.stringify(payload.appState),
userId: req.user.id,
collectionId: targetCollectionId,
preview: payload.preview ?? null,
files: JSON.stringify(payload.files ?? {}),
},
});
invalidateDrawingsCache();
return res.json({
...newDrawing,
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
files: parseJsonField(newDrawing.files, {}),
});
}));
app.put("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const existingDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
});
if (!existingDrawing) return res.status(404).json({ error: "Drawing not found" });
const parsed = drawingUpdateSchema.safeParse(req.body);
if (!parsed.success) {
if (config.nodeEnv === "development") {
console.error("[API] Validation failed", { id, errors: parsed.error.issues });
}
return respondWithValidationErrors(res, parsed.error.issues);
}
const payload = parsed.data as {
name?: string;
collectionId?: string | null;
elements?: unknown[];
appState?: Record<string, unknown>;
preview?: string | null;
files?: Record<string, unknown>;
version?: number;
};
const isSceneUpdate =
payload.elements !== undefined ||
payload.appState !== undefined ||
payload.files !== undefined;
const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } };
if (payload.name !== undefined) data.name = payload.name;
if (payload.elements !== undefined) data.elements = JSON.stringify(payload.elements);
if (payload.appState !== undefined) data.appState = JSON.stringify(payload.appState);
if (payload.files !== undefined) data.files = JSON.stringify(payload.files);
if (payload.preview !== undefined) data.preview = payload.preview;
if (payload.collectionId !== undefined) {
if (payload.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = "trash";
} else if (payload.collectionId) {
const collection = await prisma.collection.findFirst({
where: { id: payload.collectionId, userId: req.user.id },
});
if (!collection) return res.status(404).json({ error: "Collection not found" });
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = payload.collectionId;
} else {
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = null;
}
}
const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
if (isSceneUpdate && payload.version !== undefined) {
updateWhere.version = payload.version;
}
const updateResult = await prisma.drawing.updateMany({
where: updateWhere,
data,
});
if (updateResult.count === 0) {
if (isSceneUpdate && payload.version !== undefined) {
const latestDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
select: { version: true },
});
return res.status(409).json({
error: "Conflict",
code: "VERSION_CONFLICT",
message: "Drawing has changed since this editor state was loaded.",
currentVersion: latestDrawing?.version ?? null,
});
}
return res.status(404).json({ error: "Drawing not found" });
}
const updatedDrawing = await prisma.drawing.findFirst({
where: { id, userId: req.user.id },
});
if (!updatedDrawing) {
return res.status(404).json({ error: "Drawing not found" });
}
invalidateDrawingsCache();
return res.json({
...updatedDrawing,
elements: parseJsonField(updatedDrawing.elements, []),
appState: parseJsonField(updatedDrawing.appState, {}),
files: parseJsonField(updatedDrawing.files, {}),
});
}));
app.delete("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
if (!drawing) return res.status(404).json({ error: "Drawing not found" });
const deleteResult = await prisma.drawing.deleteMany({
where: { id, userId: req.user.id },
});
if (deleteResult.count === 0) {
return res.status(404).json({ error: "Drawing not found" });
}
invalidateDrawingsCache();
if (config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
action: "drawing_deleted",
resource: `drawing:${id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { drawingId: id, drawingName: drawing.name },
});
}
return res.json({ success: true });
}));
app.post("/drawings/:id/duplicate", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
if (!original) return res.status(404).json({ error: "Original drawing not found" });
if (original.collectionId === "trash") {
await ensureTrashCollection(prisma, req.user.id);
}
const newDrawing = await prisma.drawing.create({
data: {
name: `${original.name} (Copy)`,
elements: original.elements,
appState: original.appState,
files: original.files,
userId: req.user.id,
collectionId: original.collectionId,
version: 1,
},
});
invalidateDrawingsCache();
return res.json({
...newDrawing,
elements: parseJsonField(newDrawing.elements, []),
appState: parseJsonField(newDrawing.appState, {}),
files: parseJsonField(newDrawing.files, {}),
});
}));
app.get("/collections", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const collections = await prisma.collection.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: "desc" },
});
return res.json(collections);
}));
app.post("/collections", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const parsed = collectionNameSchema.safeParse(req.body.name);
if (!parsed.success) {
return res.status(400).json({
error: "Validation error",
message: "Collection name must be between 1 and 100 characters",
});
}
const sanitizedName = sanitizeText(parsed.data, 100);
const newCollection = await prisma.collection.create({
data: { name: sanitizedName, userId: req.user.id },
});
return res.json(newCollection);
}));
app.put("/collections/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const existingCollection = await prisma.collection.findFirst({
where: { id, userId: req.user.id },
});
if (!existingCollection) return res.status(404).json({ error: "Collection not found" });
const parsed = collectionNameSchema.safeParse(req.body.name);
if (!parsed.success) {
return res.status(400).json({
error: "Validation error",
message: "Collection name must be between 1 and 100 characters",
});
}
const sanitizedName = sanitizeText(parsed.data, 100);
const updateResult = await prisma.collection.updateMany({
where: { id, userId: req.user.id },
data: { name: sanitizedName },
});
if (updateResult.count === 0) {
return res.status(404).json({ error: "Collection not found" });
}
const updatedCollection = await prisma.collection.findFirst({
where: { id, userId: req.user.id },
});
if (!updatedCollection) {
return res.status(404).json({ error: "Collection not found" });
}
return res.json(updatedCollection);
}));
app.delete("/collections/:id", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params;
const collection = await prisma.collection.findFirst({
where: { id, userId: req.user.id },
});
if (!collection) return res.status(404).json({ error: "Collection not found" });
await prisma.$transaction([
prisma.drawing.updateMany({
where: { collectionId: id, userId: req.user.id },
data: { collectionId: null },
}),
prisma.collection.deleteMany({ where: { id, userId: req.user.id } }),
]);
invalidateDrawingsCache();
if (config.enableAuditLogging) {
await logAuditEvent({
userId: req.user.id,
action: "collection_deleted",
resource: `collection:${id}`,
ipAddress: req.ip || req.connection.remoteAddress || undefined,
userAgent: req.headers["user-agent"] || undefined,
details: { collectionId: id, collectionName: collection.name },
});
}
return res.json({ success: true });
}));
app.get("/library", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const libraryId = `user_${req.user.id}`;
const library = await prisma.library.findUnique({ where: { id: libraryId } });
if (!library) return res.json({ items: [] });
return res.json({ items: parseJsonField(library.items, []) });
}));
app.put("/library", requireAuth, asyncHandler(async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { items } = req.body;
if (!Array.isArray(items)) {
return res.status(400).json({ error: "Items must be an array" });
}
const libraryId = `user_${req.user.id}`;
const library = await prisma.library.upsert({
where: { id: libraryId },
update: { items: JSON.stringify(items) },
create: { id: libraryId, items: JSON.stringify(items) },
});
return res.json({ items: parseJsonField(library.items, []) });
}));
};