Compare commits

..

7 Commits

Author SHA1 Message Date
Zimeng Xiong 7dfa69de2a fix export source and verisoning 2026-01-30 14:57:27 -08:00
Zimeng Xiong 81918b00cd chore: release v0.3.1 2026-01-20 13:41:22 -08:00
Zimeng Xiong 3b384dc5fb CSRF token validation failing behind nginx proxy (#38)
Express was not configured to trust proxy headers, causing req.ip to return nginx's internal container IP instead of the actual client IP. In Docker environments, nginx can appear with different internal IPs between requests, causing the CSRF clientId to change and token validation to fail.
2026-01-20 13:39:33 -08:00
Zimeng Xiong 7c238701b7 Update RELEASE.md with CSRF_SECRET instructions (#33)
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:25 -08:00
Zimeng Xiong c5c8b15e75 Update README header to remove version number
Removed version number from README header.
2026-01-14 13:10:43 -08:00
Zimeng Xiong 9bc3c7c8fc chore: release v0.3.0 2026-01-14 11:26:20 -08:00
Zimeng Xiong 0476315322 0.2.1 Release (#32)
* feat(security): implement CSRF protection

* 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

* add K8S note in README, fix broken e2e

* 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>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 11:25:27 -08:00
10 changed files with 106 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash v0.1.8
# ExcaliDash
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
+14
View File
@@ -27,3 +27,17 @@ CSRF Protection (8a78b2b)
- Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- 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.2.1
0.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "0.2.1",
"version": "0.3.1",
"description": "",
"main": "index.js",
"scripts": {
+61 -40
View File
@@ -48,12 +48,14 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
: path.resolve(
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`;
};
@@ -129,6 +131,12 @@ const initializeUploadDir = async () => {
};
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 io = new Server(httpServer, {
cors: {
@@ -140,7 +148,7 @@ const io = new Server(httpServer, {
const prisma = new PrismaClient();
const parseJsonField = <T>(
rawValue: string | null | undefined,
fallback: T
fallback: T,
): T => {
if (!rawValue) return fallback;
try {
@@ -234,7 +242,7 @@ app.use(
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
})
}),
);
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
@@ -246,8 +254,8 @@ app.use((req, res, next) => {
if (sizeInMB > 10) {
console.log(
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
2
)}MB - Content-Length: ${contentLength} bytes`
2,
)}MB - Content-Length: ${contentLength} bytes`,
);
}
}
@@ -261,18 +269,18 @@ app.use((req, res, next) => {
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=()"
"geolocation=(), microphone=(), camera=()",
);
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';"
"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();
@@ -281,14 +289,17 @@ app.use((req, res, next) => {
const requestCounts = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [ip, data] of requestCounts.entries()) {
if (now > data.resetTime) {
requestCounts.delete(ip);
setInterval(
() => {
const now = Date.now();
for (const [ip, data] of requestCounts.entries()) {
if (now > data.resetTime) {
requestCounts.delete(ip);
}
}
}
}, 5 * 60 * 1000).unref();
},
5 * 60 * 1000,
).unref();
const 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++;
} 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
@@ -370,7 +384,7 @@ app.get("/csrf-token", (req, res) => {
res.json({
token,
header: getCsrfTokenHeader()
header: getCsrfTokenHeader(),
});
});
@@ -378,7 +392,7 @@ app.get("/csrf-token", (req, res) => {
const csrfProtectionMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction
next: express.NextFunction,
) => {
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
// 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",
}
},
);
const drawingUpdateSchema = drawingBaseSchema
@@ -521,12 +535,12 @@ const drawingUpdateSchema = drawingBaseSchema
},
{
message: "Invalid or malicious drawing data detected",
}
},
);
const respondWithValidationErrors = (
res: express.Response,
issues: z.ZodIssue[]
issues: z.ZodIssue[],
) => {
res.status(400).json({
error: "Invalid drawing payload",
@@ -576,7 +590,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
path.resolve(__dirname, "./workers/db-verify.js"),
{
workerData: { filePath },
}
},
);
let timeoutHandle: NodeJS.Timeout;
let settled = false;
@@ -651,7 +665,7 @@ io.on("connection", (socket) => {
roomUsers.set(roomId, filteredUsers);
io.to(roomId).emit("presence-update", filteredUsers);
}
},
);
socket.on("cursor-move", (data) => {
@@ -676,7 +690,7 @@ io.on("connection", (socket) => {
io.to(roomId).emit("presence-update", users);
}
}
}
},
);
socket.on("disconnect", () => {
@@ -1067,8 +1081,9 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
}.${extension}"`
`attachment; filename="excalidash-db-${
new Date().toISOString().split("T")[0]
}.${extension}"`,
);
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-Disposition",
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
}.zip"`
`attachment; filename="excalidraw-drawings-${
new Date().toISOString().split("T")[0]
}.zip"`,
);
const archive = archiver("zip", { zlib: { level: 9 } });
@@ -1105,6 +1121,8 @@ app.get("/export/json", async (req, res) => {
const drawingsByCollection: { [key: string]: any[] } = {};
const exportSource = `${req.protocol}://${req.get("host")}`;
drawings.forEach((drawing: any) => {
const collectionName = drawing.collection?.name || "Unorganized";
if (!drawingsByCollection[collectionName]) {
@@ -1112,6 +1130,9 @@ app.get("/export/json", async (req, res) => {
}
const drawingData = {
type: "excalidraw",
version: 2,
source: exportSource,
elements: JSON.parse(drawing.elements),
appState: JSON.parse(drawing.appState),
files: JSON.parse(drawing.files || "{}"),
@@ -1129,7 +1150,7 @@ app.get("/export/json", async (req, res) => {
collectionDrawings.forEach((drawing, index) => {
const fileName = `${drawing.name.replace(
/[<>:"/\\|?*]/g,
"_"
"_",
)}.excalidraw`;
const filePath = `${folderName}/${fileName}`;
@@ -1137,7 +1158,7 @@ app.get("/export/json", async (req, res) => {
name: filePath,
});
});
}
},
);
const readmeContent = `ExcaliDash Export
@@ -1155,8 +1176,8 @@ Total Drawings: ${drawings.length}
Collections:
${Object.entries(drawingsByCollection)
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")}
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
.join("\n")}
`;
archive.append(readmeContent, { name: "README.txt" });
@@ -1201,7 +1222,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
const originalPath = req.file.path;
const stagedPath = path.join(
uploadDir,
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
try {
@@ -1228,7 +1249,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
try {
await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath);
} catch { }
} catch {}
await moveFile(stagedPath, dbPath);
} catch (error) {
-1
View File
@@ -532,7 +532,6 @@ export const validateImportedDrawing = (data: any): boolean => {
// CSRF Protection
// ============================================================================
const CSRF_TOKEN_LENGTH = 32;
const CSRF_TOKEN_HEADER = "x-csrf-token";
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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.2.1",
"version": "0.3.1",
"type": "module",
"scripts": {
"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 newTasks: UploadTask[] = files.map(f => ({
id: Math.random().toString(36).substring(2, 11),
id: crypto.randomUUID(),
fileName: f.name,
status: 'pending',
progress: 0
@@ -56,12 +56,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
setTasks(prev => [...newTasks, ...prev]);
// Map file names to task IDs for progress callbacks
const fileTaskMap = new Map<string, string>();
newTasks.forEach(t => fileTaskMap.set(t.fileName, t.id));
// Map file index to task ID for progress callbacks (handles duplicate filenames)
const indexToTaskId = new Map<number, string>();
newTasks.forEach((t, index) => indexToTaskId.set(index, t.id));
const handleProgress = (fileName: string, status: UploadStatus, progress: number, error?: string) => {
const taskId = fileTaskMap.get(fileName);
const handleProgress = (fileIndex: number, status: UploadStatus, progress: number, error?: string) => {
const taskId = indexToTaskId.get(fileIndex);
if (taskId) {
updateTask(taskId, { status, progress, error });
}
+15 -7
View File
@@ -7,7 +7,7 @@ export const importDrawings = async (
targetCollectionId: string | null,
onSuccess?: () => void | Promise<void>,
onProgress?: (
fileName: string,
fileIndex: number,
status: UploadStatus,
progress: number,
error?: string
@@ -25,12 +25,20 @@ export const importDrawings = async (
let failCount = 0;
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.
// For now, full parallel is fine as browser limits connection count anyway.
await Promise.all(
drawingFiles.map(async (file) => {
drawingFiles.map(async (file, drawingIndex) => {
const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex;
try {
if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase
if (onProgress) onProgress(fileIndex, 'processing', 0); // Parsing phase
const text = await file.text();
const data = JSON.parse(text);
@@ -61,7 +69,7 @@ export const importDrawings = async (
preview: svg.outerHTML,
};
if (onProgress) onProgress(file.name, 'uploading', 0);
if (onProgress) onProgress(fileIndex, 'uploading', 0);
await api.post("/drawings", payload, {
headers: {
@@ -73,12 +81,12 @@ export const importDrawings = async (
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(file.name, 'uploading', percentCompleted);
onProgress(fileIndex, 'uploading', percentCompleted);
}
},
});
if (onProgress) onProgress(file.name, 'success', 100);
if (onProgress) onProgress(fileIndex, 'success', 100);
successCount++;
} catch (err: any) {
@@ -90,7 +98,7 @@ export const importDrawings = async (
err?.message ||
"Upload failed";
errors.push(`${file.name}: ${errorMessage}`);
if (onProgress) onProgress(file.name, 'error', 0, errorMessage);
if (onProgress) onProgress(fileIndex, 'error', 0, errorMessage);
}
})
);
+6 -9
View File
@@ -15,19 +15,16 @@ try {
console.warn("Unable to read VERSION file:", error);
}
if (
!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";
}
}
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
},
server: {
proxy: {
"/api": {