merge: pull PR48 auth and UX into pre-release

This commit is contained in:
Zimeng Xiong
2026-02-05 23:25:56 -08:00
32 changed files with 4401 additions and 536 deletions
+7 -1
View File
@@ -2,4 +2,10 @@
PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
FRONTEND_URL=http://localhost:6767
# Optional Feature Flags (all default to false for backward compatibility)
# Set to "true" or "1" to enable:
# ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false
+224
View File
@@ -11,19 +11,29 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
"@types/bcrypt": "^6.0.0",
"@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"@types/uuid": "^10.0.0",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
"dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"ms": "^2.1.3",
"multer": "^2.0.2",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
@@ -1001,6 +1011,15 @@
"@types/readdir-glob": "*"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1101,6 +1120,16 @@
"parse5": "^7.0.0"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1114,6 +1143,12 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -1229,6 +1264,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
@@ -1621,6 +1662,20 @@
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-sqlite3": {
"version": "12.4.6",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
@@ -1789,6 +1844,12 @@
"node": ">=8.0.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2282,6 +2343,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2621,6 +2691,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -2955,6 +3043,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
@@ -3065,6 +3162,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3243,6 +3349,49 @@
}
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -3291,6 +3440,48 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -3566,6 +3757,26 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -5032,6 +5243,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+10
View File
@@ -16,19 +16,29 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
"@types/bcrypt": "^6.0.0",
"@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"@types/uuid": "^10.0.0",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
"dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"ms": "^2.1.3",
"multer": "^2.0.2",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
@@ -0,0 +1,64 @@
/*
Warnings:
- Added the required column `userId` to the `Collection` table without a default value. This is not possible if the table is not empty.
- Added the required column `userId` to the `Drawing` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Collection" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Collection" ("createdAt", "id", "name", "updatedAt") SELECT "createdAt", "id", "name", "updatedAt" FROM "Collection";
DROP TABLE "Collection";
ALTER TABLE "new_Collection" RENAME TO "Collection";
CREATE TABLE "new_Drawing" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"elements" TEXT NOT NULL,
"appState" TEXT NOT NULL,
"files" TEXT NOT NULL DEFAULT '{}',
"preview" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"userId" TEXT NOT NULL,
"collectionId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing";
DROP TABLE "Drawing";
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
CREATE TABLE "new_Library" (
"id" TEXT NOT NULL PRIMARY KEY,
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt") SELECT "createdAt", "id", "items", "updatedAt" FROM "Library";
DROP TABLE "Library";
ALTER TABLE "new_Library" RENAME TO "Library";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
+52 -1
View File
@@ -12,9 +12,26 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
name String
isActive Boolean @default(true)
drawings Drawing[]
collections Collection[]
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Collection {
id String @id @default(uuid())
name String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
drawings Drawing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -28,6 +45,8 @@ model Drawing {
files String @default("{}") // Stored as JSON string
preview String? // SVG string for thumbnail
version Int @default(1)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id])
createdAt DateTime @default(now())
@@ -35,8 +54,40 @@ model Drawing {
}
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PasswordResetToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
revoked Boolean @default(false)
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted"
resource String? // e.g., "drawing:123", "collection:456"
ipAddress String?
userAgent String?
details String? // JSON string for additional details
createdAt DateTime @default(now())
}
@@ -315,10 +315,11 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
let testUser: { id: string };
beforeAll(async () => {
setupTestDb();
await initTestDb(prisma);
testUser = await initTestDb(prisma);
});
afterAll(async () => {
@@ -343,6 +344,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -381,6 +383,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -404,6 +407,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify({}),
userId: testUser.id,
},
});
+24 -1
View File
@@ -54,19 +54,42 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
});
};
/**
* Create a test user for testing
*/
export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => {
const bcrypt = require("bcrypt");
const passwordHash = await bcrypt.hash("testpassword", 10);
return await prisma.user.upsert({
where: { email },
update: {},
create: {
email,
passwordHash,
name: "Test User",
},
});
};
/**
* Initialize test database with required data
*/
export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first
const testUser = await createTestUser(prisma);
// Ensure Trash collection exists
const trash = await prisma.collection.findUnique({
where: { id: "trash" },
});
if (!trash) {
await prisma.collection.create({
data: { id: "trash", name: "Trash" },
data: { id: "trash", name: "Trash", userId: testUser.id },
});
}
return testUser;
};
/**
+855
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
/**
* Configuration validation and environment variable management
*/
import dotenv from "dotenv";
dotenv.config();
interface Config {
port: number;
nodeEnv: string;
databaseUrl: string;
frontendUrl: string;
jwtSecret: string;
jwtAccessExpiresIn: string;
jwtRefreshExpiresIn: string;
rateLimitMaxRequests: number;
csrfMaxRequests: number;
csrfSecret: string | null;
// Feature flags - all default to false for backward compatibility
enablePasswordReset: boolean;
enableRefreshTokenRotation: boolean;
enableAuditLogging: boolean;
}
const getRequiredEnv = (key: string): string => {
const value = process.env[key];
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
};
const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => {
const value = process.env[key];
if (!value) return defaultValue;
return value.toLowerCase() === "true" || value === "1";
};
const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
const value = process.env[key];
if (!value) return defaultValue;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid value for environment variable ${key}: must be a positive number`);
}
return parsed;
};
export const config: Config = {
port: getRequiredEnvNumber("PORT", 8000),
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
databaseUrl: getRequiredEnv("DATABASE_URL"),
frontendUrl: getOptionalEnv("FRONTEND_URL", "http://localhost:6767"),
jwtSecret: getRequiredEnv("JWT_SECRET"),
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60),
csrfSecret: process.env.CSRF_SECRET || null,
// Feature flags - disabled by default for backward compatibility
enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false),
enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", false),
enableAuditLogging: getOptionalBoolean("ENABLE_AUDIT_LOGGING", false),
};
// Validate JWT_SECRET strength in production
if (config.nodeEnv === "production") {
if (config.jwtSecret.length < 32) {
throw new Error("JWT_SECRET must be at least 32 characters long in production");
}
if (config.jwtSecret === "your-secret-key-change-in-production") {
throw new Error("JWT_SECRET must be changed from default value in production");
}
}
// Validate frontend URL format
try {
new URL(config.frontendUrl);
} catch {
throw new Error(`Invalid FRONTEND_URL format: ${config.frontendUrl}`);
}
console.log("Configuration validated successfully");
+731 -457
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
/**
* Authentication middleware for protecting routes
*/
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config";
import { PrismaClient } from "../generated/client";
const prisma = new PrismaClient();
// Extend Express Request type to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
name: string;
};
}
}
}
interface JwtPayload {
userId: string;
email: string;
type: "access" | "refresh";
}
/**
* Type guard to check if decoded JWT is our expected payload structure
*/
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
if (typeof decoded !== "object" || decoded === null) {
return false;
}
const payload = decoded as Record<string, unknown>;
return (
typeof payload.userId === "string" &&
typeof payload.email === "string" &&
(payload.type === "access" || payload.type === "refresh")
);
};
/**
* Extract JWT token from Authorization header
*/
const extractToken = (req: Request): string | null => {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") return null;
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
};
/**
* Verify and decode JWT token
*/
const verifyToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
if (!isJwtPayload(decoded)) {
return null;
}
if (decoded.type !== "access") {
return null; // Only accept access tokens in middleware
}
return decoded;
} catch {
return null;
}
};
/**
* Require authentication middleware
* Protects routes that require a valid JWT token
*/
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const token = extractToken(req);
if (!token) {
res.status(401).json({
error: "Unauthorized",
message: "Authentication token required",
});
return;
}
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({
error: "Unauthorized",
message: "Invalid or expired token",
});
return;
}
// Verify user still exists and is active
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, isActive: true },
});
if (!user || !user.isActive) {
res.status(401).json({
error: "Unauthorized",
message: "User account not found or inactive",
});
return;
}
// Attach user to request
req.user = {
id: user.id,
email: user.email,
name: user.name,
};
next();
} catch (error) {
console.error("Error verifying user:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to verify user",
});
}
};
/**
* Optional authentication middleware
* Attaches user to request if token is present, but doesn't require it
*/
export const optionalAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const token = extractToken(req);
if (!token) {
return next();
}
const payload = verifyToken(token);
if (!payload) {
return next();
}
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, isActive: true },
});
if (user && user.isActive) {
req.user = {
id: user.id,
email: user.email,
name: user.name,
};
}
} catch (error) {
// Silently fail for optional auth
console.error("Error in optional auth:", error);
}
next();
};
+86
View File
@@ -0,0 +1,86 @@
/**
* Error handling middleware
* Sanitizes error messages in production to prevent information leakage
*/
import { Request, Response, NextFunction } from "express";
import { config } from "../config";
export interface AppError extends Error {
statusCode?: number;
isOperational?: boolean;
}
/**
* Error handler middleware
* Should be added last in the middleware chain
*/
export const errorHandler = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
): void => {
const statusCode = err.statusCode || 500;
const isDevelopment = config.nodeEnv === "development";
// Log full error details server-side
console.error("Error:", {
message: err.message,
stack: err.stack,
statusCode,
path: req.path,
method: req.method,
timestamp: new Date().toISOString(),
});
// In production, don't expose internal error details
if (!isDevelopment) {
// Generic error messages for clients
if (statusCode >= 500) {
res.status(statusCode).json({
error: "Internal server error",
message: "An error occurred while processing your request",
});
return;
}
// For client errors (4xx), provide generic message
res.status(statusCode).json({
error: "Request error",
message: err.isOperational ? err.message : "Invalid request",
});
return;
}
// In development, show full error details
res.status(statusCode).json({
error: err.message,
stack: err.stack,
statusCode,
});
};
/**
* Async error wrapper
* Wraps async route handlers to catch errors
*/
export const asyncHandler = <T = void>(
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
) => {
return (req: Request, res: Response, next: NextFunction): void => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* Create an operational error (known error that can be safely shown to client)
*/
export const createError = (
message: string,
statusCode: number = 400
): AppError => {
const error: AppError = new Error(message);
error.statusCode = statusCode;
error.isOperational = true;
return error;
};
@@ -0,0 +1,92 @@
/**
* Data migration script for existing drawings and collections
* This script assigns existing data to a default user
* Run this if you have existing data before the auth migration
*/
import { PrismaClient } from '../generated/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function migrateExistingData() {
try {
console.log('Starting data migration...');
// Check if there are any drawings or collections without userId
// Note: After migration, userId is required, so this query is for pre-migration data
// We use a raw query or check for missing userId field
const allDrawings = await prisma.drawing.findMany({
select: { id: true, userId: true },
});
const drawingsWithoutUser = allDrawings.filter((d) => !d.userId);
const allCollections = await prisma.collection.findMany({
select: { id: true, userId: true },
});
const collectionsWithoutUser = allCollections.filter((c) => !c.userId);
if (drawingsWithoutUser.length === 0 && collectionsWithoutUser.length === 0) {
console.log('No data to migrate. All records already have userId.');
return;
}
console.log(`Found ${drawingsWithoutUser.length} drawings and ${collectionsWithoutUser.length} collections without userId`);
// Create a default migration user
const defaultEmail = 'migration@excalidash.local';
const defaultPassword = await bcrypt.hash('migration-temp-password-change-me', 10);
let migrationUser = await prisma.user.findUnique({
where: { email: defaultEmail },
});
if (!migrationUser) {
migrationUser = await prisma.user.create({
data: {
email: defaultEmail,
passwordHash: defaultPassword,
name: 'Migration User',
},
});
console.log('Created migration user:', migrationUser.id);
}
// Update collections
if (collectionsWithoutUser.length > 0) {
const collectionIds = collectionsWithoutUser.map((c) => c.id);
await prisma.collection.updateMany({
where: {
id: { in: collectionIds },
},
data: {
userId: migrationUser.id,
},
});
console.log(`Assigned ${collectionsWithoutUser.length} collections to migration user`);
}
// Update drawings
if (drawingsWithoutUser.length > 0) {
const drawingIds = drawingsWithoutUser.map((d) => d.id);
await prisma.drawing.updateMany({
where: {
id: { in: drawingIds },
},
data: {
userId: migrationUser.id,
},
});
console.log(`Assigned ${drawingsWithoutUser.length} drawings to migration user`);
}
console.log('Migration completed successfully!');
console.log(`⚠️ IMPORTANT: Change the password for user ${defaultEmail} or delete this user after assigning data to real users.`);
} catch (error) {
console.error('Migration failed:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
migrateExistingData();
+205
View File
@@ -0,0 +1,205 @@
/**
* Tests for audit logging utility
*
* These tests verify that audit logging works correctly when enabled
* and gracefully degrades when disabled or when tables don't exist.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { getTestPrisma, setupTestDb, initTestDb, createTestUser } from "../../__tests__/testUtils";
import { logAuditEvent, getAuditLogs, type AuditLogData } from "../audit";
describe("Audit Logging", () => {
const prisma = getTestPrisma();
let testUser: { id: string; email: string };
beforeAll(async () => {
setupTestDb();
testUser = await initTestDb(prisma);
// Enable audit logging for tests
process.env.ENABLE_AUDIT_LOGGING = "true";
});
afterAll(async () => {
await prisma.$disconnect();
delete process.env.ENABLE_AUDIT_LOGGING;
});
beforeEach(async () => {
// Clean up audit logs before each test
await prisma.auditLog.deleteMany({});
});
describe("logAuditEvent", () => {
it("should create an audit log entry when enabled", async () => {
const auditData: AuditLogData = {
userId: testUser.id,
action: "test_action",
resource: "test_resource",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
details: { test: "value" },
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { userId: testUser.id, action: "test_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].action).toBe("test_action");
expect(logs[0].resource).toBe("test_resource");
expect(logs[0].ipAddress).toBe("127.0.0.1");
expect(logs[0].userAgent).toBe("test-agent");
expect(logs[0].details).toBe(JSON.stringify({ test: "value" }));
});
it("should handle audit log without userId", async () => {
const auditData: AuditLogData = {
action: "anonymous_action",
ipAddress: "127.0.0.1",
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { action: "anonymous_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].userId).toBeNull();
});
it("should handle audit log without optional fields", async () => {
const auditData: AuditLogData = {
action: "minimal_action",
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { action: "minimal_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].resource).toBeNull();
expect(logs[0].ipAddress).toBeNull();
expect(logs[0].userAgent).toBeNull();
expect(logs[0].details).toBeNull();
});
it("should gracefully handle when feature is disabled", async () => {
// Note: Config is cached, so we test the graceful error handling instead
// by checking that errors don't propagate
const auditData: AuditLogData = {
action: "should_not_log_disabled",
};
// Should not throw even if feature is disabled or table missing
await expect(logAuditEvent(auditData)).resolves.not.toThrow();
});
it("should serialize details object to JSON", async () => {
const complexDetails = {
nested: { value: 123 },
array: [1, 2, 3],
string: "test",
};
await logAuditEvent({
userId: testUser.id,
action: "complex_details",
details: complexDetails,
});
const logs = await prisma.auditLog.findMany({
where: { action: "complex_details" },
});
expect(logs.length).toBe(1);
const parsed = JSON.parse(logs[0].details || "{}");
expect(parsed).toEqual(complexDetails);
});
});
describe("getAuditLogs", () => {
beforeEach(async () => {
// Create some test audit logs
await prisma.auditLog.createMany({
data: [
{
userId: testUser.id,
action: "action_1",
createdAt: new Date("2025-01-01T10:00:00Z"),
},
{
userId: testUser.id,
action: "action_2",
createdAt: new Date("2025-01-01T11:00:00Z"),
},
{
userId: testUser.id,
action: "action_3",
createdAt: new Date("2025-01-01T12:00:00Z"),
},
],
});
});
it("should retrieve audit logs for a specific user", async () => {
const logs = await getAuditLogs(testUser.id);
expect(logs.length).toBe(3);
expect(logs[0].action).toBe("action_3"); // Most recent first
expect(logs[1].action).toBe("action_2");
expect(logs[2].action).toBe("action_1");
});
it("should retrieve all audit logs when userId is not provided", async () => {
// Create a log for another user
const otherUser = await createTestUser(prisma, "other@example.com");
await prisma.auditLog.create({
data: {
userId: otherUser.id,
action: "other_action",
},
});
const logs = await getAuditLogs();
expect(logs.length).toBeGreaterThanOrEqual(4);
});
it("should respect limit parameter", async () => {
const logs = await getAuditLogs(testUser.id, 2);
expect(logs.length).toBe(2);
});
it("should parse details JSON in returned logs", async () => {
await prisma.auditLog.create({
data: {
userId: testUser.id,
action: "with_details",
details: JSON.stringify({ key: "value" }),
},
});
const logs = await getAuditLogs(testUser.id, 1);
expect(logs.length).toBe(1);
expect((logs[0] as { details: unknown }).details).toEqual({ key: "value" });
});
it("should include user information in logs", async () => {
const logs = await getAuditLogs(testUser.id, 1);
expect(logs.length).toBe(1);
const log = logs[0] as { user: { id: string; email: string; name: string } };
expect(log.user).toBeDefined();
expect(log.user.id).toBe(testUser.id);
expect(log.user.email).toBe(testUser.email);
});
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Audit logging utility for security events
*/
import { PrismaClient } from "../generated/client";
const prisma = new PrismaClient();
export interface AuditLogData {
userId?: string;
action: string;
resource?: string;
ipAddress?: string;
userAgent?: string;
details?: Record<string, unknown>;
}
/**
* Log a security event to the audit log
* This should be called for important security-related actions
* Gracefully handles missing audit log table (feature disabled)
*/
export const logAuditEvent = async (data: AuditLogData): Promise<void> => {
try {
// Check if audit logging is enabled via config
const { config } = await import("../config");
if (!config.enableAuditLogging) {
return; // Feature disabled, silently skip
}
await prisma.auditLog.create({
data: {
userId: data.userId || null,
action: data.action,
resource: data.resource || null,
ipAddress: data.ipAddress || null,
userAgent: data.userAgent || null,
details: data.details ? JSON.stringify(data.details) : null,
},
});
} catch (error) {
// Don't fail the request if audit logging fails
// This handles cases where the table doesn't exist (feature disabled)
// or other database errors
if (process.env.NODE_ENV === "development") {
console.debug("Audit logging skipped (feature disabled or table missing):", error);
}
}
};
/**
* Get audit logs for a user (or all users if userId is not provided)
* Returns empty array if audit logging is disabled or table doesn't exist
*/
export const getAuditLogs = async (
userId?: string,
limit: number = 100
): Promise<unknown[]> => {
try {
// Check if audit logging is enabled via config
const { config } = await import("../config");
if (!config.enableAuditLogging) {
return []; // Feature disabled, return empty array
}
const logs = await prisma.auditLog.findMany({
where: userId ? { userId } : undefined,
orderBy: { createdAt: "desc" },
take: limit,
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
return logs.map((log) => ({
...log,
details: log.details ? JSON.parse(log.details) : null,
}));
} catch (error) {
// Gracefully handle missing table or other errors
if (process.env.NODE_ENV === "development") {
console.debug("Failed to retrieve audit logs (feature disabled or table missing):", error);
}
return [];
}
};