From 997fa4af03f9a5b0a434c663994305c2ea5bf31f Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sun, 23 Nov 2025 06:56:45 -0800 Subject: [PATCH] add prisma cli to dependencies, make zod checks more permissive --- backend/.env.example | 1 + backend/Dockerfile | 15 +- backend/docker-entrypoint.sh | 38 ++--- backend/package-lock.json | 9 +- backend/package.json | 2 +- backend/prisma/dev.db.backup | Bin 45056 -> 0 bytes backend/prisma/prisma/dev.db | Bin 20480 -> 36864 bytes backend/prisma/prisma/dev.db-journal | Bin 0 -> 12824 bytes backend/src/generated/client/edge.js | 1 - backend/src/generated/client/index.js | 1 - backend/src/index.ts | 45 +++++- backend/src/security.ts | 213 +++++++++++++++----------- frontend/.env | 4 + frontend/src/pages/Editor.tsx | 9 +- test_async_fix.js | 165 -------------------- validate_fix.js | 87 ----------- 16 files changed, 195 insertions(+), 395 deletions(-) delete mode 100644 backend/prisma/dev.db.backup create mode 100644 backend/prisma/prisma/dev.db-journal create mode 100644 frontend/.env delete mode 100644 test_async_fix.js delete mode 100644 validate_fix.js diff --git a/backend/.env.example b/backend/.env.example index da1157d..da110e9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,4 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db +FRONTEND_URL=http://localhost:6767 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index e5b7bca..53589ec 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,8 +25,8 @@ RUN npx tsc # Production stage FROM node:20-alpine -# Install OpenSSL for Prisma and create non-root user -RUN apk add --no-cache openssl && \ +# Install OpenSSL for Prisma and su-exec, create non-root user +RUN apk add --no-cache openssl su-exec && \ addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 @@ -51,17 +51,14 @@ COPY --from=builder /app/src/generated ./dist/generated # Generate Prisma Client in production (updates node_modules) RUN npx prisma generate -# Create necessary directories and set proper ownership -RUN mkdir -p /app/uploads /app/prisma && \ - chown -R nodejs:nodejs /app +# Create necessary directories (ownership will be set in entrypoint) +RUN mkdir -p /app/uploads /app/prisma # Copy and set permissions for entrypoint script COPY docker-entrypoint.sh ./ -RUN chmod +x docker-entrypoint.sh && \ - chown nodejs:nodejs docker-entrypoint.sh +RUN chmod +x docker-entrypoint.sh -# Switch to non-root user -USER nodejs +# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint) EXPOSE 8000 diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 16bfc9d..d1a311a 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -1,36 +1,28 @@ #!/bin/sh set -e -# Auto-hydrate prisma directory when bind-mounted volume is empty +# 1. Hydrate volume if empty (Running as root) if [ ! -f "/app/prisma/schema.prisma" ]; then - echo "Mount is empty. Hydrating /app/prisma from /app/prisma_template..." - cp -R /app/prisma_template/. /app/prisma/ + echo "Mount is empty. Hydrating /app/prisma..." + cp -R /app/prisma_template/. /app/prisma/ fi -# Ensure proper ownership and permissions for data directories -echo "Setting up data directory permissions..." -mkdir -p /app/uploads -mkdir -p /app/prisma - -# Set ownership to the node user (UID 1000) -if [ "$(id -u)" = "0" ]; then - # If running as root (for some reason), fix ownership - chown -R nodejs:nodejs /app/uploads - chown -R nodejs:nodejs /app/prisma -fi +# 2. Fix permissions unconditionally (Running as root) +echo "Fixing filesystem permissions..." +chown -R nodejs:nodejs /app/uploads +chown -R nodejs:nodejs /app/prisma +chmod 755 /app/uploads # Ensure database file has proper permissions if [ -f "/app/prisma/dev.db" ]; then - chmod 664 /app/prisma/dev.db 2>/dev/null || true + echo "Database file found, ensuring write permissions..." + chmod 666 /app/prisma/dev.db fi -# Set appropriate permissions for uploads directory -chmod 755 /app/uploads - -# Run migrations as the current user +# 3. Run Migrations (Drop privileges to nodejs) echo "Running database migrations..." -npx prisma migrate deploy +su-exec nodejs npx prisma migrate deploy -# Start the application -echo "Starting application as user $(whoami) (UID: $(id -u))" -node dist/index.js +# 4. Start Application (Drop privileges to nodejs) +echo "Starting application as nodejs..." +exec su-exec nodejs node dist/index.js diff --git a/backend/package-lock.json b/backend/package-lock.json index cccae61..e2c7b77 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "express": "^5.1.0", "jsdom": "^27.2.0", "multer": "^2.0.2", + "prisma": "^5.22.0", "socket.io": "^4.8.1", "zod": "^4.1.12" }, @@ -30,7 +31,6 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "nodemon": "^3.1.11", - "prisma": "^5.22.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" } @@ -312,14 +312,12 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -333,14 +331,12 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -352,7 +348,6 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -1747,7 +1742,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2677,7 +2671,6 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, diff --git a/backend/package.json b/backend/package.json index 460f654..2594ba9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "express": "^5.1.0", "jsdom": "^27.2.0", "multer": "^2.0.2", + "prisma": "^5.22.0", "socket.io": "^4.8.1", "zod": "^4.1.12" }, @@ -33,7 +34,6 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "nodemon": "^3.1.11", - "prisma": "^5.22.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" } diff --git a/backend/prisma/dev.db.backup b/backend/prisma/dev.db.backup deleted file mode 100644 index 8c402fb5584d72d132714d8019a89496fcc9a6fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45056 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 diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index d745181b96ef2fbe40aae399b8e01ffcccf1c1ad..78817f79d6a0c86760b99c380558f330d3757ce9 100644 GIT binary patch literal 36864 zcmeI)-)`Gf90zcx*_t)bg^NiQ>;f;7*ecD)KK^?ykO-+aRqN7`KVpAC*75OiXDvxX z;%*?sMYU<#GjNA^0EBo1-T}dzaLKXjByF=!yBHE$zLqSz_Rr_f=bZkW+HE^u?j)&D z$QKB)oNN^|Fib{ z`+uzeT>Yh*R{PcdYp%m72tWV=5P$##{@()6KdDrn*DIBct5-`e-%DB87enzh*c!8^ z$#C!3?uBOCZ*+aN+xTL~SL;PUUAfQGCokshmZuvJGy`8Rl?S~()6?Kfp;z=T& z9#v5sdm_e@WPey!w_9ERmfxPYep-N0MDqQ9U+`20Zx=nCo)`<+w5XBR)h+4M-M-^5 zmeTCC+kUGXWM@0w#+|#x#RsG4%!`jgn!Bw|x80EKo0$j4+Y-c&#rJjf=5E{HzSYXG z)$8@+0rgF_?cele^-aH{)|<0_$f3(dZByNCNv<7Vj%~*;#!epU-OakAHXEI0W6Q5@ z+80(gwy%~HO>P^POG+|~#CMZ#`|<;5zz)*={Ojqw35;TXtUdoo&aKOrr*o?~Tx>9> zmU~VO_bP`E%ax5QS4z)HdG47{&-vcDlbL6J;w>c~{$G6uS2xXbs~fkjob}Bz?@3_H z_s>6jP^xSgM(IV6lLVu2G8wR7knD|FmVhRUM^;V}Ee4#R$`baeB~RVQGWINmO|#r1 z!5>c!2KAHIoyhn_Xq*g_$s-X3Os2&7GEI&})T}qb(Osbkd{u${{XLoA7nUrvC~TZ1 zwI~P~e|)^wQ&mn6r{;O$cqP~qB?m$>L;?GNh(GY znT-#Ic~7VJkY1}JQ?b9bSfF0rY_6OWzQk*oHxf?%0-X2P}$${0VOMRP$gk%!9r@ytI$ zT(hWP8uc8id5+2INo2u!+lr^g`S}Nj>!rL|X4b68327*6)QC+@&C;2xQ9HIZ z%QWJwkya!O#%(TfC=+hXTs_vEkh{V(9KvMeVP}A;u91KmY;|fB*y_009U<00Izz00iEa z!1Z#k^d`?w%30u>Jj__RR_?vg*2z{r{2;IJye~2tWV=5P$##AOHaf lKmY)ery&3V2tWV=5P$##AOHafKw!xP{slum)5`z= delta 136 zcmZozz|^pSae}lU69WSSD-go~(?lI(Q6>hxvJPJU9}Fx!Ga2}v@*n1z$+vB@pnxim zT#FPlySTJ8V+VIhVp2}3OHpEZW?nj!V%dC<>pvrtF3aRHE*%!u#%RXL$9NPNnI=Ey Y*$pHY^BQq711;hJS+>Z)V3C6W06C~2mH+?% diff --git a/backend/prisma/prisma/dev.db-journal b/backend/prisma/prisma/dev.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..1b10b30333405bc314ada468b61c8540de76a966 GIT binary patch literal 12824 zcmeI&OKKc35C-73jT2%RClK#*0)bR|)-q)2bs$UvgH%#MgX5Sy*R#zLa*G@%o1Den z6Yy>>;QZPMTBz&SS399;!uZbxPB#m{+2xz*`Q7E8Wx{{}1Rwwb2tWV=5P$##AOHaf z923~TJiBV4At|ZYWVI)8 zO{Ij^L$^VhDU^^~ZIy?wzOWy}gDgeoh-``>#$D}wr}NGDvXf6Y-#*@+{QCXs>cjr( zbWL^sdQC-Lmc20>Ym=D3qZ%%WO-ZhJTQZA}v9wURcvotu!&XPl7Mn-Z#MQIq=tW)Z zrCJI#jN*lSaw$6)ZY2wS>` literal 0 HcmV?d00001 diff --git a/backend/src/generated/client/edge.js b/backend/src/generated/client/edge.js index 93c2a87..5dee164 100644 --- a/backend/src/generated/client/edge.js +++ b/backend/src/generated/client/edge.js @@ -169,7 +169,6 @@ const config = { "db" ], "activeProvider": "sqlite", - "postinstall": false, "inlineDatasources": { "db": { "url": { diff --git a/backend/src/generated/client/index.js b/backend/src/generated/client/index.js index 08e0ca4..2948f4c 100644 --- a/backend/src/generated/client/index.js +++ b/backend/src/generated/client/index.js @@ -170,7 +170,6 @@ const config = { "db" ], "activeProvider": "sqlite", - "postinstall": false, "inlineDatasources": { "db": { "url": { diff --git a/backend/src/index.ts b/backend/src/index.ts index 5f3e86b..169ebe4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -234,9 +234,12 @@ const drawingUpdateSchema = drawingBaseSchema const sanitizedData = { ...data }; if (data.elements !== undefined || data.appState !== undefined) { const fullData = { - elements: data.elements || [], - appState: data.appState || {}, - files: data.files, + elements: Array.isArray(data.elements) ? data.elements : [], + appState: + typeof data.appState === "object" && data.appState !== null + ? data.appState + : {}, + files: data.files || {}, preview: data.preview, name: data.name, collectionId: data.collectionId, @@ -252,6 +255,17 @@ const drawingUpdateSchema = drawingBaseSchema return true; } catch (error) { console.error("Sanitization failed:", error); + // For updates, if sanitization fails but we have minimal data, allow it to pass + // This prevents legitimate empty drawings from failing + if ( + data.elements === undefined && + data.appState === undefined && + (data.name !== undefined || + data.preview !== undefined || + data.collectionId !== undefined) + ) { + return true; + } return false; } }, @@ -566,8 +580,32 @@ app.post("/drawings", async (req, res) => { app.put("/drawings/:id", async (req, res) => { try { const { id } = req.params; + + console.log("[API] Update request received", { + id, + bodyKeys: Object.keys(req.body || {}), + hasElements: req.body?.elements !== undefined, + elementCount: Array.isArray(req.body?.elements) + ? req.body.elements.length + : undefined, + hasAppState: req.body?.appState !== undefined, + appStateKeys: req.body?.appState ? Object.keys(req.body.appState) : [], + hasFiles: req.body?.files !== undefined, + hasPreview: req.body?.preview !== undefined, + }); + const parsed = drawingUpdateSchema.safeParse(req.body); if (!parsed.success) { + console.error("[API] Validation failed", { + id, + errorCount: parsed.error.issues.length, + errors: parsed.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + received: + issue.path.length > 0 ? req.body?.[issue.path.join(".")] : "root", + })), + }); return respondWithValidationErrors(res, parsed.error.issues); } @@ -622,6 +660,7 @@ app.put("/drawings/:id", async (req, res) => { files: JSON.parse(updatedDrawing.files || "{}"), }); } catch (error) { + console.error("[CRITICAL] Update failed:", error); res.status(500).json({ error: "Failed to update drawing" }); } }); diff --git a/backend/src/security.ts b/backend/src/security.ts index 235df02..e0c230a 100644 --- a/backend/src/security.ts +++ b/backend/src/security.ts @@ -257,133 +257,160 @@ export const sanitizeUrl = (url: unknown): string => { }; /** - * Strict Zod schema for Excalidraw elements with validation + * Very flexible Zod schema for Excalidraw elements */ export const elementSchema = z .object({ - id: z.string().min(1).max(100), - type: z.enum([ - "rectangle", - "ellipse", - "diamond", - "arrow", - "line", - "text", - "image", - "frame", - "embed", - "selection", - "text-container", - ]), - x: z.number().finite().min(-100000).max(100000), - y: z.number().finite().min(-100000).max(100000), - width: z.number().finite().min(0).max(100000), - height: z.number().finite().min(0).max(100000), - angle: z - .number() - .finite() - .min(-2 * Math.PI) - .max(2 * Math.PI), - strokeColor: z.string().optional(), - backgroundColor: z.string().optional(), - fillStyle: z.enum(["solid", "hachure", "cross-hatch", "dots"]).optional(), - strokeWidth: z.number().finite().min(0).max(10).optional(), - strokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(), - roundness: z - .object({ - type: z.enum(["round", "sharp"]), - value: z.number().finite().min(0).max(1), - }) - .optional(), - boundElements: z - .array( - z.object({ - id: z.string(), - type: z.string(), - }) - ) - .optional(), - groupIds: z.array(z.string()).optional(), - frameId: z.string().optional(), - seed: z.number().finite().optional(), - version: z.number().finite().min(0).max(100000), - versionNonce: z.number().finite().min(0).max(100000), - isDeleted: z.boolean().optional(), - opacity: z.number().finite().min(0).max(1).optional(), - link: z.string().optional().transform(sanitizeUrl), - locked: z.boolean().optional(), - // Text-specific properties - text: z - .string() - .optional() - .transform((val) => sanitizeText(val, 5000)), - fontSize: z.number().finite().min(1).max(200).optional(), - fontFamily: z.number().finite().min(1).max(5).optional(), - textAlign: z.enum(["left", "center", "right"]).optional(), - verticalAlign: z.enum(["top", "middle", "bottom"]).optional(), - // Custom properties - whitelist only known safe properties - customData: z.record(z.string(), z.any()).optional(), + id: z.string().min(1).max(200).optional().nullable(), + type: z.string().optional().nullable(), + x: z.number().optional().nullable(), + y: z.number().optional().nullable(), + width: z.number().optional().nullable(), + height: z.number().optional().nullable(), + angle: z.number().optional().nullable(), + strokeColor: z.string().optional().nullable(), + backgroundColor: z.string().optional().nullable(), + fillStyle: z.string().optional().nullable(), + strokeWidth: z.number().optional().nullable(), + strokeStyle: z.string().optional().nullable(), + roundness: z.any().optional().nullable(), + boundElements: z.array(z.any()).optional().nullable(), + groupIds: z.array(z.string()).optional().nullable(), + frameId: z.string().optional().nullable(), + seed: z.number().optional().nullable(), + version: z.number().optional().nullable(), + versionNonce: z.number().optional().nullable(), + isDeleted: z.boolean().optional().nullable(), + opacity: z.number().optional().nullable(), + link: z.string().optional().nullable(), + locked: z.boolean().optional().nullable(), + text: z.string().optional().nullable(), + fontSize: z.number().optional().nullable(), + fontFamily: z.number().optional().nullable(), + textAlign: z.string().optional().nullable(), + verticalAlign: z.string().optional().nullable(), + customData: z.record(z.string(), z.any()).optional().nullable(), }) - .strict(); + .passthrough() + .transform((element) => { + // Apply basic sanitization to string values only + const sanitized = { ...element }; + + if (typeof sanitized.text === "string") { + sanitized.text = sanitizeText(sanitized.text, 5000); + } + + if (typeof sanitized.link === "string") { + sanitized.link = sanitizeUrl(sanitized.link); + } + + return sanitized; + }); /** - * Strict Zod schema for Excalidraw app state with validation + * Flexible Zod schema for Excalidraw app state with validation */ export const appStateSchema = z .object({ - gridSize: z.number().finite().min(0).max(100).optional(), - gridStep: z.number().finite().min(1).max(100).optional(), - viewBackgroundColor: z.string().optional(), - currentItemStrokeColor: z.string().optional(), - currentItemBackgroundColor: z.string().optional(), + gridSize: z.number().finite().min(0).max(1000).optional().nullable(), + gridStep: z.number().finite().min(1).max(1000).optional().nullable(), + viewBackgroundColor: z.string().optional().nullable(), + currentItemStrokeColor: z.string().optional().nullable(), + currentItemBackgroundColor: z.string().optional().nullable(), currentItemFillStyle: z .enum(["solid", "hachure", "cross-hatch", "dots"]) - .optional(), - currentItemStrokeWidth: z.number().finite().min(0).max(10).optional(), - currentItemStrokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + .optional() + .nullable(), + currentItemStrokeWidth: z + .number() + .finite() + .min(0) + .max(50) + .optional() + .nullable(), + currentItemStrokeStyle: z + .enum(["solid", "dashed", "dotted"]) + .optional() + .nullable(), currentItemRoundness: z .object({ type: z.enum(["round", "sharp"]), value: z.number().finite().min(0).max(1), }) - .optional(), - currentItemFontSize: z.number().finite().min(1).max(200).optional(), - currentItemFontFamily: z.number().finite().min(1).max(5).optional(), - currentItemTextAlign: z.enum(["left", "center", "right"]).optional(), - currentItemVerticalAlign: z.enum(["top", "middle", "bottom"]).optional(), - scrollX: z.number().finite().min(-1000000).max(1000000).optional(), - scrollY: z.number().finite().min(-1000000).max(1000000).optional(), + .optional() + .nullable(), + currentItemFontSize: z + .number() + .finite() + .min(1) + .max(500) + .optional() + .nullable(), + currentItemFontFamily: z + .number() + .finite() + .min(1) + .max(10) + .optional() + .nullable(), + currentItemTextAlign: z + .enum(["left", "center", "right"]) + .optional() + .nullable(), + currentItemVerticalAlign: z + .enum(["top", "middle", "bottom"]) + .optional() + .nullable(), + scrollX: z + .number() + .finite() + .min(-10000000) + .max(10000000) + .optional() + .nullable(), + scrollY: z + .number() + .finite() + .min(-10000000) + .max(10000000) + .optional() + .nullable(), zoom: z .object({ - value: z.number().finite().min(0.1).max(10), + value: z.number().finite().min(0.01).max(100), }) - .optional(), - selection: z.array(z.string()).optional(), - selectedElementIds: z.record(z.string(), z.boolean()).optional(), - selectedGroupIds: z.record(z.string(), z.boolean()).optional(), + .optional() + .nullable(), + selection: z.array(z.string()).optional().nullable(), + selectedElementIds: z.record(z.string(), z.boolean()).optional().nullable(), + selectedGroupIds: z.record(z.string(), z.boolean()).optional().nullable(), activeEmbeddable: z .object({ elementId: z.string(), state: z.string(), }) - .optional(), + .optional() + .nullable(), activeTool: z .object({ type: z.string(), - customType: z.string().optional(), + customType: z.string().optional().nullable(), }) - .optional(), - cursorX: z.number().finite().optional(), - cursorY: z.number().finite().optional(), - // Sanitize any string values in appState + .optional() + .nullable(), + cursorX: z.number().finite().optional().nullable(), + cursorY: z.number().finite().optional().nullable(), + // Add common Excalidraw app state properties + collaborators: z.record(z.string(), z.any()).optional().nullable(), }) - .strict() + // Allow any additional properties .catchall( z.any().refine((val) => { - // Recursively sanitize any string values found in the object + // Sanitize string values, but be more permissive for other types if (typeof val === "string") { return sanitizeText(val, 1000); } + // Allow numbers, booleans, objects, arrays, null, undefined return true; }) ); diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..390658c --- /dev/null +++ b/frontend/.env @@ -0,0 +1,4 @@ +# Frontend Environment Variables +# Use /api for production (proxied by nginx) +# Use http://localhost:8000 for local development +VITE_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 193d817..01f874d 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -265,13 +265,14 @@ export const Editor: React.FC = () => { if (!id) return; try { + // Ensure we always have valid data structure const persistableAppState = { - viewBackgroundColor: appState.viewBackgroundColor, - gridSize: appState.gridSize, + viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff', + gridSize: appState?.gridSize || null, }; - const snapshot = latestElementsRef.current ?? elements; - const persistableElements = Array.from(snapshot); + const snapshot = latestElementsRef.current ?? elements ?? []; + const persistableElements = Array.isArray(snapshot) ? snapshot : []; console.log("[Editor] Saving drawing", { drawingId: id, diff --git a/test_async_fix.js b/test_async_fix.js deleted file mode 100644 index e03a90b..0000000 --- a/test_async_fix.js +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env node - -/** - * Test script to verify async file operations are non-blocking - * This simulates the database import scenario with a large file - */ - -const { spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// Configuration -const BACKEND_PORT = 8001; // Use different port to avoid conflicts -const TEST_FILE_SIZE = 50 * 1024 * 1024; // 50MB -const TEST_DB_PATH = path.join(__dirname, 'test_large_db.db'); - -// Create a test database file -function createTestDatabase(size) { - console.log(`Creating test database file (${size / (1024 * 1024)}MB)...`); - const buffer = Buffer.alloc(size); - // Add SQLite header to make it a valid-ish file - buffer.write('SQLite format 3\0', 0); - - fs.writeFileSync(TEST_DB_PATH, buffer); - console.log('Test database created successfully'); -} - -// Cleanup function -function cleanup() { - if (fs.existsSync(TEST_DB_PATH)) { - fs.unlinkSync(TEST_DB_PATH); - console.log('Test database cleaned up'); - } -} - -// Test async operations don't block -async function testNonBlockingBehavior() { - console.log('\n=== Testing Non-Blocking File Operations ===\n'); - - // Create test database - createTestDatabase(TEST_FILE_SIZE); - - return new Promise((resolve) => { - console.log('Starting backend server...'); - - // Start backend server - const backend = spawn('node', ['src/index.ts'], { - cwd: path.join(__dirname, 'backend'), - env: { ...process.env, PORT: BACKEND_PORT.toString() }, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let serverReady = false; - let healthCheckPassed = false; - - backend.stdout.on('data', (data) => { - const output = data.toString(); - console.log(`[Backend] ${output.trim()}`); - - if (output.includes('Server running on port')) { - serverReady = true; - } - }); - - backend.stderr.on('data', (data) => { - console.error(`[Backend Error] ${data.toString().trim()}`); - }); - - // Wait for server to be ready, then test health endpoints - setTimeout(() => { - if (!serverReady) { - console.error('Server failed to start'); - backend.kill(); - cleanup(); - resolve(false); - return; - } - - console.log('\n--- Testing Health Endpoint (should work during file ops) ---'); - - // Test health endpoint multiple times to ensure it's responsive - const healthTests = []; - for (let i = 0; i < 3; i++) { - setTimeout(() => { - const healthReq = spawn('curl', ['-s', `http://localhost:${BACKEND_PORT}/health`]); - - healthReq.stdout.on('data', (data) => { - const response = data.toString(); - console.log(`Health check ${i + 1}: ${response}`); - healthCheckPassed = healthCheckPassed || response.includes('ok'); - }); - - healthReq.stderr.on('data', (data) => { - console.error(`Health check ${i + 1} error: ${data.toString()}`); - }); - }, i * 1000); - } - - // Test file upload (simulating the blocking operation) - setTimeout(() => { - console.log('\n--- Testing File Upload (simulating async operations) ---'); - - const formData = `--boundary\r\nContent-Disposition: form-data; name="db"; filename="test.db"\r\nContent-Type: application/octet-stream\r\n\r\n`; - const endBoundary = `\r\n--boundary--\r\n`; - - const fileContent = fs.readFileSync(TEST_DB_PATH); - const uploadData = Buffer.concat([ - Buffer.from(formData), - fileContent, - Buffer.from(endBoundary) - ]); - - const uploadReq = spawn('curl', [ - '-X', 'POST', - '-H', `Content-Type: multipart/form-data; boundary=boundary`, - '--data-binary', `@-`, - `http://localhost:${BACKEND_PORT}/import/sqlite/verify` - ], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - uploadReq.stdin.write(uploadData); - uploadReq.stdin.end(); - - let uploadResponse = ''; - uploadReq.stdout.on('data', (data) => { - uploadResponse += data.toString(); - }); - - uploadReq.on('close', (code) => { - console.log(`Upload test completed with code: ${code}`); - console.log(`Response: ${uploadResponse}`); - - // Final health check to ensure server is still responsive - setTimeout(() => { - const finalHealthReq = spawn('curl', ['-s', `http://localhost:${BACKEND_PORT}/health`]); - finalHealthReq.stdout.on('data', (data) => { - const response = data.toString(); - console.log(`Final health check: ${response}`); - - backend.kill(); - cleanup(); - - const success = healthCheckPassed && response.includes('ok'); - console.log(`\n=== Test Result: ${success ? 'PASS' : 'FAIL'} ===`); - console.log(`Health checks responsive: ${healthCheckPassed}`); - console.log(`Server still responsive after upload: ${response.includes('ok')}`); - - resolve(success); - }); - }, 2000); - }); - }, 5000); // Start upload test after 5 seconds - }, 3000); // Wait 3 seconds for server startup - }); -} - -// Run the test -testNonBlockingBehavior().then((success) => { - process.exit(success ? 0 : 1); -}).catch((error) => { - console.error('Test failed with error:', error); - cleanup(); - process.exit(1); -}); \ No newline at end of file diff --git a/validate_fix.js b/validate_fix.js deleted file mode 100644 index cc7d5d4..0000000 --- a/validate_fix.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Quick validation of async file operations fix - * This checks that all synchronous operations have been converted - */ - -const fs = require('fs'); -const path = require('path'); - -const backendFile = path.join(__dirname, 'backend', 'src', 'index.ts'); - -// Read the backend file -const content = fs.readFileSync(backendFile, 'utf8'); - -// Check for any remaining synchronous file operations -const syncPatterns = [ - { pattern: /fs\.(read|write|open|rename|copy|unlink|mkdir)Sync/g, name: 'Synchronous file operations' }, - { pattern: /existsSync/g, name: 'existsSync calls' } -]; - -console.log('=== Async File Operations Fix Validation ===\n'); - -let issues = []; -let conversions = []; - -syncPatterns.forEach(({ pattern, name }) => { - const matches = content.match(pattern); - if (matches) { - console.log(`❌ Found ${matches.length} ${name}:`); - matches.forEach((match, index) => { - console.log(` ${index + 1}. ${match}`); - }); - issues.push({ type: name, count: matches.length, matches }); - } else { - console.log(`✅ No ${name} found`); - } -}); - -// Check for async operations that were added -const asyncPatterns = [ - { pattern: /fsPromises\.(rename|copyFile|access|unlink|mkdir)/g, name: 'Async file operations' }, - { pattern: /await removeFileIfExists/g, name: 'Async file cleanup calls' } -]; - -asyncPatterns.forEach(({ pattern, name }) => { - const matches = content.match(pattern); - if (matches) { - console.log(`✅ Found ${matches.length} ${name}`); - conversions.push({ type: name, count: matches.length }); - } -}); - -// Check for proper error handling -const errorHandlingMatches = content.match(/try\s*{[\s\S]*?catch\s*\(/g); -if (errorHandlingMatches) { - console.log(`✅ Found ${errorHandlingMatches.length} try-catch blocks for error handling`); -} - -// Summary -console.log('\n=== Summary ==='); -if (issues.length === 0) { - console.log('✅ All synchronous file operations have been successfully converted to async!'); - console.log('✅ The Node.js event loop will no longer be blocked during file operations'); - console.log('✅ Large database uploads (50MB+) will not freeze the application'); - console.log('✅ Health checks and WebSocket connections will remain responsive'); -} else { - console.log('⚠️ Some synchronous operations still exist:'); - issues.forEach(issue => { - console.log(` - ${issue.type}: ${issue.count} instances`); - }); -} - -console.log('\n=== Performance Impact ==='); -console.log('Before: fs.renameSync() blocked event loop for entire file operation'); -console.log('After: await fsPromises.rename() allows event loop to process other requests'); -console.log('Before: fs.copyFileSync() blocked during database backup'); -console.log('After: await fsPromises.copyFile() enables concurrent request processing'); -console.log('Before: fs.unlinkSync() blocked during cleanup'); -console.log('After: await fsPromises.unlink() allows responsive error handling'); - -// Export result for programmatic use -module.exports = { - success: issues.length === 0, - issues, - conversions, - totalSyncOperationsRemoved: issues.reduce((sum, issue) => sum + issue.count, 0), - totalAsyncOperationsAdded: conversions.reduce((sum, conv) => sum + conv.count, 0) -}; \ No newline at end of file