add prisma cli to dependencies, make zod checks more permissive

This commit is contained in:
Zimeng Xiong
2025-11-23 06:56:45 -08:00
parent b864e82318
commit 997fa4af03
16 changed files with 195 additions and 395 deletions
+1
View File
@@ -2,3 +2,4 @@
PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
+6 -9
View File
@@ -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
+15 -23
View File
@@ -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
+1 -8
View File
@@ -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,
+1 -1
View File
@@ -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"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -169,7 +169,6 @@ const config = {
"db"
],
"activeProvider": "sqlite",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {
-1
View File
@@ -170,7 +170,6 @@ const config = {
"db"
],
"activeProvider": "sqlite",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {
+42 -3
View File
@@ -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" });
}
});
+120 -93
View File
@@ -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;
})
);