Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d1fe8e0e5 | |||
| b6d0150d44 | |||
| 55cd816cca | |||
| d67bd1daf8 | |||
| 4b56d3cfc6 | |||
| 88ed4360c0 |
@@ -511,6 +511,17 @@ release-docker: ## Build and push release Docker images
|
||||
pre-release-docker: ## Build and push pre-release Docker images
|
||||
./publish-docker-prerelease.sh
|
||||
|
||||
dev-release: ## Build and push custom dev release (usage: make dev-release NAME=issue38)
|
||||
@if [ -z "$(NAME)" ]; then \
|
||||
echo "$(RED)ERROR: NAME parameter is required!$(NC)"; \
|
||||
echo "$(YELLOW)Usage: make dev-release NAME=<custom-name>$(NC)"; \
|
||||
echo "$(YELLOW)Example: make dev-release NAME=issue38$(NC)"; \
|
||||
echo "$(YELLOW) This will create tags like: 0.3.1-dev-issue38$(NC)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(BLUE)Building custom dev release: $(NAME)$(NC)"
|
||||
@./publish-docker-dev.sh $(NAME)
|
||||
|
||||
#===============================================================================
|
||||
# DATABASE
|
||||
#===============================================================================
|
||||
|
||||
@@ -120,14 +120,17 @@ docker compose up -d
|
||||
|
||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
||||
|
||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks.
|
||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
backend:
|
||||
environment:
|
||||
# Single URL
|
||||
- FRONTEND_URL=https://excalidash.example.com
|
||||
# Or multiple URLs (comma-separated) for local + network access
|
||||
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||
frontend:
|
||||
environment:
|
||||
# For standard Docker Compose (default)
|
||||
|
||||
Generated
+5
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
@@ -1128,7 +1128,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -3287,9 +3286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
@@ -3814,7 +3813,6 @@
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines": "5.22.0"
|
||||
},
|
||||
@@ -4821,7 +4819,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4980,7 +4977,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5058,7 +5054,6 @@
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5152,7 +5147,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Issue #38: CSRF fails with multiple reverse proxies
|
||||
*
|
||||
* This test demonstrates how trust proxy settings affect CSRF validation
|
||||
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import {
|
||||
createCsrfToken,
|
||||
validateCsrfToken,
|
||||
getCsrfTokenHeader,
|
||||
} from "../security";
|
||||
|
||||
// mock the getClientId function behavior
|
||||
const getClientIdFromRequest = (req: express.Request): string => {
|
||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||
const userAgent = req.headers["user-agent"] || "unknown";
|
||||
return `${ip}:${userAgent}`.slice(0, 256);
|
||||
};
|
||||
|
||||
describe("Issue #38: CSRF with trust proxy settings", () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
});
|
||||
|
||||
it("demonstrates the trust proxy issue with multiple proxies", async () => {
|
||||
// ext proxy -> frontend nginx -> backend
|
||||
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
|
||||
|
||||
// With trust proxy: 1 (current setting)
|
||||
const app1 = express();
|
||||
app1.set("trust proxy", 1);
|
||||
app1.use(express.json());
|
||||
|
||||
app1.get("/test-ip", (req, res) => {
|
||||
res.json({
|
||||
ip: req.ip,
|
||||
clientId: getClientIdFromRequest(req),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate request through multiple proxies
|
||||
const response1 = await request(app1)
|
||||
.get("/test-ip")
|
||||
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||
.set("User-Agent", "Mozilla/5.0 Test");
|
||||
|
||||
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
|
||||
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
|
||||
expect(response1.body.ip).toBe("172.17.0.3");
|
||||
console.log(
|
||||
"trust proxy: 1 → IP:",
|
||||
response1.body.ip,
|
||||
"(not the real client IP)",
|
||||
);
|
||||
|
||||
// With trust proxy: true
|
||||
const app2 = express();
|
||||
app2.set("trust proxy", true);
|
||||
app2.use(express.json());
|
||||
|
||||
app2.get("/test-ip", (req, res) => {
|
||||
res.json({
|
||||
ip: req.ip,
|
||||
clientId: getClientIdFromRequest(req),
|
||||
});
|
||||
});
|
||||
|
||||
const response2 = await request(app2)
|
||||
.get("/test-ip")
|
||||
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||
.set("User-Agent", "Mozilla/5.0 Test");
|
||||
|
||||
// With trust proxy: true, Express takes leftmost IP
|
||||
expect(response2.body.ip).toBe("203.0.113.42");
|
||||
console.log(
|
||||
"trust proxy: true → IP:",
|
||||
response2.body.ip,
|
||||
"(real client IP - CORRECT)",
|
||||
);
|
||||
});
|
||||
|
||||
it("simulates CSRF failure scenario from issue #38", async () => {
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||
|
||||
// Request 1: Fetch CSRF token
|
||||
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
|
||||
const clientIp1 = "203.0.113.42";
|
||||
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
|
||||
|
||||
// With trust proxy: 1, Express sees the external proxy IP
|
||||
const clientId1 = `${externalProxyIp1}:${userAgent}`;
|
||||
const token = createCsrfToken(clientId1);
|
||||
|
||||
console.log(
|
||||
" X-Forwarded-For:",
|
||||
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
|
||||
);
|
||||
console.log(" Express sees IP:", externalProxyIp1);
|
||||
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
|
||||
|
||||
// Request 2: Try to create drawing with token
|
||||
// External proxy IP might differ slightly
|
||||
const externalProxyIp2 = "10.0.0.6";
|
||||
|
||||
const clientId2 = `${externalProxyIp2}:${userAgent}`;
|
||||
|
||||
console.log(
|
||||
" X-Forwarded-For:",
|
||||
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
|
||||
);
|
||||
console.log(" Express sees IP:", externalProxyIp2);
|
||||
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
|
||||
|
||||
// CSRF validation fails because clientId changed
|
||||
const isValid = validateCsrfToken(clientId2, token);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
console.log(" Expected:", clientId1.slice(0, 50) + "...");
|
||||
console.log(" Got:", clientId2.slice(0, 50) + "...");
|
||||
});
|
||||
|
||||
it("shows the fix works with trust proxy: true", async () => {
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||
const realClientIp = "203.0.113.42";
|
||||
|
||||
const clientId1 = `${realClientIp}:${userAgent}`;
|
||||
const token = createCsrfToken(clientId1);
|
||||
|
||||
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
|
||||
console.log(" Express sees IP:", realClientIp);
|
||||
|
||||
// Request 2: Use token (even if middle proxy IPs differ)
|
||||
const clientId2 = `${realClientIp}:${userAgent}`;
|
||||
|
||||
console.log("Create drawing");
|
||||
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
|
||||
console.log("Express sees IP:", realClientIp, "(same!)");
|
||||
|
||||
const isValid = validateCsrfToken(clientId2, token);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
console.log("\nCSRF Validation: SUCCESS");
|
||||
});
|
||||
|
||||
it("demonstrates the Synology NAS scenario from issue #38", async () => {
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.use(express.json());
|
||||
|
||||
let seenIp: string | undefined;
|
||||
app.get("/test", (req, res) => {
|
||||
seenIp = req.ip;
|
||||
res.json({ ip: req.ip });
|
||||
});
|
||||
|
||||
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
|
||||
// In supertest without real socket, trust proxy: 1 returns last IP
|
||||
// Key point: it's NOT the real client IP (192.168.0.100)
|
||||
await request(app)
|
||||
.get("/test")
|
||||
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
|
||||
console.log(" With trust proxy: 1, Express sees:", seenIp);
|
||||
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
|
||||
});
|
||||
});
|
||||
+73
-60
@@ -48,14 +48,12 @@ 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}`;
|
||||
};
|
||||
@@ -134,8 +132,21 @@ 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);
|
||||
// Fix for issue #38: Use 'true' to handle multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||
// This ensures Express extracts the real client IP from the leftmost X-Forwarded-For value
|
||||
const trustProxyConfig = process.env.TRUST_PROXY || "true";
|
||||
const trustProxyValue = trustProxyConfig === "true"
|
||||
? true
|
||||
: trustProxyConfig === "false"
|
||||
? false
|
||||
: parseInt(trustProxyConfig, 10) || 1;
|
||||
app.set("trust proxy", trustProxyValue);
|
||||
|
||||
if (trustProxyValue === true) {
|
||||
console.log("[config] trust proxy: enabled (handles multiple proxy layers)");
|
||||
} else {
|
||||
console.log(`[config] trust proxy: ${trustProxyValue}`);
|
||||
}
|
||||
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
@@ -148,7 +159,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 {
|
||||
@@ -242,7 +253,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" }));
|
||||
@@ -254,8 +265,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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,18 +280,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();
|
||||
@@ -289,17 +300,14 @@ 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);
|
||||
@@ -335,9 +343,24 @@ app.use((req, res, next) => {
|
||||
const getClientId = (req: express.Request): string => {
|
||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||
const userAgent = req.headers["user-agent"] || "unknown";
|
||||
// Create a simple hash for client identification
|
||||
// In production, you might use a session ID instead
|
||||
return `${ip}:${userAgent}`.slice(0, 256);
|
||||
const clientId = `${ip}:${userAgent}`.slice(0, 256);
|
||||
|
||||
// Debug logging for CSRF troubleshooting (issue #38)
|
||||
if (process.env.DEBUG_CSRF === "true") {
|
||||
console.log("[CSRF DEBUG] getClientId", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip,
|
||||
remoteAddress: req.connection.remoteAddress,
|
||||
"x-forwarded-for": req.headers["x-forwarded-for"],
|
||||
"x-real-ip": req.headers["x-real-ip"],
|
||||
userAgent: userAgent.slice(0, 100),
|
||||
clientIdPreview: clientId.slice(0, 60) + "...",
|
||||
trustProxySetting: req.app.get("trust proxy"),
|
||||
});
|
||||
}
|
||||
|
||||
return clientId;
|
||||
};
|
||||
|
||||
// Rate limiter specifically for CSRF token generation to prevent store exhaustion
|
||||
@@ -366,10 +389,7 @@ 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
|
||||
@@ -384,7 +404,7 @@ app.get("/csrf-token", (req, res) => {
|
||||
|
||||
res.json({
|
||||
token,
|
||||
header: getCsrfTokenHeader(),
|
||||
header: getCsrfTokenHeader()
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,7 +412,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
|
||||
@@ -485,7 +505,7 @@ const drawingCreateSchema = drawingBaseSchema
|
||||
},
|
||||
{
|
||||
message: "Invalid or malicious drawing data detected",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const drawingUpdateSchema = drawingBaseSchema
|
||||
@@ -535,12 +555,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",
|
||||
@@ -590,7 +610,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
|
||||
path.resolve(__dirname, "./workers/db-verify.js"),
|
||||
{
|
||||
workerData: { filePath },
|
||||
},
|
||||
}
|
||||
);
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
let settled = false;
|
||||
@@ -665,7 +685,7 @@ io.on("connection", (socket) => {
|
||||
roomUsers.set(roomId, filteredUsers);
|
||||
|
||||
io.to(roomId).emit("presence-update", filteredUsers);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on("cursor-move", (data) => {
|
||||
@@ -690,7 +710,7 @@ io.on("connection", (socket) => {
|
||||
io.to(roomId).emit("presence-update", users);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
@@ -1081,9 +1101,8 @@ 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);
|
||||
@@ -1105,9 +1124,8 @@ 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 } });
|
||||
@@ -1121,8 +1139,6 @@ 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]) {
|
||||
@@ -1130,9 +1146,6 @@ 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 || "{}"),
|
||||
@@ -1150,7 +1163,7 @@ app.get("/export/json", async (req, res) => {
|
||||
collectionDrawings.forEach((drawing, index) => {
|
||||
const fileName = `${drawing.name.replace(
|
||||
/[<>:"/\\|?*]/g,
|
||||
"_",
|
||||
"_"
|
||||
)}.excalidraw`;
|
||||
const filePath = `${folderName}/${fileName}`;
|
||||
|
||||
@@ -1158,7 +1171,7 @@ app.get("/export/json", async (req, res) => {
|
||||
name: filePath,
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const readmeContent = `ExcaliDash Export
|
||||
@@ -1176,8 +1189,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" });
|
||||
@@ -1222,7 +1235,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 {
|
||||
@@ -1249,7 +1262,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) {
|
||||
|
||||
@@ -569,8 +569,12 @@ const getCsrfSecret = (): Buffer => {
|
||||
cachedCsrfSecret = crypto.randomBytes(32);
|
||||
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
||||
console.warn(
|
||||
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
|
||||
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
||||
`[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
|
||||
`Using an ephemeral per-process secret.\n` +
|
||||
` - Tokens will expire on container restart\n` +
|
||||
` - Horizontal scaling (k8s) will NOT work\n` +
|
||||
` - Generate a secret: openssl rand -base64 32\n` +
|
||||
` - Set environment variable: CSRF_SECRET=<generated-secret>`
|
||||
);
|
||||
return cachedCsrfSecret;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Custom name is required
|
||||
CUSTOM_NAME=$1
|
||||
|
||||
if [ -z "$CUSTOM_NAME" ]; then
|
||||
echo -e "${RED}ERROR: Custom name is required!${NC}"
|
||||
echo -e "${YELLOW}Usage: $0 <custom-name>${NC}"
|
||||
echo -e "${YELLOW}Example: $0 issue38${NC}"
|
||||
echo -e "${YELLOW} This will create tags like: 0.3.1-dev-issue38${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
DOCKER_USERNAME="zimengxiong"
|
||||
IMAGE_NAME="excalidash"
|
||||
BASE_VERSION=$(node -e "try { console.log(require('fs').readFileSync('VERSION', 'utf8').trim()) } catch { console.log('0.0.0') }")
|
||||
VERSION="${BASE_VERSION}-dev-${CUSTOM_NAME}"
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
echo -e "${BLUE}===========================================${NC}"
|
||||
echo -e "${BLUE}ExcaliDash Custom Dev Release${NC}"
|
||||
echo -e "${BLUE}===========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Branch: ${CURRENT_BRANCH}${NC}"
|
||||
echo -e "${YELLOW}Base version: ${BASE_VERSION}${NC}"
|
||||
echo -e "${YELLOW}Custom name: ${CUSTOM_NAME}${NC}"
|
||||
echo -e "${YELLOW}Full tag: ${VERSION}${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This will publish images with tag: ${VERSION}${NC}"
|
||||
echo -e "${YELLOW}Dev images will NOT update 'latest' or 'dev' tags${NC}"
|
||||
echo ""
|
||||
|
||||
# Confirm before proceeding
|
||||
read -p "Continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${RED}Aborted.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if logged in to Docker Hub
|
||||
echo -e "${YELLOW}Checking Docker Hub authentication...${NC}"
|
||||
if ! docker info | grep -q "Username: $DOCKER_USERNAME"; then
|
||||
echo -e "${YELLOW}Not logged in. Please login to Docker Hub:${NC}"
|
||||
docker login
|
||||
else
|
||||
echo -e "${GREEN}✓ Already logged in as $DOCKER_USERNAME${NC}"
|
||||
fi
|
||||
|
||||
# Create buildx builder if it doesn't exist
|
||||
echo -e "${YELLOW}Setting up buildx builder...${NC}"
|
||||
if ! docker buildx inspect excalidash-builder > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Creating new buildx builder...${NC}"
|
||||
docker buildx create --name excalidash-builder --use --bootstrap
|
||||
else
|
||||
echo -e "${GREEN}✓ Using existing buildx builder${NC}"
|
||||
docker buildx use excalidash-builder
|
||||
fi
|
||||
|
||||
# Build and push backend image
|
||||
echo ""
|
||||
echo -e "${BLUE}Building and pushing backend image...${NC}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION \
|
||||
--file backend/Dockerfile \
|
||||
--push \
|
||||
backend/
|
||||
|
||||
echo -e "${GREEN}✓ Backend image pushed successfully${NC}"
|
||||
|
||||
# Build and push frontend image
|
||||
echo ""
|
||||
echo -e "${BLUE}Building and pushing frontend image...${NC}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION \
|
||||
--build-arg VITE_APP_VERSION=$VERSION \
|
||||
--file frontend/Dockerfile \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo -e "${GREEN}✓ Frontend image pushed successfully${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}===========================================${NC}"
|
||||
echo -e "${GREEN}✓ Custom dev images published!${NC}"
|
||||
echo -e "${BLUE}===========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Images published:${NC}"
|
||||
echo -e " • $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
|
||||
echo -e " • $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To use these images in docker-compose:${NC}"
|
||||
echo -e "${BLUE} services:"
|
||||
echo -e " backend:"
|
||||
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
|
||||
echo -e " frontend:"
|
||||
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION${NC}"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user