add prisma cli to dependencies, make zod checks more permissive
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
|
FRONTEND_URL=http://localhost:6767
|
||||||
+6
-9
@@ -25,8 +25,8 @@ RUN npx tsc
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenSSL for Prisma and create non-root user
|
# Install OpenSSL for Prisma and su-exec, create non-root user
|
||||||
RUN apk add --no-cache openssl && \
|
RUN apk add --no-cache openssl su-exec && \
|
||||||
addgroup -g 1001 -S nodejs && \
|
addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
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)
|
# Generate Prisma Client in production (updates node_modules)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Create necessary directories and set proper ownership
|
# Create necessary directories (ownership will be set in entrypoint)
|
||||||
RUN mkdir -p /app/uploads /app/prisma && \
|
RUN mkdir -p /app/uploads /app/prisma
|
||||||
chown -R nodejs:nodejs /app
|
|
||||||
|
|
||||||
# Copy and set permissions for entrypoint script
|
# Copy and set permissions for entrypoint script
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x docker-entrypoint.sh && \
|
RUN chmod +x docker-entrypoint.sh
|
||||||
chown nodejs:nodejs docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
|
||||||
USER nodejs
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
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
|
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||||
echo "Mount is empty. Hydrating /app/prisma from /app/prisma_template..."
|
echo "Mount is empty. Hydrating /app/prisma..."
|
||||||
cp -R /app/prisma_template/. /app/prisma/
|
cp -R /app/prisma_template/. /app/prisma/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure proper ownership and permissions for data directories
|
# 2. Fix permissions unconditionally (Running as root)
|
||||||
echo "Setting up data directory permissions..."
|
echo "Fixing filesystem permissions..."
|
||||||
mkdir -p /app/uploads
|
chown -R nodejs:nodejs /app/uploads
|
||||||
mkdir -p /app/prisma
|
chown -R nodejs:nodejs /app/prisma
|
||||||
|
chmod 755 /app/uploads
|
||||||
# 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
|
|
||||||
|
|
||||||
# Ensure database file has proper permissions
|
# Ensure database file has proper permissions
|
||||||
if [ -f "/app/prisma/dev.db" ]; then
|
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
|
fi
|
||||||
|
|
||||||
# Set appropriate permissions for uploads directory
|
# 3. Run Migrations (Drop privileges to nodejs)
|
||||||
chmod 755 /app/uploads
|
|
||||||
|
|
||||||
# Run migrations as the current user
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npx prisma migrate deploy
|
su-exec nodejs npx prisma migrate deploy
|
||||||
|
|
||||||
# Start the application
|
# 4. Start Application (Drop privileges to nodejs)
|
||||||
echo "Starting application as user $(whoami) (UID: $(id -u))"
|
echo "Starting application as nodejs..."
|
||||||
node dist/index.js
|
exec su-exec nodejs node dist/index.js
|
||||||
|
|||||||
Generated
+1
-8
@@ -22,6 +22,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
@@ -30,7 +31,6 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
@@ -312,14 +312,12 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -333,14 +331,12 @@
|
|||||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0",
|
"@prisma/debug": "5.22.0",
|
||||||
@@ -352,7 +348,6 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0"
|
"@prisma/debug": "5.22.0"
|
||||||
@@ -1747,7 +1742,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2677,7 +2671,6 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
@@ -33,7 +34,6 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -169,7 +169,6 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "sqlite",
|
"activeProvider": "sqlite",
|
||||||
"postinstall": false,
|
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"url": {
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ const config = {
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"activeProvider": "sqlite",
|
"activeProvider": "sqlite",
|
||||||
"postinstall": false,
|
|
||||||
"inlineDatasources": {
|
"inlineDatasources": {
|
||||||
"db": {
|
"db": {
|
||||||
"url": {
|
"url": {
|
||||||
|
|||||||
+42
-3
@@ -234,9 +234,12 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
const sanitizedData = { ...data };
|
const sanitizedData = { ...data };
|
||||||
if (data.elements !== undefined || data.appState !== undefined) {
|
if (data.elements !== undefined || data.appState !== undefined) {
|
||||||
const fullData = {
|
const fullData = {
|
||||||
elements: data.elements || [],
|
elements: Array.isArray(data.elements) ? data.elements : [],
|
||||||
appState: data.appState || {},
|
appState:
|
||||||
files: data.files,
|
typeof data.appState === "object" && data.appState !== null
|
||||||
|
? data.appState
|
||||||
|
: {},
|
||||||
|
files: data.files || {},
|
||||||
preview: data.preview,
|
preview: data.preview,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
collectionId: data.collectionId,
|
collectionId: data.collectionId,
|
||||||
@@ -252,6 +255,17 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Sanitization failed:", 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;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -566,8 +580,32 @@ app.post("/drawings", async (req, res) => {
|
|||||||
app.put("/drawings/:id", async (req, res) => {
|
app.put("/drawings/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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);
|
const parsed = drawingUpdateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
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);
|
return respondWithValidationErrors(res, parsed.error.issues);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +660,7 @@ app.put("/drawings/:id", async (req, res) => {
|
|||||||
files: JSON.parse(updatedDrawing.files || "{}"),
|
files: JSON.parse(updatedDrawing.files || "{}"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[CRITICAL] Update failed:", error);
|
||||||
res.status(500).json({ error: "Failed to update drawing" });
|
res.status(500).json({ error: "Failed to update drawing" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+120
-93
@@ -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
|
export const elementSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().min(1).max(100),
|
id: z.string().min(1).max(200).optional().nullable(),
|
||||||
type: z.enum([
|
type: z.string().optional().nullable(),
|
||||||
"rectangle",
|
x: z.number().optional().nullable(),
|
||||||
"ellipse",
|
y: z.number().optional().nullable(),
|
||||||
"diamond",
|
width: z.number().optional().nullable(),
|
||||||
"arrow",
|
height: z.number().optional().nullable(),
|
||||||
"line",
|
angle: z.number().optional().nullable(),
|
||||||
"text",
|
strokeColor: z.string().optional().nullable(),
|
||||||
"image",
|
backgroundColor: z.string().optional().nullable(),
|
||||||
"frame",
|
fillStyle: z.string().optional().nullable(),
|
||||||
"embed",
|
strokeWidth: z.number().optional().nullable(),
|
||||||
"selection",
|
strokeStyle: z.string().optional().nullable(),
|
||||||
"text-container",
|
roundness: z.any().optional().nullable(),
|
||||||
]),
|
boundElements: z.array(z.any()).optional().nullable(),
|
||||||
x: z.number().finite().min(-100000).max(100000),
|
groupIds: z.array(z.string()).optional().nullable(),
|
||||||
y: z.number().finite().min(-100000).max(100000),
|
frameId: z.string().optional().nullable(),
|
||||||
width: z.number().finite().min(0).max(100000),
|
seed: z.number().optional().nullable(),
|
||||||
height: z.number().finite().min(0).max(100000),
|
version: z.number().optional().nullable(),
|
||||||
angle: z
|
versionNonce: z.number().optional().nullable(),
|
||||||
.number()
|
isDeleted: z.boolean().optional().nullable(),
|
||||||
.finite()
|
opacity: z.number().optional().nullable(),
|
||||||
.min(-2 * Math.PI)
|
link: z.string().optional().nullable(),
|
||||||
.max(2 * Math.PI),
|
locked: z.boolean().optional().nullable(),
|
||||||
strokeColor: z.string().optional(),
|
text: z.string().optional().nullable(),
|
||||||
backgroundColor: z.string().optional(),
|
fontSize: z.number().optional().nullable(),
|
||||||
fillStyle: z.enum(["solid", "hachure", "cross-hatch", "dots"]).optional(),
|
fontFamily: z.number().optional().nullable(),
|
||||||
strokeWidth: z.number().finite().min(0).max(10).optional(),
|
textAlign: z.string().optional().nullable(),
|
||||||
strokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
|
verticalAlign: z.string().optional().nullable(),
|
||||||
roundness: z
|
customData: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
.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(),
|
|
||||||
})
|
})
|
||||||
.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
|
export const appStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
gridSize: z.number().finite().min(0).max(100).optional(),
|
gridSize: z.number().finite().min(0).max(1000).optional().nullable(),
|
||||||
gridStep: z.number().finite().min(1).max(100).optional(),
|
gridStep: z.number().finite().min(1).max(1000).optional().nullable(),
|
||||||
viewBackgroundColor: z.string().optional(),
|
viewBackgroundColor: z.string().optional().nullable(),
|
||||||
currentItemStrokeColor: z.string().optional(),
|
currentItemStrokeColor: z.string().optional().nullable(),
|
||||||
currentItemBackgroundColor: z.string().optional(),
|
currentItemBackgroundColor: z.string().optional().nullable(),
|
||||||
currentItemFillStyle: z
|
currentItemFillStyle: z
|
||||||
.enum(["solid", "hachure", "cross-hatch", "dots"])
|
.enum(["solid", "hachure", "cross-hatch", "dots"])
|
||||||
.optional(),
|
.optional()
|
||||||
currentItemStrokeWidth: z.number().finite().min(0).max(10).optional(),
|
.nullable(),
|
||||||
currentItemStrokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
|
currentItemStrokeWidth: z
|
||||||
|
.number()
|
||||||
|
.finite()
|
||||||
|
.min(0)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
currentItemStrokeStyle: z
|
||||||
|
.enum(["solid", "dashed", "dotted"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
currentItemRoundness: z
|
currentItemRoundness: z
|
||||||
.object({
|
.object({
|
||||||
type: z.enum(["round", "sharp"]),
|
type: z.enum(["round", "sharp"]),
|
||||||
value: z.number().finite().min(0).max(1),
|
value: z.number().finite().min(0).max(1),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional()
|
||||||
currentItemFontSize: z.number().finite().min(1).max(200).optional(),
|
.nullable(),
|
||||||
currentItemFontFamily: z.number().finite().min(1).max(5).optional(),
|
currentItemFontSize: z
|
||||||
currentItemTextAlign: z.enum(["left", "center", "right"]).optional(),
|
.number()
|
||||||
currentItemVerticalAlign: z.enum(["top", "middle", "bottom"]).optional(),
|
.finite()
|
||||||
scrollX: z.number().finite().min(-1000000).max(1000000).optional(),
|
.min(1)
|
||||||
scrollY: z.number().finite().min(-1000000).max(1000000).optional(),
|
.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
|
zoom: z
|
||||||
.object({
|
.object({
|
||||||
value: z.number().finite().min(0.1).max(10),
|
value: z.number().finite().min(0.01).max(100),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional()
|
||||||
selection: z.array(z.string()).optional(),
|
.nullable(),
|
||||||
selectedElementIds: z.record(z.string(), z.boolean()).optional(),
|
selection: z.array(z.string()).optional().nullable(),
|
||||||
selectedGroupIds: z.record(z.string(), z.boolean()).optional(),
|
selectedElementIds: z.record(z.string(), z.boolean()).optional().nullable(),
|
||||||
|
selectedGroupIds: z.record(z.string(), z.boolean()).optional().nullable(),
|
||||||
activeEmbeddable: z
|
activeEmbeddable: z
|
||||||
.object({
|
.object({
|
||||||
elementId: z.string(),
|
elementId: z.string(),
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
activeTool: z
|
activeTool: z
|
||||||
.object({
|
.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
customType: z.string().optional(),
|
customType: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional()
|
||||||
cursorX: z.number().finite().optional(),
|
.nullable(),
|
||||||
cursorY: z.number().finite().optional(),
|
cursorX: z.number().finite().optional().nullable(),
|
||||||
// Sanitize any string values in appState
|
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(
|
.catchall(
|
||||||
z.any().refine((val) => {
|
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") {
|
if (typeof val === "string") {
|
||||||
return sanitizeText(val, 1000);
|
return sanitizeText(val, 1000);
|
||||||
}
|
}
|
||||||
|
// Allow numbers, booleans, objects, arrays, null, undefined
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -265,13 +265,14 @@ export const Editor: React.FC = () => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure we always have valid data structure
|
||||||
const persistableAppState = {
|
const persistableAppState = {
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff',
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState?.gridSize || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshot = latestElementsRef.current ?? elements;
|
const snapshot = latestElementsRef.current ?? elements ?? [];
|
||||||
const persistableElements = Array.from(snapshot);
|
const persistableElements = Array.isArray(snapshot) ? snapshot : [];
|
||||||
|
|
||||||
console.log("[Editor] Saving drawing", {
|
console.log("[Editor] Saving drawing", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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)
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user