import express from "express"; import cors from "cors"; import dotenv from "dotenv"; import path from "path"; import fs from "fs"; import { promises as fsPromises } from "fs"; import { createServer } from "http"; import { Server } from "socket.io"; import { Worker } from "worker_threads"; import multer from "multer"; import archiver from "archiver"; import { z } from "zod"; // @ts-ignore import { PrismaClient } from "./generated/client"; import { sanitizeDrawingData, validateImportedDrawing, sanitizeText, sanitizeSvg, elementSchema, appStateSchema, } from "./security"; dotenv.config(); // Ensure DATABASE_URL always points to an absolute path when using SQLite. // Respect externally provided values and only fall back to the dev database when unset. const backendRoot = path.resolve(__dirname, "../"); const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); const resolveDatabaseUrl = (rawUrl?: string) => { if (!rawUrl || rawUrl.trim().length === 0) { return `file:${defaultDbPath}`; } if (!rawUrl.startsWith("file:")) { return rawUrl; } const filePath = rawUrl.replace(/^file:/, ""); const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(backendRoot, filePath); return `file:${absolutePath}`; }; process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); const normalizeOrigins = (rawOrigins?: string | null): string[] => { const fallback = "http://localhost:6767"; if (!rawOrigins || rawOrigins.trim().length === 0) { return [fallback]; } const ensureProtocol = (origin: string) => /^https?:\/\//i.test(origin) ? origin : `http://${origin}`; const parsed = rawOrigins .split(",") .map((origin) => origin.trim()) .filter((origin) => origin.length > 0) .map(ensureProtocol); return parsed.length > 0 ? parsed : [fallback]; }; const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); console.log("Allowed origins:", allowedOrigins); const uploadDir = path.resolve(__dirname, "../uploads"); // Initialize upload directory asynchronously const initializeUploadDir = async () => { try { await fsPromises.mkdir(uploadDir, { recursive: true }); } catch (error) { console.error("Failed to create upload directory:", error); } }; const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: allowedOrigins, credentials: true, }, maxHttpBufferSize: 1e8, // 100 MB }); const prisma = new PrismaClient(); const PORT = process.env.PORT || 8000; // Multer setup for file uploads with streaming support const upload = multer({ dest: uploadDir, limits: { fileSize: 100 * 1024 * 1024, // 100MB limit files: 1, // Only one file per upload }, fileFilter: (req, file, cb) => { // Only allow .db files for SQLite imports if (file.fieldname === "db" && !file.originalname.endsWith(".db")) { return cb(new Error("Only .db files are allowed")); } cb(null, true); }, }); app.use( cors({ origin: allowedOrigins, credentials: true, }) ); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); // Log large requests for monitoring and debugging app.use((req, res, next) => { const contentLength = req.headers["content-length"]; if (contentLength) { const sizeInMB = parseInt(contentLength, 10) / 1024 / 1024; if (sizeInMB > 10) { console.log( `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed( 2 )}MB - Content-Length: ${contentLength} bytes` ); } } next(); }); // Security middleware - Add security headers app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-XSS-Protection", "1; mode=block"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); res.setHeader( "Permissions-Policy", "geolocation=(), microphone=(), camera=()" ); // Content Security Policy - restrict sources res.setHeader( "Content-Security-Policy", "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: blob: https:; " + "connect-src 'self' ws: wss:; " + "frame-ancestors 'none';" ); next(); }); // Rate limiting middleware (basic implementation) const requestCounts = new Map(); const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window app.use((req, res, next) => { const ip = req.ip || req.connection.remoteAddress || "unknown"; const now = Date.now(); const clientData = requestCounts.get(ip); if (!clientData || now > clientData.resetTime) { requestCounts.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); return next(); } if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) { return res.status(429).json({ error: "Rate limit exceeded", message: "Too many requests, please try again later", }); } clientData.count++; next(); }); const filesFieldSchema = z .union([z.record(z.string(), z.any()), z.null()]) .optional() .transform((value) => (value === null ? undefined : value)); const drawingBaseSchema = z.object({ name: z.string().trim().min(1).max(255).optional(), collectionId: z.union([z.string().trim().min(1), z.null()]).optional(), preview: z.string().nullable().optional(), }); // Use strict schemas from security module with sanitization const drawingCreateSchema = drawingBaseSchema .extend({ elements: elementSchema.array().default([]), appState: appStateSchema.default({}), files: filesFieldSchema, }) .refine( (data) => { // Apply sanitization before database persistence try { const sanitized = sanitizeDrawingData(data); // Merge sanitized data back with original properties Object.assign(data, sanitized); return true; } catch (error) { console.error("Sanitization failed:", error); return false; } }, { message: "Invalid or malicious drawing data detected", } ); const drawingUpdateSchema = drawingBaseSchema .extend({ elements: elementSchema.array().optional(), appState: appStateSchema.optional(), files: filesFieldSchema, }) .refine( (data) => { // Apply sanitization before database persistence try { // Only sanitize provided fields const sanitizedData = { ...data }; if (data.elements !== undefined || data.appState !== undefined) { const fullData = { elements: data.elements || [], appState: data.appState || {}, files: data.files, preview: data.preview, name: data.name, collectionId: data.collectionId, }; const sanitized = sanitizeDrawingData(fullData); sanitizedData.elements = sanitized.elements; sanitizedData.appState = sanitized.appState; if (data.files !== undefined) sanitizedData.files = sanitized.files; if (data.preview !== undefined) sanitizedData.preview = sanitized.preview; Object.assign(data, sanitizedData); } return true; } catch (error) { console.error("Sanitization failed:", error); return false; } }, { message: "Invalid or malicious drawing data detected", } ); const respondWithValidationErrors = ( res: express.Response, issues: z.ZodIssue[] ) => { res.status(400).json({ error: "Invalid drawing payload", details: issues, }); }; const validateSqliteHeader = (filePath: string): boolean => { try { const buffer = Buffer.alloc(16); const fd = fs.openSync(filePath, "r"); const bytesRead = fs.readSync(fd, buffer, 0, 16, 0); fs.closeSync(fd); if (bytesRead < 16) { console.warn("File too small to be a valid SQLite database"); return false; } // SQLite format 3 header: "SQLite format 3\0" (16 bytes) // Hex: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00 const expectedHeader = Buffer.from([ 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, ]); const isValid = buffer.equals(expectedHeader); if (!isValid) { console.warn("Invalid SQLite file header detected", { filePath, header: buffer.toString("hex"), expected: expectedHeader.toString("hex"), }); } return isValid; } catch (error) { console.error("Failed to validate SQLite header:", error); return false; } }; // Non-blocking CPU check using worker threads while still verifying headers const verifyDatabaseIntegrityAsync = (filePath: string): Promise => { if (!validateSqliteHeader(filePath)) { return Promise.resolve(false); } return new Promise((resolve) => { const worker = new Worker( path.resolve(__dirname, "./workers/db-verify.js"), { workerData: { filePath }, } ); let timeoutHandle: NodeJS.Timeout; let settled = false; const finish = (result: boolean) => { if (settled) return; settled = true; clearTimeout(timeoutHandle); resolve(result); }; worker.on("message", (isValid: boolean) => finish(isValid)); worker.on("error", (err) => { console.error("Worker error:", err); finish(false); }); worker.on("exit", (code) => { if (code !== 0) { finish(false); } }); timeoutHandle = setTimeout(() => { console.warn("Integrity check worker timed out", { filePath }); worker.terminate(); finish(false); }, 10000); // 10 second timeout }); }; const removeFileIfExists = async (filePath?: string) => { if (!filePath) return; try { await fsPromises.access(filePath).catch(() => { // File doesn't exist, nothing to remove return; }); await fsPromises.unlink(filePath); } catch (error) { console.error("Failed to remove file", { filePath, error }); } }; // Socket.io Logic interface User { id: string; name: string; initials: string; color: string; socketId: string; isActive: boolean; } const roomUsers = new Map(); io.on("connection", (socket) => { socket.on( "join-room", ({ drawingId, user, }: { drawingId: string; user: Omit; }) => { const roomId = `drawing_${drawingId}`; socket.join(roomId); const newUser: User = { ...user, socketId: socket.id, isActive: true }; const currentUsers = roomUsers.get(roomId) || []; const filteredUsers = currentUsers.filter((u) => u.id !== user.id); filteredUsers.push(newUser); roomUsers.set(roomId, filteredUsers); io.to(roomId).emit("presence-update", filteredUsers); } ); socket.on("cursor-move", (data) => { const roomId = `drawing_${data.drawingId}`; // Use volatile for high-frequency, low-importance updates (cursors) // If network is congested, drop these packets socket.volatile.to(roomId).emit("cursor-move", data); }); socket.on("element-update", (data) => { const roomId = `drawing_${data.drawingId}`; socket.to(roomId).emit("element-update", data); }); socket.on( "user-activity", ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { const roomId = `drawing_${drawingId}`; const users = roomUsers.get(roomId); if (users) { const user = users.find((u) => u.socketId === socket.id); if (user) { user.isActive = isActive; io.to(roomId).emit("presence-update", users); } } } ); socket.on("disconnect", () => { roomUsers.forEach((users, roomId) => { const index = users.findIndex((u) => u.socketId === socket.id); if (index !== -1) { users.splice(index, 1); roomUsers.set(roomId, users); io.to(roomId).emit("presence-update", users); } }); }); }); // Health check endpoint app.get("/health", (req, res) => { res.status(200).json({ status: "ok" }); }); // --- Drawings --- // GET /drawings app.get("/drawings", async (req, res) => { try { const { search, collectionId } = req.query; const where: any = {}; if (search) { where.name = { contains: String(search) }; } if (collectionId === "null") { where.collectionId = null; } else if (collectionId) { where.collectionId = String(collectionId); } else { // Default: Exclude trash, but include unorganized (null) where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; } const drawings = await prisma.drawing.findMany({ where, orderBy: { updatedAt: "desc" }, }); // Parse JSON strings for response const parsedDrawings = drawings.map((d: any) => ({ ...d, elements: JSON.parse(d.elements), appState: JSON.parse(d.appState), files: JSON.parse(d.files || "{}"), })); res.json(parsedDrawings); } catch (error) { console.error(error); res.status(500).json({ error: "Failed to fetch drawings" }); } }); // GET /drawings/:id app.get("/drawings/:id", async (req, res) => { try { const { id } = req.params; console.log("[API] Fetching drawing", { id }); const drawing = await prisma.drawing.findUnique({ where: { id } }); if (!drawing) { console.warn("[API] Drawing not found", { id }); return res.status(404).json({ error: "Drawing not found" }); } console.log("[API] Returning drawing", { id, elementCount: (() => { try { const parsed = JSON.parse(drawing.elements); return Array.isArray(parsed) ? parsed.length : null; } catch (_err) { return null; } })(), }); res.json({ ...drawing, elements: JSON.parse(drawing.elements), appState: JSON.parse(drawing.appState), files: JSON.parse(drawing.files || "{}"), }); } catch (error) { res.status(500).json({ error: "Failed to fetch drawing" }); } }); // POST /drawings app.post("/drawings", async (req, res) => { try { // Additional security validation for imported data 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; const drawingName = payload.name ?? "Untitled Drawing"; const targetCollectionId = payload.collectionId === undefined ? null : payload.collectionId; const newDrawing = await prisma.drawing.create({ data: { name: drawingName, elements: JSON.stringify(payload.elements), appState: JSON.stringify(payload.appState), collectionId: targetCollectionId, preview: payload.preview ?? null, files: JSON.stringify(payload.files ?? {}), }, }); res.json({ ...newDrawing, elements: JSON.parse(newDrawing.elements), appState: JSON.parse(newDrawing.appState), files: JSON.parse(newDrawing.files || "{}"), }); } catch (error) { console.error("Failed to create drawing:", error); res.status(500).json({ error: "Failed to create drawing" }); } }); // PUT /drawings/:id app.put("/drawings/:id", async (req, res) => { try { const { id } = req.params; const parsed = drawingUpdateSchema.safeParse(req.body); if (!parsed.success) { return respondWithValidationErrors(res, parsed.error.issues); } const payload = parsed.data; console.log("[API] Updating drawing", { id, hasElements: payload.elements !== undefined, elementCount: Array.isArray(payload.elements) ? payload.elements.length : undefined, hasAppState: payload.appState !== undefined, hasFiles: payload.files !== undefined, hasPreview: payload.preview !== undefined, }); const data: any = { 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.collectionId !== undefined) data.collectionId = payload.collectionId; if (payload.preview !== undefined) data.preview = payload.preview; const updatedDrawing = await prisma.drawing.update({ where: { id }, data, }); console.log("[API] Update complete", { id, storedElementCount: (() => { try { const parsed = JSON.parse(updatedDrawing.elements); return Array.isArray(parsed) ? parsed.length : null; } catch (_err) { return null; } })(), }); res.json({ ...updatedDrawing, elements: JSON.parse(updatedDrawing.elements), appState: JSON.parse(updatedDrawing.appState), files: JSON.parse(updatedDrawing.files || "{}"), }); } catch (error) { res.status(500).json({ error: "Failed to update drawing" }); } }); // DELETE /drawings/:id app.delete("/drawings/:id", async (req, res) => { try { const { id } = req.params; await prisma.drawing.delete({ where: { id } }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: "Failed to delete drawing" }); } }); // POST /drawings/:id/duplicate app.post("/drawings/:id/duplicate", async (req, res) => { try { const { id } = req.params; const original = await prisma.drawing.findUnique({ where: { id } }); if (!original) { return res.status(404).json({ error: "Original drawing not found" }); } const newDrawing = await prisma.drawing.create({ data: { name: `${original.name} (Copy)`, elements: original.elements, appState: original.appState, files: original.files, collectionId: original.collectionId, version: 1, }, }); res.json({ ...newDrawing, elements: JSON.parse(newDrawing.elements), appState: JSON.parse(newDrawing.appState), files: JSON.parse(newDrawing.files || "{}"), }); } catch (error) { res.status(500).json({ error: "Failed to duplicate drawing" }); } }); // --- Collections --- // GET /collections app.get("/collections", async (req, res) => { try { const collections = await prisma.collection.findMany({ orderBy: { createdAt: "desc" }, }); res.json(collections); } catch (error) { console.error(error); res.status(500).json({ error: "Failed to fetch collections" }); } }); // POST /collections app.post("/collections", async (req, res) => { try { const { name } = req.body; const newCollection = await prisma.collection.create({ data: { name }, }); res.json(newCollection); } catch (error) { res.status(500).json({ error: "Failed to create collection" }); } }); // PUT /collections/:id app.put("/collections/:id", async (req, res) => { try { const { id } = req.params; const { name } = req.body; const updatedCollection = await prisma.collection.update({ where: { id }, data: { name }, }); res.json(updatedCollection); } catch (error) { res.status(500).json({ error: "Failed to update collection" }); } }); // DELETE /collections/:id app.delete("/collections/:id", async (req, res) => { try { const { id } = req.params; // Transaction: Unlink drawings, then delete collection await prisma.$transaction([ prisma.drawing.updateMany({ where: { collectionId: id }, data: { collectionId: null }, }), prisma.collection.delete({ where: { id }, }), ]); res.json({ success: true }); } catch (error) { res.status(500).json({ error: "Failed to delete collection" }); } }); // --- Export/Import Endpoints --- // GET /export - Export SQLite database app.get("/export", async (req, res) => { try { const dbPath = path.resolve(__dirname, "../prisma/dev.db"); try { await fsPromises.access(dbPath); } catch { return res.status(404).json({ error: "Database file not found" }); } res.setHeader("Content-Type", "application/octet-stream"); res.setHeader( "Content-Disposition", `attachment; filename="excalidash-db-${ new Date().toISOString().split("T")[0] }.sqlite"` ); const fileStream = fs.createReadStream(dbPath); fileStream.pipe(res); } catch (error) { console.error(error); res.status(500).json({ error: "Failed to export database" }); } }); // GET /export/json - Export drawings as ZIP of .excalidraw files app.get("/export/json", async (req, res) => { try { const drawings = await prisma.drawing.findMany({ include: { collection: true, }, }); res.setHeader("Content-Type", "application/zip"); res.setHeader( "Content-Disposition", `attachment; filename="excalidraw-drawings-${ new Date().toISOString().split("T")[0] }.zip"` ); const archive = archiver("zip", { zlib: { level: 9 } }); archive.on("error", (err) => { console.error("Archive error:", err); res.status(500).json({ error: "Failed to create archive" }); }); archive.pipe(res); // Group drawings by collection const drawingsByCollection: { [key: string]: any[] } = {}; drawings.forEach((drawing: any) => { const collectionName = drawing.collection?.name || "Unorganized"; if (!drawingsByCollection[collectionName]) { drawingsByCollection[collectionName] = []; } const drawingData = { elements: JSON.parse(drawing.elements), appState: JSON.parse(drawing.appState), files: JSON.parse(drawing.files || "{}"), }; drawingsByCollection[collectionName].push({ name: drawing.name, data: drawingData, }); }); // Create folders and add files Object.entries(drawingsByCollection).forEach( ([collectionName, collectionDrawings]) => { const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_"); // Sanitize folder name collectionDrawings.forEach((drawing, index) => { const fileName = `${drawing.name.replace( /[<>:"/\\|?*]/g, "_" )}.excalidraw`; const filePath = `${folderName}/${fileName}`; archive.append(JSON.stringify(drawing.data, null, 2), { name: filePath, }); }); } ); // Add a readme file const readmeContent = `ExcaliDash Export This archive contains your ExcaliDash drawings organized by collection folders. Structure: - Each collection has its own folder - Each drawing is saved as a .excalidraw file - Files can be imported back into ExcaliDash Export Date: ${new Date().toISOString()} Total Collections: ${Object.keys(drawingsByCollection).length} Total Drawings: ${drawings.length} Collections: ${Object.entries(drawingsByCollection) .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) .join("\n")} `; archive.append(readmeContent, { name: "README.txt" }); await archive.finalize(); } catch (error) { console.error(error); res.status(500).json({ error: "Failed to export drawings" }); } }); // POST /import/sqlite/verify - Verify SQLite database before import app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } const stagedPath = req.file.path; const isValid = await verifyDatabaseIntegrityAsync(stagedPath); await removeFileIfExists(stagedPath); if (!isValid) { return res.status(400).json({ error: "Invalid SQLite file" }); } res.json({ valid: true, message: "Database file is valid" }); } catch (error) { console.error(error); if (req.file) { await removeFileIfExists(req.file.path); } res.status(500).json({ error: "Failed to verify database file" }); } }); // POST /import/sqlite - Import SQLite database app.post("/import/sqlite", upload.single("db"), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } const originalPath = req.file.path; const stagedPath = path.join( uploadDir, `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` ); try { // Use async rename instead of blocking renameSync await fsPromises.rename(originalPath, stagedPath); } catch (error) { console.error("Failed to stage uploaded database", error); await removeFileIfExists(originalPath); await removeFileIfExists(stagedPath); return res.status(500).json({ error: "Failed to stage uploaded file" }); } const isValid = await verifyDatabaseIntegrityAsync(stagedPath); if (!isValid) { await removeFileIfExists(stagedPath); return res .status(400) .json({ error: "Uploaded database failed integrity check" }); } const dbPath = path.resolve(__dirname, "../prisma/dev.db"); const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup"); try { // Use async file operations instead of blocking ones try { await fsPromises.access(dbPath); // Database exists, create backup await fsPromises.copyFile(dbPath, backupPath); } catch { // Database doesn't exist, skip backup } // Move staged file to final location await fsPromises.rename(stagedPath, dbPath); } catch (error) { console.error("Failed to replace database", error); await removeFileIfExists(stagedPath); return res.status(500).json({ error: "Failed to replace database" }); } // Reinitialize Prisma client await prisma.$disconnect(); res.json({ success: true, message: "Database imported successfully" }); } catch (error) { console.error(error); if (req.file) { await removeFileIfExists(req.file.path); } res.status(500).json({ error: "Failed to import database" }); } }); // Ensure Trash collection exists const ensureTrashCollection = async () => { try { const trash = await prisma.collection.findUnique({ where: { id: "trash" }, }); if (!trash) { await prisma.collection.create({ data: { id: "trash", name: "Trash" }, }); console.log("Created Trash collection"); } } catch (error) { console.error("Failed to ensure Trash collection:", error); } }; httpServer.listen(PORT, async () => { // Initialize upload directory asynchronously to avoid blocking startup await initializeUploadDir(); await ensureTrashCollection(); console.log(`Server running on port ${PORT}`); });