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 8c402fb..0000000 Binary files a/backend/prisma/dev.db.backup and /dev/null differ diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index d745181..78817f7 100644 Binary files a/backend/prisma/prisma/dev.db and b/backend/prisma/prisma/dev.db differ diff --git a/backend/prisma/prisma/dev.db-journal b/backend/prisma/prisma/dev.db-journal new file mode 100644 index 0000000..1b10b30 Binary files /dev/null and b/backend/prisma/prisma/dev.db-journal differ 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