|
|
@@ -48,12 +48,14 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
|
|
|
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
|
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
|
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
|
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
|
|
const hasLeadingPrismaDir =
|
|
|
|
const hasLeadingPrismaDir =
|
|
|
|
normalizedRelative === "prisma" ||
|
|
|
|
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
|
|
|
normalizedRelative.startsWith("prisma/");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const absolutePath = path.isAbsolute(filePath)
|
|
|
|
const absolutePath = path.isAbsolute(filePath)
|
|
|
|
? filePath
|
|
|
|
? filePath
|
|
|
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
|
|
|
: path.resolve(
|
|
|
|
|
|
|
|
hasLeadingPrismaDir ? backendRoot : prismaDir,
|
|
|
|
|
|
|
|
normalizedRelative,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return `file:${absolutePath}`;
|
|
|
|
return `file:${absolutePath}`;
|
|
|
|
};
|
|
|
|
};
|
|
|
@@ -129,6 +131,12 @@ const initializeUploadDir = async () => {
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
const app = express();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
|
|
|
|
|
|
|
|
// Required for correct client IP detection when running behind a reverse proxy
|
|
|
|
|
|
|
|
// This fixes CSRF token validation failures in Docker/K8s environments
|
|
|
|
|
|
|
|
app.set("trust proxy", 1);
|
|
|
|
|
|
|
|
|
|
|
|
const httpServer = createServer(app);
|
|
|
|
const httpServer = createServer(app);
|
|
|
|
const io = new Server(httpServer, {
|
|
|
|
const io = new Server(httpServer, {
|
|
|
|
cors: {
|
|
|
|
cors: {
|
|
|
@@ -140,7 +148,7 @@ const io = new Server(httpServer, {
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const parseJsonField = <T>(
|
|
|
|
const parseJsonField = <T>(
|
|
|
|
rawValue: string | null | undefined,
|
|
|
|
rawValue: string | null | undefined,
|
|
|
|
fallback: T
|
|
|
|
fallback: T,
|
|
|
|
): T => {
|
|
|
|
): T => {
|
|
|
|
if (!rawValue) return fallback;
|
|
|
|
if (!rawValue) return fallback;
|
|
|
|
try {
|
|
|
|
try {
|
|
|
@@ -234,7 +242,7 @@ app.use(
|
|
|
|
credentials: true,
|
|
|
|
credentials: true,
|
|
|
|
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
|
|
|
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
|
|
|
exposedHeaders: ["x-csrf-token"],
|
|
|
|
exposedHeaders: ["x-csrf-token"],
|
|
|
|
})
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
app.use(express.json({ limit: "50mb" }));
|
|
|
|
app.use(express.json({ limit: "50mb" }));
|
|
|
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
|
|
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
|
|
@@ -246,8 +254,8 @@ app.use((req, res, next) => {
|
|
|
|
if (sizeInMB > 10) {
|
|
|
|
if (sizeInMB > 10) {
|
|
|
|
console.log(
|
|
|
|
console.log(
|
|
|
|
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
|
|
|
|
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
|
|
|
|
2
|
|
|
|
2,
|
|
|
|
)}MB - Content-Length: ${contentLength} bytes`
|
|
|
|
)}MB - Content-Length: ${contentLength} bytes`,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -261,7 +269,7 @@ app.use((req, res, next) => {
|
|
|
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
|
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
|
|
res.setHeader(
|
|
|
|
res.setHeader(
|
|
|
|
"Permissions-Policy",
|
|
|
|
"Permissions-Policy",
|
|
|
|
"geolocation=(), microphone=(), camera=()"
|
|
|
|
"geolocation=(), microphone=(), camera=()",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
res.setHeader(
|
|
|
|
res.setHeader(
|
|
|
@@ -272,7 +280,7 @@ app.use((req, res, next) => {
|
|
|
|
"font-src 'self' https://fonts.gstatic.com; " +
|
|
|
|
"font-src 'self' https://fonts.gstatic.com; " +
|
|
|
|
"img-src 'self' data: blob: https:; " +
|
|
|
|
"img-src 'self' data: blob: https:; " +
|
|
|
|
"connect-src 'self' ws: wss:; " +
|
|
|
|
"connect-src 'self' ws: wss:; " +
|
|
|
|
"frame-ancestors 'none';"
|
|
|
|
"frame-ancestors 'none';",
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
next();
|
|
|
|
next();
|
|
|
@@ -281,14 +289,17 @@ app.use((req, res, next) => {
|
|
|
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
|
|
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
setInterval(
|
|
|
|
|
|
|
|
() => {
|
|
|
|
const now = Date.now();
|
|
|
|
const now = Date.now();
|
|
|
|
for (const [ip, data] of requestCounts.entries()) {
|
|
|
|
for (const [ip, data] of requestCounts.entries()) {
|
|
|
|
if (now > data.resetTime) {
|
|
|
|
if (now > data.resetTime) {
|
|
|
|
requestCounts.delete(ip);
|
|
|
|
requestCounts.delete(ip);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, 5 * 60 * 1000).unref();
|
|
|
|
},
|
|
|
|
|
|
|
|
5 * 60 * 1000,
|
|
|
|
|
|
|
|
).unref();
|
|
|
|
|
|
|
|
|
|
|
|
const RATE_LIMIT_MAX_REQUESTS = (() => {
|
|
|
|
const RATE_LIMIT_MAX_REQUESTS = (() => {
|
|
|
|
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
|
|
|
|
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
|
|
|
@@ -355,7 +366,10 @@ app.get("/csrf-token", (req, res) => {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
clientLimit.count++;
|
|
|
|
clientLimit.count++;
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
|
|
|
|
csrfRateLimit.set(ip, {
|
|
|
|
|
|
|
|
count: 1,
|
|
|
|
|
|
|
|
resetTime: now + CSRF_RATE_LIMIT_WINDOW,
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup old rate limit entries occasionally
|
|
|
|
// Cleanup old rate limit entries occasionally
|
|
|
@@ -370,7 +384,7 @@ app.get("/csrf-token", (req, res) => {
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
res.json({
|
|
|
|
token,
|
|
|
|
token,
|
|
|
|
header: getCsrfTokenHeader()
|
|
|
|
header: getCsrfTokenHeader(),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
@@ -378,7 +392,7 @@ app.get("/csrf-token", (req, res) => {
|
|
|
|
const csrfProtectionMiddleware = (
|
|
|
|
const csrfProtectionMiddleware = (
|
|
|
|
req: express.Request,
|
|
|
|
req: express.Request,
|
|
|
|
res: express.Response,
|
|
|
|
res: express.Response,
|
|
|
|
next: express.NextFunction
|
|
|
|
next: express.NextFunction,
|
|
|
|
) => {
|
|
|
|
) => {
|
|
|
|
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
|
|
|
|
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
|
|
|
|
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
|
|
|
|
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
|
|
|
@@ -471,7 +485,7 @@ const drawingCreateSchema = drawingBaseSchema
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
{
|
|
|
|
message: "Invalid or malicious drawing data detected",
|
|
|
|
message: "Invalid or malicious drawing data detected",
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const drawingUpdateSchema = drawingBaseSchema
|
|
|
|
const drawingUpdateSchema = drawingBaseSchema
|
|
|
@@ -521,12 +535,12 @@ const drawingUpdateSchema = drawingBaseSchema
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
{
|
|
|
|
message: "Invalid or malicious drawing data detected",
|
|
|
|
message: "Invalid or malicious drawing data detected",
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const respondWithValidationErrors = (
|
|
|
|
const respondWithValidationErrors = (
|
|
|
|
res: express.Response,
|
|
|
|
res: express.Response,
|
|
|
|
issues: z.ZodIssue[]
|
|
|
|
issues: z.ZodIssue[],
|
|
|
|
) => {
|
|
|
|
) => {
|
|
|
|
res.status(400).json({
|
|
|
|
res.status(400).json({
|
|
|
|
error: "Invalid drawing payload",
|
|
|
|
error: "Invalid drawing payload",
|
|
|
@@ -576,7 +590,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
|
|
|
|
path.resolve(__dirname, "./workers/db-verify.js"),
|
|
|
|
path.resolve(__dirname, "./workers/db-verify.js"),
|
|
|
|
{
|
|
|
|
{
|
|
|
|
workerData: { filePath },
|
|
|
|
workerData: { filePath },
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
let timeoutHandle: NodeJS.Timeout;
|
|
|
|
let timeoutHandle: NodeJS.Timeout;
|
|
|
|
let settled = false;
|
|
|
|
let settled = false;
|
|
|
@@ -651,7 +665,7 @@ io.on("connection", (socket) => {
|
|
|
|
roomUsers.set(roomId, filteredUsers);
|
|
|
|
roomUsers.set(roomId, filteredUsers);
|
|
|
|
|
|
|
|
|
|
|
|
io.to(roomId).emit("presence-update", filteredUsers);
|
|
|
|
io.to(roomId).emit("presence-update", filteredUsers);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
socket.on("cursor-move", (data) => {
|
|
|
|
socket.on("cursor-move", (data) => {
|
|
|
@@ -676,7 +690,7 @@ io.on("connection", (socket) => {
|
|
|
|
io.to(roomId).emit("presence-update", users);
|
|
|
|
io.to(roomId).emit("presence-update", users);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
socket.on("disconnect", () => {
|
|
|
|
socket.on("disconnect", () => {
|
|
|
@@ -1067,8 +1081,9 @@ app.get("/export", async (req, res) => {
|
|
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
|
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
|
|
res.setHeader(
|
|
|
|
res.setHeader(
|
|
|
|
"Content-Disposition",
|
|
|
|
"Content-Disposition",
|
|
|
|
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
|
|
|
|
`attachment; filename="excalidash-db-${
|
|
|
|
}.${extension}"`
|
|
|
|
new Date().toISOString().split("T")[0]
|
|
|
|
|
|
|
|
}.${extension}"`,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const fileStream = fs.createReadStream(dbPath);
|
|
|
|
const fileStream = fs.createReadStream(dbPath);
|
|
|
@@ -1090,8 +1105,9 @@ app.get("/export/json", async (req, res) => {
|
|
|
|
res.setHeader("Content-Type", "application/zip");
|
|
|
|
res.setHeader("Content-Type", "application/zip");
|
|
|
|
res.setHeader(
|
|
|
|
res.setHeader(
|
|
|
|
"Content-Disposition",
|
|
|
|
"Content-Disposition",
|
|
|
|
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
|
|
|
|
`attachment; filename="excalidraw-drawings-${
|
|
|
|
}.zip"`
|
|
|
|
new Date().toISOString().split("T")[0]
|
|
|
|
|
|
|
|
}.zip"`,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
|
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
|
@@ -1105,6 +1121,8 @@ app.get("/export/json", async (req, res) => {
|
|
|
|
|
|
|
|
|
|
|
|
const drawingsByCollection: { [key: string]: any[] } = {};
|
|
|
|
const drawingsByCollection: { [key: string]: any[] } = {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const exportSource = `${req.protocol}://${req.get("host")}`;
|
|
|
|
|
|
|
|
|
|
|
|
drawings.forEach((drawing: any) => {
|
|
|
|
drawings.forEach((drawing: any) => {
|
|
|
|
const collectionName = drawing.collection?.name || "Unorganized";
|
|
|
|
const collectionName = drawing.collection?.name || "Unorganized";
|
|
|
|
if (!drawingsByCollection[collectionName]) {
|
|
|
|
if (!drawingsByCollection[collectionName]) {
|
|
|
@@ -1112,6 +1130,9 @@ app.get("/export/json", async (req, res) => {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const drawingData = {
|
|
|
|
const drawingData = {
|
|
|
|
|
|
|
|
type: "excalidraw",
|
|
|
|
|
|
|
|
version: 2,
|
|
|
|
|
|
|
|
source: exportSource,
|
|
|
|
elements: JSON.parse(drawing.elements),
|
|
|
|
elements: JSON.parse(drawing.elements),
|
|
|
|
appState: JSON.parse(drawing.appState),
|
|
|
|
appState: JSON.parse(drawing.appState),
|
|
|
|
files: JSON.parse(drawing.files || "{}"),
|
|
|
|
files: JSON.parse(drawing.files || "{}"),
|
|
|
@@ -1129,7 +1150,7 @@ app.get("/export/json", async (req, res) => {
|
|
|
|
collectionDrawings.forEach((drawing, index) => {
|
|
|
|
collectionDrawings.forEach((drawing, index) => {
|
|
|
|
const fileName = `${drawing.name.replace(
|
|
|
|
const fileName = `${drawing.name.replace(
|
|
|
|
/[<>:"/\\|?*]/g,
|
|
|
|
/[<>:"/\\|?*]/g,
|
|
|
|
"_"
|
|
|
|
"_",
|
|
|
|
)}.excalidraw`;
|
|
|
|
)}.excalidraw`;
|
|
|
|
const filePath = `${folderName}/${fileName}`;
|
|
|
|
const filePath = `${folderName}/${fileName}`;
|
|
|
|
|
|
|
|
|
|
|
@@ -1137,7 +1158,7 @@ app.get("/export/json", async (req, res) => {
|
|
|
|
name: filePath,
|
|
|
|
name: filePath,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const readmeContent = `ExcaliDash Export
|
|
|
|
const readmeContent = `ExcaliDash Export
|
|
|
@@ -1201,7 +1222,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|
|
|
const originalPath = req.file.path;
|
|
|
|
const originalPath = req.file.path;
|
|
|
|
const stagedPath = path.join(
|
|
|
|
const stagedPath = path.join(
|
|
|
|
uploadDir,
|
|
|
|
uploadDir,
|
|
|
|
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
|
|
|
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
@@ -1228,7 +1249,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await fsPromises.access(dbPath);
|
|
|
|
await fsPromises.access(dbPath);
|
|
|
|
await fsPromises.copyFile(dbPath, backupPath);
|
|
|
|
await fsPromises.copyFile(dbPath, backupPath);
|
|
|
|
} catch { }
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
await moveFile(stagedPath, dbPath);
|
|
|
|
await moveFile(stagedPath, dbPath);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|