1044 lines
30 KiB
TypeScript
1044 lines
30 KiB
TypeScript
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");
|
|
|
|
const moveFile = async (source: string, destination: string) => {
|
|
try {
|
|
await fsPromises.rename(source, destination);
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (!err || err.code !== "EXDEV") {
|
|
throw error;
|
|
}
|
|
|
|
// Cross-device rename fallback: copy then delete source
|
|
await fsPromises
|
|
.unlink(destination)
|
|
.catch((unlinkError: NodeJS.ErrnoException) => {
|
|
if (unlinkError && unlinkError.code !== "ENOENT") {
|
|
throw unlinkError;
|
|
}
|
|
});
|
|
|
|
await fsPromises.copyFile(source, destination);
|
|
await fsPromises.unlink(source);
|
|
}
|
|
};
|
|
|
|
// 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 SQLite database extensions for database imports
|
|
if (file.fieldname === "db") {
|
|
const isSqliteDb =
|
|
file.originalname.endsWith(".db") ||
|
|
file.originalname.endsWith(".sqlite");
|
|
if (!isSqliteDb) {
|
|
return cb(new Error("Only .db or .sqlite 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<string, { count: number; resetTime: number }>();
|
|
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: Array.isArray(data.elements) ? data.elements : [],
|
|
appState:
|
|
typeof data.appState === "object" && data.appState !== null
|
|
? 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);
|
|
// For updates, if sanitization fails but we have minimal data, allow it to pass
|
|
// This prevents legitimate empty drawings from failing
|
|
if (
|
|
data.elements === undefined &&
|
|
data.appState === undefined &&
|
|
(data.name !== undefined ||
|
|
data.preview !== undefined ||
|
|
data.collectionId !== undefined)
|
|
) {
|
|
return true;
|
|
}
|
|
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<boolean> => {
|
|
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<string, User[]>();
|
|
|
|
io.on("connection", (socket) => {
|
|
socket.on(
|
|
"join-room",
|
|
({
|
|
drawingId,
|
|
user,
|
|
}: {
|
|
drawingId: string;
|
|
user: Omit<User, "socketId" | "isActive">;
|
|
}) => {
|
|
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;
|
|
|
|
console.log("[API] Update request received", {
|
|
id,
|
|
bodyKeys: Object.keys(req.body || {}),
|
|
hasElements: req.body?.elements !== undefined,
|
|
elementCount: Array.isArray(req.body?.elements)
|
|
? req.body.elements.length
|
|
: undefined,
|
|
hasAppState: req.body?.appState !== undefined,
|
|
appStateKeys: req.body?.appState ? Object.keys(req.body.appState) : [],
|
|
hasFiles: req.body?.files !== undefined,
|
|
hasPreview: req.body?.preview !== undefined,
|
|
});
|
|
|
|
const parsed = drawingUpdateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
console.error("[API] Validation failed", {
|
|
id,
|
|
errorCount: parsed.error.issues.length,
|
|
errors: parsed.error.issues.map((issue) => ({
|
|
path: issue.path,
|
|
message: issue.message,
|
|
received:
|
|
issue.path.length > 0 ? req.body?.[issue.path.join(".")] : "root",
|
|
})),
|
|
});
|
|
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) {
|
|
console.error("[CRITICAL] Update failed:", 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 (supports .sqlite and .db extensions)
|
|
app.get("/export", async (req, res) => {
|
|
try {
|
|
const formatParam =
|
|
typeof req.query.format === "string"
|
|
? req.query.format.toLowerCase()
|
|
: undefined;
|
|
const extension = formatParam === "db" ? "db" : "sqlite";
|
|
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]
|
|
}.${extension}"`
|
|
);
|
|
|
|
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 database format" });
|
|
}
|
|
|
|
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 {
|
|
await moveFile(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, supporting cross-device mounts
|
|
await moveFile(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}`);
|
|
});
|