Compare commits

..

3 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
6 changed files with 64 additions and 137 deletions
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+1 -1
View File
@@ -1 +1 @@
0.3.0 0.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.3.0", "version": "0.3.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+48 -27
View File
@@ -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 {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.3.0", "version": "0.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",