diff --git a/README.md b/README.md
index 3f94003..d7a777f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# ExcaliDash v0.1.0
+# ExcaliDash v0.1.5

[](https://hub.docker.com)
@@ -74,7 +74,10 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation
> [!CAUTION]
-> NOT for production use. This is just a side project (and also the first release), and it likely contains some bugs. DO NOT open ports to the internet (e.g. CORS is set to allow all)
+> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization) have been made, they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF.
+
+> [!CAUTION]
+> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
## Docker Hub (Recommended)
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..04bf419
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,30 @@
+# ExcaliDash v0.1.5
+
+Date: 2025-11-23
+
+Compatibility: v0.1.x (Backward Compatible)
+
+# Security
+
+- RCE: implemented strict Zod schema validation and input sanitization on file uploads; added path traversal guards to file handling logic
+
+- XSS: used DOMPurify for HTML sanitization; blocked execution-capable SVG attributes and enforces CSP headers.
+
+- DoS: moved CPU-intensive operations to worker threads to prevent event loop blocking; request rate limiting (1,000 req/15 min per IP) and streaming for large files
+
+# Infras & Deployment
+
+- non-root execution (uid 1001) in containers
+- migrated to multi-stage Docker builds
+
+# Database
+
+- migrated to better-sqlite3, converted all DB interactions to non-blocking async operations and offloaded integrity checks to worker threads.
+
+- implemented SQLite magic header validation; added automatic backup triggers preceding data import
+
+- input validation logic
+
+# Frontend
+
+- updated Settings UI to show version
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..def9a01
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.5
\ No newline at end of file
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 3f0d30e..53589ec 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -25,8 +25,10 @@ RUN npx tsc
# Production stage
FROM node:20-alpine
-# Install OpenSSL for Prisma
-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
WORKDIR /app
@@ -49,10 +51,15 @@ COPY --from=builder /app/src/generated ./dist/generated
# Generate Prisma Client in production (updates node_modules)
RUN npx prisma generate
-# Run migrations and start server
+# 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
+# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
+
EXPOSE 8000
ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh
index 41ca11c..d1a311a 100644
--- a/backend/docker-entrypoint.sh
+++ b/backend/docker-entrypoint.sh
@@ -1,14 +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
-# Run migrations
-npx prisma migrate deploy
+# 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
-# Start the application
-node dist/index.js
+# Ensure database file has proper permissions
+if [ -f "/app/prisma/dev.db" ]; then
+ echo "Database file found, ensuring write permissions..."
+ chmod 666 /app/prisma/dev.db
+fi
+
+# 3. Run Migrations (Drop privileges to nodejs)
+echo "Running database migrations..."
+su-exec nodejs npx prisma migrate deploy
+
+# 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 07f23d9..e2c7b77 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -11,14 +11,18 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
+ "@types/jsdom": "^27.0.0",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
+ "dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"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"
},
@@ -27,11 +31,66 @@
"@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"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.24",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz",
+ "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==",
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz",
+ "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.4",
+ "@csstools/css-color-parser": "^3.1.0",
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4",
+ "lru-cache": "^11.2.2"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
+ "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.7.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz",
+ "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.2"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
+ "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -45,6 +104,137 @@
"node": ">=12"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.17",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.17.tgz",
+ "integrity": "sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -122,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": {
@@ -143,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",
@@ -162,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"
@@ -268,6 +453,17 @@
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
+ "node_modules/@types/jsdom": {
+ "version": "27.0.0",
+ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz",
+ "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/tough-cookie": "*",
+ "parse5": "^7.0.0"
+ }
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -353,6 +549,19 @@
"socket.io": "*"
}
},
+ "node_modules/@types/tough-cookie": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -404,6 +613,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -606,6 +824,15 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1019,6 +1246,46 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz",
+ "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.0.3",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.14",
+ "css-tree": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
+ "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1036,6 +1303,12 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1088,6 +1361,15 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
+ "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -1239,6 +1521,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1448,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,
@@ -1620,6 +1913,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1640,6 +1945,32 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -1755,6 +2086,12 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1800,6 +2137,78 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
+ "node_modules/jsdom": {
+ "version": "27.2.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz",
+ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.23",
+ "@asamuzakjp/dom-selector": "^6.7.4",
+ "cssstyle": "^5.3.3",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^15.1.0",
+ "ws": "^8.18.3",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/jsdom/node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -1870,6 +2279,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -2157,6 +2572,18 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2244,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,
@@ -2306,6 +2732,15 @@
"once": "^1.3.1"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -2433,6 +2868,15 @@
"node": ">=8.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2475,6 +2919,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2828,6 +3284,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2983,6 +3448,12 @@
"node": ">=4"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -3031,6 +3502,24 @@
"b4a": "^1.6.4"
}
},
+ "node_modules/tldts": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
+ "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.19"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
+ "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3063,6 +3552,30 @@
"nodetouch": "bin/nodetouch.js"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -3198,6 +3711,61 @@
"node": ">= 0.8"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
+ "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3331,6 +3899,21 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/backend/package.json b/backend/package.json
index 1f16800..384c855 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "backend",
- "version": "1.0.0",
+ "version": "0.1.5",
"description": "",
"main": "index.js",
"scripts": {
@@ -14,14 +14,18 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
+ "@types/jsdom": "^27.0.0",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
+ "dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"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 +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
index 8c402fb..9f71e00 100644
Binary files a/backend/prisma/dev.db.backup and b/backend/prisma/dev.db.backup 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/index.ts b/backend/src/index.ts
index fa47cc0..74f3833 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -3,14 +3,23 @@ import cors from "cors";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
+import { promises as fsPromises } from "fs";
import { createServer } from "http";
import { Server } from "socket.io";
+import { Worker } from "worker_threads";
import multer from "multer";
import archiver from "archiver";
-import Database from "better-sqlite3";
import { z } from "zod";
// @ts-ignore
import { PrismaClient } from "./generated/client";
+import {
+ sanitizeDrawingData,
+ validateImportedDrawing,
+ sanitizeText,
+ sanitizeSvg,
+ elementSchema,
+ appStateSchema,
+} from "./security";
dotenv.config();
@@ -60,9 +69,38 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
console.log("Allowed origins:", allowedOrigins);
const uploadDir = path.resolve(__dirname, "../uploads");
-if (!fs.existsSync(uploadDir)) {
- fs.mkdirSync(uploadDir, { recursive: true });
-}
+
+const moveFile = async (source: string, destination: string) => {
+ try {
+ await fsPromises.rename(source, destination);
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+ if (!err || err.code !== "EXDEV") {
+ throw error;
+ }
+
+ // Cross-device rename fallback: copy then delete source
+ await fsPromises
+ .unlink(destination)
+ .catch((unlinkError: NodeJS.ErrnoException) => {
+ if (unlinkError && unlinkError.code !== "ENOENT") {
+ throw unlinkError;
+ }
+ });
+
+ await fsPromises.copyFile(source, destination);
+ await fsPromises.unlink(source);
+ }
+};
+
+// Initialize upload directory asynchronously
+const initializeUploadDir = async () => {
+ try {
+ await fsPromises.mkdir(uploadDir, { recursive: true });
+ } catch (error) {
+ console.error("Failed to create upload directory:", error);
+ }
+};
const app = express();
const httpServer = createServer(app);
@@ -76,8 +114,26 @@ const io = new Server(httpServer, {
const prisma = new PrismaClient();
const PORT = process.env.PORT || 8000;
-// Multer setup for file uploads
-const upload = multer({ dest: uploadDir });
+// Multer setup for file uploads with streaming support
+const upload = multer({
+ dest: uploadDir,
+ limits: {
+ fileSize: 100 * 1024 * 1024, // 100MB limit
+ files: 1, // Only one file per upload
+ },
+ fileFilter: (req, file, cb) => {
+ // Only allow SQLite database extensions for database imports
+ if (file.fieldname === "db") {
+ const isSqliteDb =
+ file.originalname.endsWith(".db") ||
+ file.originalname.endsWith(".sqlite");
+ if (!isSqliteDb) {
+ return cb(new Error("Only .db or .sqlite files are allowed"));
+ }
+ }
+ cb(null, true);
+ },
+});
app.use(
cors({
@@ -88,9 +144,73 @@ app.use(
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
-const elementsSchema = z.array(z.object({}).passthrough());
+// Log large requests for monitoring and debugging
+app.use((req, res, next) => {
+ const contentLength = req.headers["content-length"];
+ if (contentLength) {
+ const sizeInMB = parseInt(contentLength, 10) / 1024 / 1024;
+ if (sizeInMB > 10) {
+ console.log(
+ `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
+ 2
+ )}MB - Content-Length: ${contentLength} bytes`
+ );
+ }
+ }
+ next();
+});
-const appStateSchema = z.object({}).passthrough();
+// Security middleware - Add security headers
+app.use((req, res, next) => {
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("X-Frame-Options", "DENY");
+ res.setHeader("X-XSS-Protection", "1; mode=block");
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
+ res.setHeader(
+ "Permissions-Policy",
+ "geolocation=(), microphone=(), camera=()"
+ );
+
+ // Content Security Policy - restrict sources
+ res.setHeader(
+ "Content-Security-Policy",
+ "default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
+ "font-src 'self' https://fonts.gstatic.com; " +
+ "img-src 'self' data: blob: https:; " +
+ "connect-src 'self' ws: wss:; " +
+ "frame-ancestors 'none';"
+ );
+
+ next();
+});
+
+// Rate limiting middleware (basic implementation)
+const requestCounts = new Map();
+const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
+const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window
+
+app.use((req, res, next) => {
+ const ip = req.ip || req.connection.remoteAddress || "unknown";
+ const now = Date.now();
+ const clientData = requestCounts.get(ip);
+
+ if (!clientData || now > clientData.resetTime) {
+ requestCounts.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
+ return next();
+ }
+
+ if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) {
+ return res.status(429).json({
+ error: "Rate limit exceeded",
+ message: "Too many requests, please try again later",
+ });
+ }
+
+ clientData.count++;
+ next();
+});
const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()])
@@ -103,17 +223,84 @@ const drawingBaseSchema = z.object({
preview: z.string().nullable().optional(),
});
-const drawingCreateSchema = drawingBaseSchema.extend({
- elements: elementsSchema.default([]),
- appState: appStateSchema.default({}),
- files: filesFieldSchema,
-});
+// Use strict schemas from security module with sanitization
+const drawingCreateSchema = drawingBaseSchema
+ .extend({
+ elements: elementSchema.array().default([]),
+ appState: appStateSchema.default({}),
+ files: filesFieldSchema,
+ })
+ .refine(
+ (data) => {
+ // Apply sanitization before database persistence
+ try {
+ const sanitized = sanitizeDrawingData(data);
+ // Merge sanitized data back with original properties
+ Object.assign(data, sanitized);
+ return true;
+ } catch (error) {
+ console.error("Sanitization failed:", error);
+ return false;
+ }
+ },
+ {
+ message: "Invalid or malicious drawing data detected",
+ }
+ );
-const drawingUpdateSchema = drawingBaseSchema.extend({
- elements: elementsSchema.optional(),
- appState: appStateSchema.optional(),
- files: filesFieldSchema,
-});
+const drawingUpdateSchema = drawingBaseSchema
+ .extend({
+ elements: elementSchema.array().optional(),
+ appState: appStateSchema.optional(),
+ files: filesFieldSchema,
+ })
+ .refine(
+ (data) => {
+ // Apply sanitization before database persistence
+ try {
+ // Only sanitize provided fields
+ const sanitizedData = { ...data };
+ if (data.elements !== undefined || data.appState !== undefined) {
+ const fullData = {
+ 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,
+ };
+ const sanitized = sanitizeDrawingData(fullData);
+ sanitizedData.elements = sanitized.elements;
+ sanitizedData.appState = sanitized.appState;
+ if (data.files !== undefined) sanitizedData.files = sanitized.files;
+ if (data.preview !== undefined)
+ sanitizedData.preview = sanitized.preview;
+ Object.assign(data, sanitizedData);
+ }
+ 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;
+ }
+ },
+ {
+ message: "Invalid or malicious drawing data detected",
+ }
+ );
const respondWithValidationErrors = (
res: express.Response,
@@ -125,29 +312,90 @@ const respondWithValidationErrors = (
});
};
-const runIntegrityCheck = (filePath: string): boolean => {
- let dbInstance: Database.Database | undefined;
+const validateSqliteHeader = (filePath: string): boolean => {
try {
- dbInstance = new Database(filePath, {
- readonly: true,
- fileMustExist: true,
- });
- const result = dbInstance.prepare("PRAGMA integrity_check;").get();
- return result?.integrity_check === "ok";
+ const buffer = Buffer.alloc(16);
+ const fd = fs.openSync(filePath, "r");
+ const bytesRead = fs.readSync(fd, buffer, 0, 16, 0);
+ fs.closeSync(fd);
+
+ if (bytesRead < 16) {
+ console.warn("File too small to be a valid SQLite database");
+ return false;
+ }
+
+ // SQLite format 3 header: "SQLite format 3\0" (16 bytes)
+ // Hex: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
+ const expectedHeader = Buffer.from([
+ 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61,
+ 0x74, 0x20, 0x33, 0x00,
+ ]);
+
+ const isValid = buffer.equals(expectedHeader);
+ if (!isValid) {
+ console.warn("Invalid SQLite file header detected", {
+ filePath,
+ header: buffer.toString("hex"),
+ expected: expectedHeader.toString("hex"),
+ });
+ }
+
+ return isValid;
} catch (error) {
- console.error("Integrity check failed:", error);
+ console.error("Failed to validate SQLite header:", error);
return false;
- } finally {
- dbInstance?.close();
}
};
+// Non-blocking CPU check using worker threads while still verifying headers
+const verifyDatabaseIntegrityAsync = (filePath: string): Promise => {
+ if (!validateSqliteHeader(filePath)) {
+ return Promise.resolve(false);
+ }
-const removeFileIfExists = (filePath?: string) => {
+ return new Promise((resolve) => {
+ const worker = new Worker(
+ path.resolve(__dirname, "./workers/db-verify.js"),
+ {
+ workerData: { filePath },
+ }
+ );
+ let timeoutHandle: NodeJS.Timeout;
+ let settled = false;
+
+ const finish = (result: boolean) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeoutHandle);
+ resolve(result);
+ };
+
+ worker.on("message", (isValid: boolean) => finish(isValid));
+ worker.on("error", (err) => {
+ console.error("Worker error:", err);
+ finish(false);
+ });
+ worker.on("exit", (code) => {
+ if (code !== 0) {
+ finish(false);
+ }
+ });
+
+ timeoutHandle = setTimeout(() => {
+ console.warn("Integrity check worker timed out", { filePath });
+ worker.terminate();
+ finish(false);
+ }, 10000); // 10 second timeout
+ });
+};
+
+const removeFileIfExists = async (filePath?: string) => {
if (!filePath) return;
try {
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
+ await fsPromises.access(filePath).catch(() => {
+ // File doesn't exist, nothing to remove
+ return;
+ });
+ await fsPromises.unlink(filePath);
} catch (error) {
console.error("Failed to remove file", { filePath, error });
}
@@ -312,6 +560,17 @@ app.get("/drawings/:id", async (req, res) => {
// POST /drawings
app.post("/drawings", async (req, res) => {
try {
+ // Additional security validation for imported data
+ const isImportedDrawing = req.headers["x-imported-file"] === "true";
+
+ if (isImportedDrawing && !validateImportedDrawing(req.body)) {
+ return res.status(400).json({
+ error: "Invalid imported drawing file",
+ message:
+ "The imported file contains potentially malicious content or invalid structure",
+ });
+ }
+
const parsed = drawingCreateSchema.safeParse(req.body);
if (!parsed.success) {
return respondWithValidationErrors(res, parsed.error.issues);
@@ -340,6 +599,7 @@ app.post("/drawings", async (req, res) => {
files: JSON.parse(newDrawing.files || "{}"),
});
} catch (error) {
+ console.error("Failed to create drawing:", error);
res.status(500).json({ error: "Failed to create drawing" });
}
});
@@ -348,8 +608,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);
}
@@ -404,6 +688,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" });
}
});
@@ -518,12 +803,19 @@ app.delete("/collections/:id", async (req, res) => {
// --- Export/Import Endpoints ---
-// GET /export - Export SQLite database
+// GET /export - Export SQLite database (supports .sqlite and .db extensions)
app.get("/export", async (req, res) => {
try {
+ const formatParam =
+ typeof req.query.format === "string"
+ ? req.query.format.toLowerCase()
+ : undefined;
+ const extension = formatParam === "db" ? "db" : "sqlite";
const dbPath = path.resolve(__dirname, "../prisma/dev.db");
- if (!fs.existsSync(dbPath)) {
+ try {
+ await fsPromises.access(dbPath);
+ } catch {
return res.status(404).json({ error: "Database file not found" });
}
@@ -532,7 +824,7 @@ app.get("/export", async (req, res) => {
"Content-Disposition",
`attachment; filename="excalidash-db-${
new Date().toISOString().split("T")[0]
- }.sqlite"`
+ }.${extension}"`
);
const fileStream = fs.createReadStream(dbPath);
@@ -645,18 +937,18 @@ app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => {
}
const stagedPath = req.file.path;
- const isValid = runIntegrityCheck(stagedPath);
- removeFileIfExists(stagedPath);
+ const isValid = await verifyDatabaseIntegrityAsync(stagedPath);
+ await removeFileIfExists(stagedPath);
if (!isValid) {
- return res.status(400).json({ error: "Invalid SQLite file" });
+ return res.status(400).json({ error: "Invalid database format" });
}
res.json({ valid: true, message: "Database file is valid" });
} catch (error) {
console.error(error);
if (req.file) {
- removeFileIfExists(req.file.path);
+ await removeFileIfExists(req.file.path);
}
res.status(500).json({ error: "Failed to verify database file" });
}
@@ -676,17 +968,17 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
);
try {
- fs.renameSync(originalPath, stagedPath);
+ await moveFile(originalPath, stagedPath);
} catch (error) {
console.error("Failed to stage uploaded database", error);
- removeFileIfExists(originalPath);
- removeFileIfExists(stagedPath);
+ await removeFileIfExists(originalPath);
+ await removeFileIfExists(stagedPath);
return res.status(500).json({ error: "Failed to stage uploaded file" });
}
- const isValid = runIntegrityCheck(stagedPath);
+ const isValid = await verifyDatabaseIntegrityAsync(stagedPath);
if (!isValid) {
- removeFileIfExists(stagedPath);
+ await removeFileIfExists(stagedPath);
return res
.status(400)
.json({ error: "Uploaded database failed integrity check" });
@@ -696,13 +988,20 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup");
try {
- if (fs.existsSync(dbPath)) {
- fs.copyFileSync(dbPath, backupPath);
+ // Use async file operations instead of blocking ones
+ try {
+ await fsPromises.access(dbPath);
+ // Database exists, create backup
+ await fsPromises.copyFile(dbPath, backupPath);
+ } catch {
+ // Database doesn't exist, skip backup
}
- fs.renameSync(stagedPath, dbPath);
+
+ // Move staged file to final location, supporting cross-device mounts
+ await moveFile(stagedPath, dbPath);
} catch (error) {
console.error("Failed to replace database", error);
- removeFileIfExists(stagedPath);
+ await removeFileIfExists(stagedPath);
return res.status(500).json({ error: "Failed to replace database" });
}
@@ -713,7 +1012,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
} catch (error) {
console.error(error);
if (req.file) {
- removeFileIfExists(req.file.path);
+ await removeFileIfExists(req.file.path);
}
res.status(500).json({ error: "Failed to import database" });
}
@@ -737,6 +1036,8 @@ const ensureTrashCollection = async () => {
};
httpServer.listen(PORT, async () => {
+ // Initialize upload directory asynchronously to avoid blocking startup
+ await initializeUploadDir();
await ensureTrashCollection();
console.log(`Server running on port ${PORT}`);
});
diff --git a/backend/src/security.ts b/backend/src/security.ts
new file mode 100644
index 0000000..e0c230a
--- /dev/null
+++ b/backend/src/security.ts
@@ -0,0 +1,495 @@
+/**
+ * Security utilities for XSS prevention and data sanitization
+ */
+
+import { z } from "zod";
+import DOMPurify from "dompurify";
+import { JSDOM } from "jsdom";
+
+// Create a DOM environment for DOMPurify (Node.js compatibility)
+const window = new JSDOM("").window;
+const purify = DOMPurify(window);
+
+/**
+ * Sanitize HTML/JS content using DOMPurify (battle-tested library)
+ */
+export const sanitizeHtml = (input: string): string => {
+ if (typeof input !== "string") return "";
+
+ return purify
+ .sanitize(input, {
+ ALLOWED_TAGS: [
+ // Allow basic text formatting that might be in drawings
+ "b",
+ "i",
+ "u",
+ "em",
+ "strong",
+ "p",
+ "br",
+ "span",
+ "div",
+ ],
+ ALLOWED_ATTR: [], // No attributes allowed by default for security
+ FORBID_TAGS: [
+ // Explicitly forbid dangerous tags
+ "script",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "style",
+ "form",
+ "input",
+ "button",
+ "select",
+ "textarea",
+ "svg",
+ "foreignObject",
+ ],
+ FORBID_ATTR: [
+ // Explicitly forbid dangerous attributes
+ "onload",
+ "onclick",
+ "onerror",
+ "onmouseover",
+ "onfocus",
+ "onblur",
+ "onchange",
+ "onsubmit",
+ "onreset",
+ "onkeydown",
+ "onkeyup",
+ "onkeypress",
+ "href",
+ "src",
+ "action",
+ "formaction",
+ ],
+ KEEP_CONTENT: true, // Keep content even if tags are removed
+ })
+ .trim();
+};
+
+/**
+ * Sanitize SVG content using DOMPurify with strict SVG restrictions
+ */
+export const sanitizeSvg = (svgContent: string): string => {
+ if (typeof svgContent !== "string") return "";
+
+ // For SVG content, we'll be very restrictive since SVG can execute JavaScript
+ // We only allow basic geometric shapes without any scripts or external references
+ return purify
+ .sanitize(svgContent, {
+ ALLOWED_TAGS: [
+ // Allow only safe SVG geometric elements
+ "svg",
+ "g",
+ "rect",
+ "circle",
+ "ellipse",
+ "line",
+ "polyline",
+ "polygon",
+ "path",
+ "text",
+ "tspan",
+ ],
+ ALLOWED_ATTR: [
+ // Allow only safe geometric attributes
+ "x",
+ "y",
+ "width",
+ "height",
+ "cx",
+ "cy",
+ "r",
+ "rx",
+ "ry",
+ "x1",
+ "y1",
+ "x2",
+ "y2",
+ "points",
+ "d",
+ "fill",
+ "stroke",
+ "stroke-width",
+ "opacity",
+ "transform",
+ "font-size",
+ "font-family",
+ "text-anchor",
+ "dominant-baseline",
+ ],
+ FORBID_TAGS: [
+ // Completely forbid any script-related or external content
+ "script",
+ "foreignObject",
+ "iframe",
+ "object",
+ "embed",
+ "use",
+ "image",
+ "style",
+ "link",
+ "defs",
+ "symbol",
+ "marker",
+ "clipPath",
+ "mask",
+ "filter",
+ ],
+ FORBID_ATTR: [
+ // Forbid any attributes that could execute code or load external content
+ "onload",
+ "onclick",
+ "onerror",
+ "onmouseover",
+ "onfocus",
+ "onblur",
+ "href",
+ "xlink:href",
+ "src",
+ "action",
+ "style",
+ "class",
+ "id",
+ ],
+ KEEP_CONTENT: true,
+ })
+ .trim();
+};
+
+/**
+ * Validate and sanitize text content using DOMPurify
+ */
+export const sanitizeText = (
+ input: unknown,
+ maxLength: number = 1000
+): string => {
+ if (typeof input !== "string") return "";
+
+ // Remove null bytes and control characters except newlines and tabs
+ const cleaned = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
+
+ // Truncate if too long
+ const truncated = cleaned.slice(0, maxLength);
+
+ // Use DOMPurify for text content - more permissive than HTML but still safe
+ return purify
+ .sanitize(truncated, {
+ ALLOWED_TAGS: [
+ // Allow basic text formatting that might be in drawing text
+ "b",
+ "i",
+ "u",
+ "em",
+ "strong",
+ "br",
+ "span",
+ ],
+ ALLOWED_ATTR: [], // No attributes allowed for text content
+ FORBID_TAGS: [
+ // Block potentially dangerous tags
+ "script",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "style",
+ "form",
+ "input",
+ "button",
+ "select",
+ "textarea",
+ "svg",
+ "foreignObject",
+ ],
+ FORBID_ATTR: [
+ // Block all event handlers and dangerous attributes
+ "onload",
+ "onclick",
+ "onerror",
+ "onmouseover",
+ "onfocus",
+ "onblur",
+ "onchange",
+ "onsubmit",
+ "onreset",
+ "onkeydown",
+ "onkeyup",
+ "onkeypress",
+ "href",
+ "src",
+ "action",
+ "formaction",
+ "style",
+ ],
+ KEEP_CONTENT: true,
+ })
+ .trim();
+};
+
+/**
+ * Sanitize URL to prevent javascript: and data: attacks
+ */
+export const sanitizeUrl = (url: unknown): string => {
+ if (typeof url !== "string") return "";
+
+ const trimmed = url.trim();
+
+ // Block javascript:, data:, vbscript: URLs
+ if (/^(javascript|data|vbscript):/i.test(trimmed)) {
+ return "";
+ }
+
+ // Basic URL validation
+ try {
+ // Allow http, https, mailto, and relative URLs
+ if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/)/i.test(trimmed)) {
+ return trimmed;
+ }
+ return "";
+ } catch {
+ return "";
+ }
+};
+
+/**
+ * Very flexible Zod schema for Excalidraw elements
+ */
+export const elementSchema = z
+ .object({
+ 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(),
+ })
+ .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;
+ });
+
+/**
+ * Flexible Zod schema for Excalidraw app state with validation
+ */
+export const appStateSchema = z
+ .object({
+ 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()
+ .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()
+ .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.01).max(100),
+ })
+ .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()
+ .nullable(),
+ activeTool: z
+ .object({
+ type: z.string(),
+ customType: z.string().optional().nullable(),
+ })
+ .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(),
+ })
+ // Allow any additional properties
+ .catchall(
+ z.any().refine((val) => {
+ // 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;
+ })
+ );
+
+/**
+ * Sanitize drawing data before persistence
+ */
+export const sanitizeDrawingData = (data: {
+ elements: any[];
+ appState: any;
+ files?: any;
+ preview?: string | null;
+}) => {
+ try {
+ // Validate and sanitize elements
+ const sanitizedElements = elementSchema.array().parse(data.elements);
+
+ // Validate and sanitize app state
+ const sanitizedAppState = appStateSchema.parse(data.appState);
+
+ // Sanitize preview SVG if present
+ let sanitizedPreview = data.preview;
+ if (typeof sanitizedPreview === "string") {
+ sanitizedPreview = sanitizeSvg(sanitizedPreview);
+ }
+
+ // Sanitize files object
+ let sanitizedFiles = data.files;
+ if (typeof sanitizedFiles === "object" && sanitizedFiles !== null) {
+ // Recursively sanitize any string values in files
+ sanitizedFiles = JSON.parse(
+ JSON.stringify(sanitizedFiles, (key, value) => {
+ if (typeof value === "string") {
+ return sanitizeText(value, 10000);
+ }
+ return value;
+ })
+ );
+ }
+
+ return {
+ elements: sanitizedElements,
+ appState: sanitizedAppState,
+ files: sanitizedFiles,
+ preview: sanitizedPreview,
+ };
+ } catch (error) {
+ console.error("Data sanitization failed:", error);
+ throw new Error("Invalid or malicious drawing data detected");
+ }
+};
+
+/**
+ * Validate imported .excalidraw file structure
+ */
+export const validateImportedDrawing = (data: any): boolean => {
+ try {
+ // Basic structure validation
+ if (!data || typeof data !== "object") return false;
+
+ if (!Array.isArray(data.elements)) return false;
+ if (typeof data.appState !== "object") return false;
+
+ // Check element count to prevent DoS
+ if (data.elements.length > 10000) {
+ throw new Error("Drawing contains too many elements (max 10,000)");
+ }
+
+ // Sanitize and validate the data
+ const sanitized = sanitizeDrawingData(data);
+
+ // Additional structural validation
+ if (sanitized.elements.length !== data.elements.length) {
+ throw new Error("Element count mismatch after sanitization");
+ }
+
+ return true;
+ } catch (error) {
+ console.error("Imported drawing validation failed:", error);
+ return false;
+ }
+};
diff --git a/backend/src/securityTest.ts b/backend/src/securityTest.ts
new file mode 100644
index 0000000..311a008
--- /dev/null
+++ b/backend/src/securityTest.ts
@@ -0,0 +1,217 @@
+/**
+ * Security Test Suite for XSS Prevention
+ * Tests malicious payload detection and sanitization
+ */
+
+import {
+ sanitizeHtml,
+ sanitizeSvg,
+ sanitizeText,
+ sanitizeUrl,
+ validateImportedDrawing,
+ sanitizeDrawingData,
+} from "./security";
+
+console.log("Starting Security Test Suite...\n");
+
+// Test 1: HTML/JS Sanitization
+console.log("Test 1: HTML/JS Sanitization");
+const maliciousHtml = `
+
+
+
+
+
+ Normal text content
+`;
+const sanitizedHtml = sanitizeHtml(maliciousHtml);
+console.log("PASS: Original:", maliciousHtml.substring(0, 100) + "...");
+console.log("PASS: Sanitized:", sanitizedHtml.substring(0, 100) + "...");
+console.log("PASS: Script tags removed:", !sanitizedHtml.includes("
+
+
+
+
+
+`;
+const sanitizedSvg = sanitizeSvg(maliciousSvg);
+console.log("PASS: Original:", maliciousSvg.substring(0, 100) + "...");
+console.log("PASS: Sanitized:", sanitizedSvg.substring(0, 100) + "...");
+console.log("PASS: SVG scripts removed:", !sanitizedSvg.includes("",
+ "vbscript:msgbox('XSS')",
+ "https://example.com",
+ "/relative/path",
+ "./current/path",
+ "../parent/path",
+ "mailto:test@example.com",
+];
+
+maliciousUrls.forEach((url) => {
+ const sanitized = sanitizeUrl(url);
+ const isSafe = sanitized !== "";
+ console.log(
+ `PASS: "${url}" -> "${sanitized}" (${isSafe ? "SAFE" : "BLOCKED"})`
+ );
+});
+console.log("");
+
+// Test 4: Text Sanitization with Length Limits
+console.log("Test 4: Text Sanitization with Length Limits");
+const longText = "A".repeat(2000);
+const sanitizedLongText = sanitizeText(longText, 500);
+console.log(
+ `PASS: Long text truncated: ${longText.length} -> ${sanitizedLongText.length} chars`
+);
+
+const maliciousText = "Normal text";
+const sanitizedText = sanitizeText(maliciousText);
+console.log(`PASS: Text sanitized: "${maliciousText}" -> "${sanitizedText}"`);
+console.log(
+ "PASS: Malicious content removed:",
+ !sanitizedText.includes("Malicious text",
+ },
+ {
+ id: "test2",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 100,
+ angle: 0,
+ version: 1,
+ versionNonce: 1,
+ link: "javascript:alert('XSS')",
+ },
+ ],
+ appState: {
+ viewBackgroundColor: "",
+ },
+ files: null,
+ preview: '',
+};
+
+console.log("Testing malicious drawing validation...");
+const isValidDrawing = validateImportedDrawing(maliciousDrawing);
+console.log(`PASS: Malicious drawing rejected: ${!isValidDrawing}`);
+
+try {
+ const sanitizedDrawing = sanitizeDrawingData(maliciousDrawing);
+ console.log("PASS: Sanitization successful");
+ console.log(`PASS: Text sanitized: ${sanitizedDrawing.elements[0].text}`);
+ console.log(
+ `PASS: Link sanitized: ${sanitizedDrawing.elements[1].link || "null"}`
+ );
+ console.log(
+ `PASS: SVG sanitized: ${!sanitizedDrawing.preview?.includes("