Compare commits

...

5 Commits

Author SHA1 Message Date
Zimeng Xiong ae8f6d696e fix bind mount prisma, auto hydrate empty folder 2025-11-22 20:25:07 -08:00
Zimeng Xiong 77c1824b00 add fallback for browsers that do not have crypto.randomUUID 2025-11-22 19:18:05 -08:00
Zimeng Xiong c54a2ae5e7 add CORS fallback 2025-11-22 19:14:55 -08:00
Zimeng Xiong 55162c0b93 fix: add linux-musl-openssl-3.0.x 2025-11-22 19:07:28 -08:00
Zimeng Xiong 2826e47392 fix: pinning CORS to FRONTEND_URL, validate drawing payloads with Zod, staging SQLite imports with integrity checks and atomic swaps in index.ts 2025-11-22 17:17:50 -08:00
13 changed files with 578 additions and 56 deletions
+8
View File
@@ -15,7 +15,9 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
## Table of Contents
- [Screenshots](#screenshots)
- [Features](#features)
- [Upgrading](#upgrading)
- [Installation](#installation)
- [Docker Hub (Recommended)](#dockerhub-recommended)
- [Docker Build](#docker-build)
@@ -63,6 +65,12 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
</details>
# Upgrading
See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a specific release.
</details>
# Installation
> [!CAUTION]
+2 -1
View File
@@ -36,8 +36,9 @@ COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy prisma schema and migrations
# Copy prisma schema and migrations for runtime and hydration template
COPY prisma ./prisma/
COPY prisma ./prisma_template/
# Copy built application from builder
COPY --from=builder /app/dist ./dist
+6
View File
@@ -1,6 +1,12 @@
#!/bin/sh
set -e
# Auto-hydrate prisma directory when bind-mounted volume is empty
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/
fi
# Run migrations
npx prisma migrate deploy
+332 -2
View File
@@ -14,11 +14,13 @@
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"multer": "^2.0.2",
"socket.io": "^4.8.1"
"socket.io": "^4.8.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -590,6 +592,20 @@
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/better-sqlite3": {
"version": "12.4.6",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
"integrity": "sha512-gaYt9yqTbQ1iOxLpJA8FPR5PiaHP+jlg8I5EX0Rs2KFwNzhBsF40KzMZS5FwelY7RG0wzaucWdqSAJM3uNCPCg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -603,6 +619,50 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@@ -760,6 +820,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -970,6 +1036,30 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -979,6 +1069,15 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -1042,6 +1141,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
@@ -1203,6 +1311,15 @@
"bare-events": "^2.7.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
@@ -1251,6 +1368,12 @@
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1315,6 +1438,12 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1376,6 +1505,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -1550,6 +1685,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1775,6 +1916,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -1818,6 +1971,12 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1885,6 +2044,12 @@
"node": ">= 0.6"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -1894,6 +2059,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -2037,6 +2214,32 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@@ -2093,6 +2296,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -2148,6 +2361,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -2251,7 +2479,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2408,6 +2635,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -2689,6 +2961,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -2702,6 +2983,34 @@
"node": ">=4"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
@@ -2798,6 +3107,18 @@
}
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -3058,6 +3379,15 @@
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+3 -1
View File
@@ -17,11 +17,13 @@
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"multer": "^2.0.2",
"socket.io": "^4.8.1"
"socket.io": "^4.8.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/cors": "^2.8.19",
+1 -1
View File
@@ -4,7 +4,7 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/client"
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
}
datasource db {
+7 -2
View File
@@ -148,6 +148,10 @@ const config = {
{
"fromEnvVar": null,
"value": "linux-musl-arm64-openssl-3.0.x"
},
{
"fromEnvVar": null,
"value": "linux-musl-openssl-3.0.x"
}
],
"previewFeatures": [],
@@ -165,6 +169,7 @@ const config = {
"db"
],
"activeProvider": "sqlite",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {
@@ -173,8 +178,8 @@ const config = {
}
}
},
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"linux-musl-arm64-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Collection {\n id String @id @default(uuid())\n name String\n drawings Drawing[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Drawing {\n id String @id @default(uuid())\n name String\n elements String // Stored as JSON string\n appState String // Stored as JSON string\n files String @default(\"{}\") // Stored as JSON string\n preview String? // SVG string for thumbnail\n version Int @default(1)\n collectionId String?\n collection Collection? @relation(fields: [collectionId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
"inlineSchemaHash": "9864a039193c73ddda01fd51751788fa5729bb0a603a9379a3fa314a4aced64f",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Collection {\n id String @id @default(uuid())\n name String\n drawings Drawing[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Drawing {\n id String @id @default(uuid())\n name String\n elements String // Stored as JSON string\n appState String // Stored as JSON string\n files String @default(\"{}\") // Stored as JSON string\n preview String? // SVG string for thumbnail\n version Int @default(1)\n collectionId String?\n collection Collection? @relation(fields: [collectionId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
"inlineSchemaHash": "30da526c2a5efdf3e5097c3736a52d47246ca4da8e5bd0401a3f28dd46ab5c3e",
"copyEngine": true
}
config.dirname = '/'
+11 -2
View File
@@ -149,6 +149,10 @@ const config = {
{
"fromEnvVar": null,
"value": "linux-musl-arm64-openssl-3.0.x"
},
{
"fromEnvVar": null,
"value": "linux-musl-openssl-3.0.x"
}
],
"previewFeatures": [],
@@ -166,6 +170,7 @@ const config = {
"db"
],
"activeProvider": "sqlite",
"postinstall": false,
"inlineDatasources": {
"db": {
"url": {
@@ -174,8 +179,8 @@ const config = {
}
}
},
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"linux-musl-arm64-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Collection {\n id String @id @default(uuid())\n name String\n drawings Drawing[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Drawing {\n id String @id @default(uuid())\n name String\n elements String // Stored as JSON string\n appState String // Stored as JSON string\n files String @default(\"{}\") // Stored as JSON string\n preview String? // SVG string for thumbnail\n version Int @default(1)\n collectionId String?\n collection Collection? @relation(fields: [collectionId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
"inlineSchemaHash": "9864a039193c73ddda01fd51751788fa5729bb0a603a9379a3fa314a4aced64f",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../src/generated/client\"\n binaryTargets = [\"native\", \"linux-musl-arm64-openssl-3.0.x\", \"linux-musl-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"sqlite\"\n url = env(\"DATABASE_URL\")\n}\n\nmodel Collection {\n id String @id @default(uuid())\n name String\n drawings Drawing[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Drawing {\n id String @id @default(uuid())\n name String\n elements String // Stored as JSON string\n appState String // Stored as JSON string\n files String @default(\"{}\") // Stored as JSON string\n preview String? // SVG string for thumbnail\n version Int @default(1)\n collectionId String?\n collection Collection? @relation(fields: [collectionId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
"inlineSchemaHash": "30da526c2a5efdf3e5097c3736a52d47246ca4da8e5bd0401a3f28dd46ab5c3e",
"copyEngine": true
}
@@ -219,6 +224,10 @@ path.join(process.cwd(), "src/generated/client/libquery_engine-darwin-arm64.dyli
// file annotations for bundling tools to include these files
path.join(__dirname, "libquery_engine-linux-musl-arm64-openssl-3.0.x.so.node");
path.join(process.cwd(), "src/generated/client/libquery_engine-linux-musl-arm64-openssl-3.0.x.so.node")
// file annotations for bundling tools to include these files
path.join(__dirname, "libquery_engine-linux-musl-openssl-3.0.x.so.node");
path.join(process.cwd(), "src/generated/client/libquery_engine-linux-musl-openssl-3.0.x.so.node")
// file annotations for bundling tools to include these files
path.join(__dirname, "schema.prisma");
path.join(process.cwd(), "src/generated/client/schema.prisma")
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "prisma-client-04007c5051869a2f5298bd562ab2fb60a423747e0d5699dd1a73a4757b2657b6",
"name": "prisma-client-6afe3d9baa793154c8d01c79f8418d91423e5ccaec794547bf848a451459cf53",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
+1 -1
View File
@@ -4,7 +4,7 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/client"
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
}
datasource db {
+180 -44
View File
@@ -7,6 +7,8 @@ import { createServer } from "http";
import { Server } from "socket.io";
import multer from "multer";
import archiver from "archiver";
import Database from "better-sqlite3";
import { z } from "zod";
// @ts-ignore
import { PrismaClient } from "./generated/client";
@@ -36,11 +38,38 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const fallback = "http://localhost:6767";
if (!rawOrigins || rawOrigins.trim().length === 0) {
return [fallback];
}
const ensureProtocol = (origin: string) =>
/^https?:\/\//i.test(origin) ? origin : `http://${origin}`;
const parsed = rawOrigins
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
.map(ensureProtocol);
return parsed.length > 0 ? parsed : [fallback];
};
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 app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "*",
origin: allowedOrigins,
credentials: true,
},
maxHttpBufferSize: 1e8, // 100 MB
});
@@ -48,12 +77,82 @@ const prisma = new PrismaClient();
const PORT = process.env.PORT || 8000;
// Multer setup for file uploads
const upload = multer({ dest: "uploads/" });
const upload = multer({ dest: uploadDir });
app.use(cors());
app.use(
cors({
origin: allowedOrigins,
credentials: true,
})
);
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
const elementsSchema = z.array(z.object({}).passthrough());
const appStateSchema = z.object({}).passthrough();
const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()])
.optional()
.transform((value) => (value === null ? undefined : value));
const drawingBaseSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
collectionId: z.union([z.string().trim().min(1), z.null()]).optional(),
preview: z.string().nullable().optional(),
});
const drawingCreateSchema = drawingBaseSchema.extend({
elements: elementsSchema.default([]),
appState: appStateSchema.default({}),
files: filesFieldSchema,
});
const drawingUpdateSchema = drawingBaseSchema.extend({
elements: elementsSchema.optional(),
appState: appStateSchema.optional(),
files: filesFieldSchema,
});
const respondWithValidationErrors = (
res: express.Response,
issues: z.ZodIssue[]
) => {
res.status(400).json({
error: "Invalid drawing payload",
details: issues,
});
};
const runIntegrityCheck = (filePath: string): boolean => {
let dbInstance: Database.Database | undefined;
try {
dbInstance = new Database(filePath, {
readonly: true,
fileMustExist: true,
});
const result = dbInstance.prepare("PRAGMA integrity_check;").get();
return result?.integrity_check === "ok";
} catch (error) {
console.error("Integrity check failed:", error);
return false;
} finally {
dbInstance?.close();
}
};
const removeFileIfExists = (filePath?: string) => {
if (!filePath) return;
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (error) {
console.error("Failed to remove file", { filePath, error });
}
};
// Socket.io Logic
interface User {
id: string;
@@ -213,16 +312,24 @@ app.get("/drawings/:id", async (req, res) => {
// POST /drawings
app.post("/drawings", async (req, res) => {
try {
const { name, elements, appState, collectionId, preview, files } = req.body;
const parsed = drawingCreateSchema.safeParse(req.body);
if (!parsed.success) {
return respondWithValidationErrors(res, parsed.error.issues);
}
const payload = parsed.data;
const drawingName = payload.name ?? "Untitled Drawing";
const targetCollectionId =
payload.collectionId === undefined ? null : payload.collectionId;
const newDrawing = await prisma.drawing.create({
data: {
name,
elements: JSON.stringify(elements || []),
appState: JSON.stringify(appState || {}),
collectionId: collectionId || null,
preview: preview || null,
files: JSON.stringify(files || {}),
name: drawingName,
elements: JSON.stringify(payload.elements),
appState: JSON.stringify(payload.appState),
collectionId: targetCollectionId,
preview: payload.preview ?? null,
files: JSON.stringify(payload.files ?? {}),
},
});
@@ -241,28 +348,37 @@ app.post("/drawings", async (req, res) => {
app.put("/drawings/:id", async (req, res) => {
try {
const { id } = req.params;
const { name, elements, appState, collectionId, preview, files } = req.body;
const parsed = drawingUpdateSchema.safeParse(req.body);
if (!parsed.success) {
return respondWithValidationErrors(res, parsed.error.issues);
}
const payload = parsed.data;
console.log("[API] Updating drawing", {
id,
hasElements: elements !== undefined,
elementCount:
elements && Array.isArray(elements) ? elements.length : undefined,
hasAppState: appState !== undefined,
hasFiles: files !== undefined,
hasPreview: preview !== undefined,
hasElements: payload.elements !== undefined,
elementCount: Array.isArray(payload.elements)
? payload.elements.length
: undefined,
hasAppState: payload.appState !== undefined,
hasFiles: payload.files !== undefined,
hasPreview: payload.preview !== undefined,
});
const data: any = {
version: { increment: 1 },
};
if (name !== undefined) data.name = name;
if (elements !== undefined) data.elements = JSON.stringify(elements);
if (appState !== undefined) data.appState = JSON.stringify(appState);
if (files !== undefined) data.files = JSON.stringify(files);
if (collectionId !== undefined) data.collectionId = collectionId;
if (preview !== undefined) data.preview = preview;
if (payload.name !== undefined) data.name = payload.name;
if (payload.elements !== undefined)
data.elements = JSON.stringify(payload.elements);
if (payload.appState !== undefined)
data.appState = JSON.stringify(payload.appState);
if (payload.files !== undefined) data.files = JSON.stringify(payload.files);
if (payload.collectionId !== undefined)
data.collectionId = payload.collectionId;
if (payload.preview !== undefined) data.preview = payload.preview;
const updatedDrawing = await prisma.drawing.update({
where: { id },
@@ -528,24 +644,19 @@ app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => {
return res.status(400).json({ error: "No file uploaded" });
}
// Basic verification: check if it's a SQLite file
const buffer = fs.readFileSync(req.file.path);
const header = buffer.slice(0, 16).toString("ascii");
const stagedPath = req.file.path;
const isValid = runIntegrityCheck(stagedPath);
removeFileIfExists(stagedPath);
if (!header.startsWith("SQLite format 3")) {
fs.unlinkSync(req.file.path);
if (!isValid) {
return res.status(400).json({ error: "Invalid SQLite file" });
}
// Additional verification could be added here
// For now, we'll just check the file signature
fs.unlinkSync(req.file.path);
res.json({ valid: true, message: "Database file is valid" });
} catch (error) {
console.error(error);
if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
if (req.file) {
removeFileIfExists(req.file.path);
}
res.status(500).json({ error: "Failed to verify database file" });
}
@@ -558,17 +669,42 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
return res.status(400).json({ error: "No file uploaded" });
}
const dbPath = path.resolve(__dirname, "../prisma/dev.db");
const originalPath = req.file.path;
const stagedPath = path.join(
uploadDir,
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
);
// Backup current database
if (fs.existsSync(dbPath)) {
const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup");
fs.copyFileSync(dbPath, backupPath);
try {
fs.renameSync(originalPath, stagedPath);
} catch (error) {
console.error("Failed to stage uploaded database", error);
removeFileIfExists(originalPath);
removeFileIfExists(stagedPath);
return res.status(500).json({ error: "Failed to stage uploaded file" });
}
// Replace database file
fs.copyFileSync(req.file.path, dbPath);
fs.unlinkSync(req.file.path);
const isValid = runIntegrityCheck(stagedPath);
if (!isValid) {
removeFileIfExists(stagedPath);
return res
.status(400)
.json({ error: "Uploaded database failed integrity check" });
}
const dbPath = path.resolve(__dirname, "../prisma/dev.db");
const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup");
try {
if (fs.existsSync(dbPath)) {
fs.copyFileSync(dbPath, backupPath);
}
fs.renameSync(stagedPath, dbPath);
} catch (error) {
console.error("Failed to replace database", error);
removeFileIfExists(stagedPath);
return res.status(500).json({ error: "Failed to replace database" });
}
// Reinitialize Prisma client
await prisma.$disconnect();
@@ -576,8 +712,8 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
res.json({ success: true, message: "Database imported successfully" });
} catch (error) {
console.error(error);
if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
if (req.file) {
removeFileIfExists(req.file.path);
}
res.status(500).json({ error: "Failed to import database" });
}
+26 -1
View File
@@ -80,6 +80,31 @@ const COLORS = [
"#f43f5e", // rose-500
];
const generateClientId = (): string => {
const cryptoObj: Crypto | undefined =
typeof globalThis !== "undefined"
? globalThis.crypto || (globalThis as any).msCrypto
: undefined;
if (cryptoObj?.randomUUID) {
return cryptoObj.randomUUID();
}
if (cryptoObj?.getRandomValues) {
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // RFC 4122 variant
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
// Final fallback for very old browsers; uniqueness window-scoped only.
return `id-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
};
export const getUserIdentity = (): UserIdentity => {
const stored = localStorage.getItem("excalidash-user-id");
if (stored) {
@@ -91,7 +116,7 @@ export const getUserIdentity = (): UserIdentity => {
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
const identity: UserIdentity = {
id: crypto.randomUUID(),
id: generateClientId(),
name: randomTransformer.name,
initials: randomTransformer.initials,
color: randomColor,