Compare commits

..

5 Commits

Author SHA1 Message Date
Zimeng Xiong 44fb456405 chore: pre-release v0.2.1-dev 2026-01-14 10:38:28 -08:00
adamant368 8f9b9b4945 feat/upload-bar (#30)
* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
2026-01-14 09:25:17 -08:00
Zimeng Xiong cae8f3cbf6 add K8S note in README, fix broken e2e 2026-01-14 08:57:04 -08:00
Zimeng Xiong e4e48b13d8 chore: clean up CSRF implementation
- Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety
2026-01-14 08:21:49 -08:00
AdrianAcala 8a78b2bb2e feat(security): implement CSRF protection 2025-12-21 10:08:05 -08:00
10 changed files with 67 additions and 106 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88"> <img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash # ExcaliDash v0.1.8
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash) ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
-14
View File
@@ -27,17 +27,3 @@ CSRF Protection (8a78b2b)
- Updated docker-compose configurations with new environment variables - Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes - E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README - Added Kubernetes deployment note in README
### Kubernetes
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
```bash
openssl rand -base64 32
Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
If not set, the backend will refuse to start.
```
+1 -1
View File
@@ -1 +1 @@
0.3.1 0.2.1
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.3.1", "version": "0.2.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+40 -61
View File
@@ -48,14 +48,12 @@ 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.startsWith("prisma/"); normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve( : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -131,12 +129,6 @@ 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: {
@@ -148,7 +140,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 {
@@ -242,7 +234,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" }));
@@ -254,8 +246,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`
); );
} }
} }
@@ -269,18 +261,18 @@ 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(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; " + "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"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();
@@ -289,17 +281,14 @@ 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, }, 5 * 60 * 1000).unref();
).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);
@@ -366,10 +355,7 @@ app.get("/csrf-token", (req, res) => {
} }
clientLimit.count++; clientLimit.count++;
} else { } else {
csrfRateLimit.set(ip, { csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
count: 1,
resetTime: now + CSRF_RATE_LIMIT_WINDOW,
});
} }
// Cleanup old rate limit entries occasionally // Cleanup old rate limit entries occasionally
@@ -384,7 +370,7 @@ app.get("/csrf-token", (req, res) => {
res.json({ res.json({
token, token,
header: getCsrfTokenHeader(), header: getCsrfTokenHeader()
}); });
}); });
@@ -392,7 +378,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
@@ -485,7 +471,7 @@ const drawingCreateSchema = drawingBaseSchema
}, },
{ {
message: "Invalid or malicious drawing data detected", message: "Invalid or malicious drawing data detected",
}, }
); );
const drawingUpdateSchema = drawingBaseSchema const drawingUpdateSchema = drawingBaseSchema
@@ -535,12 +521,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",
@@ -590,7 +576,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;
@@ -665,7 +651,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) => {
@@ -690,7 +676,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", () => {
@@ -1081,9 +1067,8 @@ 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-${ `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0] }.${extension}"`
}.${extension}"`,
); );
const fileStream = fs.createReadStream(dbPath); const fileStream = fs.createReadStream(dbPath);
@@ -1105,9 +1090,8 @@ 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-${ `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0] }.zip"`
}.zip"`,
); );
const archive = archiver("zip", { zlib: { level: 9 } }); const archive = archiver("zip", { zlib: { level: 9 } });
@@ -1121,8 +1105,6 @@ 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]) {
@@ -1130,9 +1112,6 @@ 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 || "{}"),
@@ -1150,7 +1129,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}`;
@@ -1158,7 +1137,7 @@ app.get("/export/json", async (req, res) => {
name: filePath, name: filePath,
}); });
}); });
}, }
); );
const readmeContent = `ExcaliDash Export const readmeContent = `ExcaliDash Export
@@ -1176,8 +1155,8 @@ Total Drawings: ${drawings.length}
Collections: Collections:
${Object.entries(drawingsByCollection) ${Object.entries(drawingsByCollection)
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")} .join("\n")}
`; `;
archive.append(readmeContent, { name: "README.txt" }); archive.append(readmeContent, { name: "README.txt" });
@@ -1222,7 +1201,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 {
@@ -1249,7 +1228,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) {
+1
View File
@@ -532,6 +532,7 @@ export const validateImportedDrawing = (data: any): boolean => {
// CSRF Protection // CSRF Protection
// ============================================================================ // ============================================================================
const CSRF_TOKEN_LENGTH = 32;
const CSRF_TOKEN_HEADER = "x-csrf-token"; const CSRF_TOKEN_HEADER = "x-csrf-token";
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.3.1", "version": "0.2.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -6
View File
@@ -48,7 +48,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => { const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
const newTasks: UploadTask[] = files.map(f => ({ const newTasks: UploadTask[] = files.map(f => ({
id: crypto.randomUUID(), id: Math.random().toString(36).substring(2, 11),
fileName: f.name, fileName: f.name,
status: 'pending', status: 'pending',
progress: 0 progress: 0
@@ -56,12 +56,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
setTasks(prev => [...newTasks, ...prev]); setTasks(prev => [...newTasks, ...prev]);
// Map file index to task ID for progress callbacks (handles duplicate filenames) // Map file names to task IDs for progress callbacks
const indexToTaskId = new Map<number, string>(); const fileTaskMap = new Map<string, string>();
newTasks.forEach((t, index) => indexToTaskId.set(index, t.id)); newTasks.forEach(t => fileTaskMap.set(t.fileName, t.id));
const handleProgress = (fileIndex: number, status: UploadStatus, progress: number, error?: string) => { const handleProgress = (fileName: string, status: UploadStatus, progress: number, error?: string) => {
const taskId = indexToTaskId.get(fileIndex); const taskId = fileTaskMap.get(fileName);
if (taskId) { if (taskId) {
updateTask(taskId, { status, progress, error }); updateTask(taskId, { status, progress, error });
} }
+7 -15
View File
@@ -7,7 +7,7 @@ export const importDrawings = async (
targetCollectionId: string | null, targetCollectionId: string | null,
onSuccess?: () => void | Promise<void>, onSuccess?: () => void | Promise<void>,
onProgress?: ( onProgress?: (
fileIndex: number, fileName: string,
status: UploadStatus, status: UploadStatus,
progress: number, progress: number,
error?: string error?: string
@@ -25,20 +25,12 @@ export const importDrawings = async (
let failCount = 0; let failCount = 0;
const errors: string[] = []; const errors: string[] = [];
// Build a map from drawingFile index to original file index for progress reporting
const originalIndexMap = new Map<number, number>();
drawingFiles.forEach((df, i) => {
const originalIndex = files.indexOf(df);
originalIndexMap.set(i, originalIndex);
});
// We process files in parallel (Promise.all) but we could limit concurrency if needed. // We process files in parallel (Promise.all) but we could limit concurrency if needed.
// For now, full parallel is fine as browser limits connection count anyway. // For now, full parallel is fine as browser limits connection count anyway.
await Promise.all( await Promise.all(
drawingFiles.map(async (file, drawingIndex) => { drawingFiles.map(async (file) => {
const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex;
try { try {
if (onProgress) onProgress(fileIndex, 'processing', 0); // Parsing phase if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text); const data = JSON.parse(text);
@@ -69,7 +61,7 @@ export const importDrawings = async (
preview: svg.outerHTML, preview: svg.outerHTML,
}; };
if (onProgress) onProgress(fileIndex, 'uploading', 0); if (onProgress) onProgress(file.name, 'uploading', 0);
await api.post("/drawings", payload, { await api.post("/drawings", payload, {
headers: { headers: {
@@ -81,12 +73,12 @@ export const importDrawings = async (
const percentCompleted = Math.round( const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total (progressEvent.loaded * 100) / progressEvent.total
); );
onProgress(fileIndex, 'uploading', percentCompleted); onProgress(file.name, 'uploading', percentCompleted);
} }
}, },
}); });
if (onProgress) onProgress(fileIndex, 'success', 100); if (onProgress) onProgress(file.name, 'success', 100);
successCount++; successCount++;
} catch (err: any) { } catch (err: any) {
@@ -98,7 +90,7 @@ export const importDrawings = async (
err?.message || err?.message ||
"Upload failed"; "Upload failed";
errors.push(`${file.name}: ${errorMessage}`); errors.push(`${file.name}: ${errorMessage}`);
if (onProgress) onProgress(fileIndex, 'error', 0, errorMessage); if (onProgress) onProgress(file.name, 'error', 0, errorMessage);
} }
}) })
); );
+9 -6
View File
@@ -15,16 +15,19 @@ try {
console.warn("Unable to read VERSION file:", error); console.warn("Unable to read VERSION file:", error);
} }
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile; if (
const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build"; !process.env.VITE_APP_VERSION ||
process.env.VITE_APP_VERSION.trim().length === 0
) {
process.env.VITE_APP_VERSION = versionFromFile;
if (!process.env.VITE_APP_BUILD_LABEL) {
process.env.VITE_APP_BUILD_LABEL = "local development build";
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
},
server: { server: {
proxy: { proxy: {
"/api": { "/api": {