From 2826e47392b0f445e2ae7f3a7094039174a2226c Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sat, 22 Nov 2025 17:17:50 -0800 Subject: [PATCH] fix: pinning CORS to FRONTEND_URL, validate drawing payloads with Zod, staging SQLite imports with integrity checks and atomic swaps in index.ts --- README.md | 8 + backend/package-lock.json | 334 +++++++++++++++++++++++++++++++++++++- backend/package.json | 4 +- backend/src/index.ts | 205 ++++++++++++++++++----- 4 files changed, 504 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 8417606..a222e46 100644 --- a/README.md +++ b/README.md @@ -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 +# Upgrading + +See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a specific release. + + + # Installation > [!CAUTION] diff --git a/backend/package-lock.json b/backend/package-lock.json index 8af5da5..07f23d9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" + } } } } diff --git a/backend/package.json b/backend/package.json index 1f65d5c..1f16800 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/index.ts b/backend/src/index.ts index ecc21ff..203584f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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,19 @@ const resolveDatabaseUrl = (rawUrl?: string) => { process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); +const allowedOrigin = process.env.FRONTEND_URL || "http://localhost:6767"; + +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: allowedOrigin, + credentials: true, }, maxHttpBufferSize: 1e8, // 100 MB }); @@ -48,12 +58,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: allowedOrigin, + 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 +293,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 +329,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 +625,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 +650,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 +693,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" }); }