Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a7915f8b | |||
| 2d51aa9d39 | |||
| 3f949252c1 |
@@ -1,69 +0,0 @@
|
|||||||
# Fork Summary
|
|
||||||
|
|
||||||
This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags.
|
|
||||||
|
|
||||||
## Security Features Added
|
|
||||||
|
|
||||||
1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`)
|
|
||||||
2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use
|
|
||||||
3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance
|
|
||||||
|
|
||||||
## UX Improvements Added
|
|
||||||
|
|
||||||
1. **Profile Page** - View and edit personal information, change password (`/profile`)
|
|
||||||
2. **Select All Button** - Quick selection of all drawings in current view
|
|
||||||
3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle
|
|
||||||
4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle)
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
✅ All security features disabled by default
|
|
||||||
✅ No breaking changes to existing code
|
|
||||||
✅ Graceful degradation (missing tables don't cause errors)
|
|
||||||
✅ Optional database migration
|
|
||||||
|
|
||||||
## Enable Security Features
|
|
||||||
|
|
||||||
Set in `backend/.env`:
|
|
||||||
```bash
|
|
||||||
ENABLE_PASSWORD_RESET=true
|
|
||||||
ENABLE_REFRESH_TOKEN_ROTATION=true
|
|
||||||
ENABLE_AUDIT_LOGGING=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run migration:
|
|
||||||
```bash
|
|
||||||
cd backend && npx prisma migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
**For base project:** Keep features disabled (default) - no migration needed, zero risk.
|
|
||||||
|
|
||||||
**For this fork:** Enable features via environment variables when ready.
|
|
||||||
|
|
||||||
## Database Changes
|
|
||||||
|
|
||||||
Migration adds 3 optional tables (only used when features enabled):
|
|
||||||
- `PasswordResetToken` - For password reset flow
|
|
||||||
- `RefreshToken` - For token rotation tracking
|
|
||||||
- `AuditLog` - For security event logging
|
|
||||||
|
|
||||||
## Code Changes
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- Feature flags in `backend/src/config.ts`
|
|
||||||
- Conditional logic in auth endpoints
|
|
||||||
- Graceful error handling for missing tables
|
|
||||||
- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST)
|
|
||||||
- Audit logging utility (`backend/src/utils/audit.ts`)
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- Password reset pages (`/reset-password`, `/reset-password-confirm`)
|
|
||||||
- Profile page (`/profile`)
|
|
||||||
- Select All button in Dashboard
|
|
||||||
- Sort dropdown with icons
|
|
||||||
- Auto-hide header in Editor with toggle
|
|
||||||
- Updated API client for token rotation
|
|
||||||
|
|
||||||
All changes are backward compatible and optional.
|
|
||||||
@@ -2,11 +2,4 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
FRONTEND_URL=http://localhost:6767
|
||||||
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
|
||||||
|
|
||||||
# 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
|
|
||||||
Generated
+9
-304
@@ -11,30 +11,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/ms": "^2.1.0",
|
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1012,15 +1001,6 @@
|
|||||||
"@types/readdir-glob": "*"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1121,16 +1101,6 @@
|
|||||||
"parse5": "^7.0.0"
|
"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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@@ -1144,12 +1114,6 @@
|
|||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
@@ -1266,12 +1230,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.0.15",
|
"version": "4.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
|
||||||
@@ -1664,20 +1622,6 @@
|
|||||||
"node": "^4.5.0 || >= 5.9"
|
"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": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.4.6",
|
"version": "12.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
|
||||||
@@ -1846,12 +1790,6 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -2282,9 +2220,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2345,15 +2283,6 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -2655,7 +2584,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz",
|
||||||
"integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==",
|
"integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -2694,24 +2622,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
@@ -3046,15 +2956,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||||
@@ -3153,12 +3054,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/immediate": {
|
|
||||||
"version": "3.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
|
||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -3171,15 +3066,6 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -3358,91 +3244,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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/jszip": {
|
|
||||||
"version": "3.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
|
||||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
|
||||||
"license": "(MIT OR GPL-3.0-or-later)",
|
|
||||||
"dependencies": {
|
|
||||||
"lie": "~3.3.0",
|
|
||||||
"pako": "~1.0.2",
|
|
||||||
"readable-stream": "~2.3.6",
|
|
||||||
"setimmediate": "^1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jszip/node_modules/readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jszip/node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/jszip/node_modules/string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": {
|
"node_modules/lazystream": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
@@ -3485,61 +3286,10 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lie": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"immediate": "~3.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
@@ -3817,26 +3567,6 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.11",
|
"version": "3.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||||
@@ -3940,12 +3670,6 @@
|
|||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
|
||||||
"license": "(MIT AND Zlib)"
|
|
||||||
},
|
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
@@ -4171,9 +3895,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -4460,12 +4184,6 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/setimmediate": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -5318,19 +5036,6 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/predev-migrate.cjs",
|
|
||||||
"dev": "nodemon src/index.ts",
|
"dev": "nodemon src/index.ts",
|
||||||
"admin:recover": "node scripts/admin-recover.cjs",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -18,30 +16,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/ms": "^2.1.0",
|
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
-- NOTE:
|
|
||||||
-- This migration assigns all pre-existing data to a bootstrap admin user so that
|
|
||||||
-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible.
|
|
||||||
-- The bootstrap admin user starts inactive and must be activated via the app's
|
|
||||||
-- initial registration flow.
|
|
||||||
|
|
||||||
-- Constants
|
|
||||||
-- Keep in sync with backend/src/auth.ts
|
|
||||||
-- (SQLite doesn't support variables; we inline the values instead.)
|
|
||||||
-- BOOTSTRAP_USER_ID = 'bootstrap-admin'
|
|
||||||
-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin'
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"username" TEXT,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"passwordHash" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL DEFAULT 'USER',
|
|
||||||
"mustResetPassword" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SystemConfig" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
|
|
||||||
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Bootstrap state:
|
|
||||||
-- - Insert a singleton config row (registration disabled by default)
|
|
||||||
-- - Insert an inactive bootstrap admin user and assign all existing data to it
|
|
||||||
INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt")
|
|
||||||
VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
|
||||||
|
|
||||||
INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt")
|
|
||||||
VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
|
||||||
|
|
||||||
-- 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", "userId", "updatedAt")
|
|
||||||
SELECT "createdAt", "id", "name", 'bootstrap-admin', "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", "userId", "updatedAt", "version")
|
|
||||||
SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "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
|
|
||||||
);
|
|
||||||
-- Migrate the singleton library to the bootstrap user's library key.
|
|
||||||
INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt")
|
|
||||||
SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default';
|
|
||||||
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");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
|
|
||||||
|
|
||||||
-- SQLite supports simple ADD COLUMN for non-null with default.
|
|
||||||
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-5
@@ -1,5 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1;
|
|
||||||
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000;
|
|
||||||
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20;
|
|
||||||
|
|
||||||
@@ -12,40 +12,9 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
username String? @unique
|
|
||||||
email String @unique
|
|
||||||
passwordHash String
|
|
||||||
name String
|
|
||||||
role String @default("USER")
|
|
||||||
mustResetPassword Boolean @default(false)
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
drawings Drawing[]
|
|
||||||
collections Collection[]
|
|
||||||
passwordResetTokens PasswordResetToken[]
|
|
||||||
refreshTokens RefreshToken[]
|
|
||||||
auditLogs AuditLog[]
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model SystemConfig {
|
|
||||||
id String @id @default("default")
|
|
||||||
authEnabled Boolean @default(false)
|
|
||||||
registrationEnabled Boolean @default(false)
|
|
||||||
authLoginRateLimitEnabled Boolean @default(true)
|
|
||||||
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
|
|
||||||
authLoginRateLimitMax Int @default(20)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Collection {
|
model Collection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -59,8 +28,6 @@ model Drawing {
|
|||||||
files String @default("{}") // Stored as JSON string
|
files String @default("{}") // Stored as JSON string
|
||||||
preview String? // SVG string for thumbnail
|
preview String? // SVG string for thumbnail
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
collectionId String?
|
collectionId String?
|
||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -68,40 +35,8 @@ model Drawing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
id String @id // User-specific library ID (e.g., "user_<userId>")
|
id String @id @default("default") // Singleton pattern - use "default" ID
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
items String @default("[]") // Stored as JSON string array of library items
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLI admin password recovery for ExcaliDash.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!"
|
|
||||||
* node scripts/admin-recover.cjs --identifier admin@example.com --generate
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db).
|
|
||||||
* - Sets the password hash and clears mustResetPassword by default.
|
|
||||||
* - If there are no active admins, this script can promote the target user to ADMIN.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require("dotenv").config();
|
|
||||||
|
|
||||||
const path = require("path");
|
|
||||||
process.env.DATABASE_URL =
|
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
`file:${path.resolve(__dirname, "../prisma/dev.db")}`;
|
|
||||||
|
|
||||||
const { PrismaClient } = require("../src/generated/client");
|
|
||||||
const bcrypt = require("bcrypt");
|
|
||||||
|
|
||||||
const parseArgs = (argv) => {
|
|
||||||
const args = {};
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i];
|
|
||||||
if (!token.startsWith("--")) continue;
|
|
||||||
const key = token.slice(2);
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (!next || next.startsWith("--")) {
|
|
||||||
args[key] = true;
|
|
||||||
} else {
|
|
||||||
args[key] = next;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatePassword = () => {
|
|
||||||
// 24 chars base64url-ish
|
|
||||||
const buf = require("crypto").randomBytes(18);
|
|
||||||
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
|
|
||||||
};
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
|
|
||||||
const identifier = typeof args.identifier === "string" ? args.identifier.trim() : "";
|
|
||||||
const providedPassword = typeof args.password === "string" ? args.password : null;
|
|
||||||
const generate = Boolean(args.generate);
|
|
||||||
const setMustReset = Boolean(args["must-reset"]);
|
|
||||||
const activate = Boolean(args.activate);
|
|
||||||
const promote = Boolean(args.promote);
|
|
||||||
const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]);
|
|
||||||
|
|
||||||
if (!identifier) {
|
|
||||||
console.error("Missing --identifier (email or username).");
|
|
||||||
process.exitCode = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newPassword = providedPassword;
|
|
||||||
if (!newPassword) {
|
|
||||||
if (!generate) {
|
|
||||||
console.error('Provide --password "<new password>" or pass --generate.');
|
|
||||||
process.exitCode = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newPassword = generatePassword();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
console.error("Password must be at least 8 characters.");
|
|
||||||
process.exitCode = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activeAdminCount = await prisma.user.count({
|
|
||||||
where: { role: "ADMIN", isActive: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const trimmed = identifier.toLowerCase();
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [{ email: trimmed }, { username: identifier }],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
username: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error("User not found:", identifier);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldPromote = promote || activeAdminCount === 0;
|
|
||||||
|
|
||||||
if (user.role !== "ADMIN" && !shouldPromote) {
|
|
||||||
console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user.");
|
|
||||||
console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins.");
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saltRounds = 10;
|
|
||||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
|
||||||
|
|
||||||
if (disableLoginRateLimit) {
|
|
||||||
await prisma.systemConfig.upsert({
|
|
||||||
where: { id: "default" },
|
|
||||||
update: { authLoginRateLimitEnabled: false },
|
|
||||||
create: {
|
|
||||||
id: "default",
|
|
||||||
authEnabled: true,
|
|
||||||
registrationEnabled: false,
|
|
||||||
authLoginRateLimitEnabled: false,
|
|
||||||
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
|
||||||
authLoginRateLimitMax: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
passwordHash,
|
|
||||||
mustResetPassword: setMustReset ? true : false,
|
|
||||||
isActive: activate ? true : user.isActive,
|
|
||||||
role: shouldPromote ? "ADMIN" : user.role,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
username: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Updated admin account:");
|
|
||||||
console.log(`- id: ${updated.id}`);
|
|
||||||
console.log(`- email: ${updated.email}`);
|
|
||||||
console.log(`- username: ${updated.username || ""}`);
|
|
||||||
console.log(`- isActive: ${updated.isActive}`);
|
|
||||||
console.log(`- mustResetPassword: ${updated.mustResetPassword}`);
|
|
||||||
console.log(`- role: ${updated.role}`);
|
|
||||||
if (disableLoginRateLimit) {
|
|
||||||
console.log("");
|
|
||||||
console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false).");
|
|
||||||
console.log("Remember to re-enable it from the Admin dashboard after you regain access.");
|
|
||||||
}
|
|
||||||
if (generate || !providedPassword) {
|
|
||||||
console.log("");
|
|
||||||
console.log("New password:");
|
|
||||||
console.log(newPassword);
|
|
||||||
} else {
|
|
||||||
console.log("");
|
|
||||||
console.log("Password updated.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Admin recovery failed:", err);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const backendRoot = path.resolve(__dirname, "..");
|
|
||||||
|
|
||||||
const resolveDatabaseUrl = (rawUrl) => {
|
|
||||||
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
|
||||||
|
|
||||||
if (!rawUrl || String(rawUrl).trim().length === 0) {
|
|
||||||
return `file:${defaultDbPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!String(rawUrl).startsWith("file:")) {
|
|
||||||
return String(rawUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = String(rawUrl).replace(/^file:/, "");
|
|
||||||
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
||||||
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
||||||
const hasLeadingPrismaDir =
|
|
||||||
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
|
||||||
|
|
||||||
const absolutePath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
|
||||||
|
|
||||||
return `file:${absolutePath}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const databaseUrl = resolveDatabaseUrl(process.env.DATABASE_URL);
|
|
||||||
process.env.DATABASE_URL = databaseUrl;
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || "development";
|
|
||||||
|
|
||||||
const runCapture = (cmd) => {
|
|
||||||
try {
|
|
||||||
const stdout = execSync(cmd, {
|
|
||||||
cwd: backendRoot,
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
||||||
});
|
|
||||||
return { ok: true, stdout: stdout || "", stderr: "" };
|
|
||||||
} catch (error) {
|
|
||||||
const err = error;
|
|
||||||
const stderr =
|
|
||||||
err && err.stderr
|
|
||||||
? Buffer.isBuffer(err.stderr)
|
|
||||||
? err.stderr.toString("utf8")
|
|
||||||
: String(err.stderr)
|
|
||||||
: "";
|
|
||||||
const stdout =
|
|
||||||
err && err.stdout
|
|
||||||
? Buffer.isBuffer(err.stdout)
|
|
||||||
? err.stdout.toString("utf8")
|
|
||||||
: String(err.stdout)
|
|
||||||
: "";
|
|
||||||
return { ok: false, stdout, stderr, error: err };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = (cmd) => {
|
|
||||||
execSync(cmd, {
|
|
||||||
cwd: backendRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDbFilePath = () => {
|
|
||||||
if (!databaseUrl.startsWith("file:")) return null;
|
|
||||||
return databaseUrl.replace(/^file:/, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const backupDbIfPresent = () => {
|
|
||||||
const dbPath = getDbFilePath();
|
|
||||||
if (!dbPath) return null;
|
|
||||||
if (!fs.existsSync(dbPath)) return null;
|
|
||||||
|
|
||||||
const dir = path.dirname(dbPath);
|
|
||||||
const base = path.basename(dbPath, path.extname(dbPath));
|
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
const backupPath = path.join(dir, `${base}.${stamp}.backup`);
|
|
||||||
|
|
||||||
fs.copyFileSync(dbPath, backupPath);
|
|
||||||
return backupPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNonProd = nodeEnv !== "production";
|
|
||||||
const isFileDb = databaseUrl.startsWith("file:");
|
|
||||||
|
|
||||||
const deploy = runCapture("npx prisma migrate deploy");
|
|
||||||
if (deploy.ok) {
|
|
||||||
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
|
||||||
} else {
|
|
||||||
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
|
||||||
if (deploy.stderr) process.stderr.write(deploy.stderr);
|
|
||||||
|
|
||||||
const stderr = deploy.stderr || "";
|
|
||||||
const isP3005 = stderr.includes("P3005");
|
|
||||||
|
|
||||||
// Common when an older dev.db exists but migrations weren't used previously.
|
|
||||||
if (isNonProd && isFileDb && isP3005) {
|
|
||||||
const backupPath = backupDbIfPresent();
|
|
||||||
console.warn(
|
|
||||||
`[predev] Prisma migrate baseline required (P3005). Resetting local SQLite database.\n` +
|
|
||||||
` DATABASE_URL=${databaseUrl}\n` +
|
|
||||||
(backupPath ? ` Backup: ${backupPath}\n` : "") +
|
|
||||||
` If you need to preserve local data, restore the backup and baseline manually.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
run("npx prisma migrate reset --force --skip-seed");
|
|
||||||
} else {
|
|
||||||
throw deploy.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -315,11 +315,10 @@ describe("Security Sanitization - Image Data URLs", () => {
|
|||||||
// Database integration tests
|
// Database integration tests
|
||||||
describe("Drawing API - Database Round-Trip", () => {
|
describe("Drawing API - Database Round-Trip", () => {
|
||||||
const prisma = getTestPrisma();
|
const prisma = getTestPrisma();
|
||||||
let testUser: { id: string };
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
setupTestDb();
|
setupTestDb();
|
||||||
testUser = await initTestDb(prisma);
|
await initTestDb(prisma);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -344,7 +343,6 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
userId: testUser.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -383,7 +381,6 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
userId: testUser.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,7 +404,6 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify({}),
|
files: JSON.stringify({}),
|
||||||
userId: testUser.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Security hardening tests
|
||||||
|
*
|
||||||
|
* Tests for input validation and sanitization improvements:
|
||||||
|
* - Route parameter ID validation
|
||||||
|
* - Collection name validation/sanitization
|
||||||
|
* - Library items validation
|
||||||
|
* - Socket.io input validation helpers
|
||||||
|
* - Path traversal protection in archive file names
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeText } from "../security";
|
||||||
|
|
||||||
|
// Replicate the validation functions from index.ts to test them in isolation
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
|
||||||
|
|
||||||
|
const isValidResourceId = (id: string): boolean => {
|
||||||
|
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Route Parameter ID Validation", () => {
|
||||||
|
it("should accept valid UUID v4", () => {
|
||||||
|
expect(isValidResourceId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||||
|
expect(isValidResourceId("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept safe alphanumeric IDs", () => {
|
||||||
|
expect(isValidResourceId("trash")).toBe(true);
|
||||||
|
expect(isValidResourceId("default")).toBe(true);
|
||||||
|
expect(isValidResourceId("my-collection-123")).toBe(true);
|
||||||
|
expect(isValidResourceId("element_1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with path traversal", () => {
|
||||||
|
expect(isValidResourceId("../etc/passwd")).toBe(false);
|
||||||
|
expect(isValidResourceId("..\\windows\\system32")).toBe(false);
|
||||||
|
expect(isValidResourceId("foo/bar")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with SQL injection attempts", () => {
|
||||||
|
expect(isValidResourceId("'; DROP TABLE drawings; --")).toBe(false);
|
||||||
|
expect(isValidResourceId("1 OR 1=1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject IDs with script injection", () => {
|
||||||
|
expect(isValidResourceId("<script>alert(1)</script>")).toBe(false);
|
||||||
|
expect(isValidResourceId('"><img src=x onerror=alert(1)>')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty or excessively long IDs", () => {
|
||||||
|
expect(isValidResourceId("")).toBe(false);
|
||||||
|
expect(isValidResourceId("a".repeat(129))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept IDs at maximum length", () => {
|
||||||
|
expect(isValidResourceId("a".repeat(128))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection Name Validation", () => {
|
||||||
|
it("should sanitize collection names with HTML", () => {
|
||||||
|
const result = sanitizeText('<script>alert("xss")</script>My Collection', 255);
|
||||||
|
expect(result).not.toContain("<script>");
|
||||||
|
expect(result).toContain("My Collection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve normal collection names", () => {
|
||||||
|
const result = sanitizeText("My Drawings Collection", 255);
|
||||||
|
expect(result).toBe("My Drawings Collection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should truncate overly long names", () => {
|
||||||
|
const longName = "A".repeat(300);
|
||||||
|
const result = sanitizeText(longName, 255);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should strip control characters", () => {
|
||||||
|
const result = sanitizeText("Name\x00With\x07Control\x1FChars", 255);
|
||||||
|
expect(result).not.toContain("\x00");
|
||||||
|
expect(result).not.toContain("\x07");
|
||||||
|
expect(result).not.toContain("\x1F");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Library Items Validation", () => {
|
||||||
|
it("should accept valid item counts", () => {
|
||||||
|
const items = Array.from({ length: 100 }, (_, i) => ({ id: `item-${i}` }));
|
||||||
|
expect(items.length).toBeLessThanOrEqual(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should flag excessive item counts", () => {
|
||||||
|
const items = Array.from({ length: 10001 }, (_, i) => ({ id: `item-${i}` }));
|
||||||
|
expect(items.length).toBeGreaterThan(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Archive Path Sanitization", () => {
|
||||||
|
const sanitizeArchiveName = (name: string): string => {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should replace path traversal sequences", () => {
|
||||||
|
const result = sanitizeArchiveName("../../etc/passwd");
|
||||||
|
expect(result).not.toContain("..");
|
||||||
|
expect(result).not.toContain("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should replace dangerous characters", () => {
|
||||||
|
const result = sanitizeArchiveName('my<drawing>:name/"test"\\path|file?name*');
|
||||||
|
expect(result).not.toContain("<");
|
||||||
|
expect(result).not.toContain(">");
|
||||||
|
expect(result).not.toContain(":");
|
||||||
|
expect(result).not.toContain('"');
|
||||||
|
expect(result).not.toContain("\\");
|
||||||
|
expect(result).not.toContain("|");
|
||||||
|
expect(result).not.toContain("?");
|
||||||
|
expect(result).not.toContain("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve normal names", () => {
|
||||||
|
const result = sanitizeArchiveName("My Drawing 2024");
|
||||||
|
expect(result).toBe("My Drawing 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle double-dot paths", () => {
|
||||||
|
const result = sanitizeArchiveName("..folder../..test..");
|
||||||
|
expect(result).not.toContain("..");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Socket.io Input Validation Helpers", () => {
|
||||||
|
const isValidDrawingId = (id: unknown): id is string =>
|
||||||
|
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
|
||||||
|
|
||||||
|
it("should accept valid drawing IDs", () => {
|
||||||
|
expect(isValidDrawingId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||||
|
expect(isValidDrawingId("my-drawing-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject non-string inputs", () => {
|
||||||
|
expect(isValidDrawingId(123)).toBe(false);
|
||||||
|
expect(isValidDrawingId(null)).toBe(false);
|
||||||
|
expect(isValidDrawingId(undefined)).toBe(false);
|
||||||
|
expect(isValidDrawingId({})).toBe(false);
|
||||||
|
expect(isValidDrawingId([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty strings", () => {
|
||||||
|
expect(isValidDrawingId("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject strings with injection attempts", () => {
|
||||||
|
expect(isValidDrawingId("<script>alert(1)</script>")).toBe(false);
|
||||||
|
expect(isValidDrawingId("../../../etc/passwd")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,53 +2,11 @@
|
|||||||
* Test utilities for backend integration tests
|
* Test utilities for backend integration tests
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
// Use a unique test database per test-file import to avoid cross-file contention
|
// Use a separate test database
|
||||||
// when Vitest runs test files in parallel.
|
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
|
||||||
const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`;
|
|
||||||
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME);
|
|
||||||
const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock");
|
|
||||||
|
|
||||||
const sleepSync = (ms: number) => {
|
|
||||||
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
||||||
Atomics.wait(shared, 0, 0, ms);
|
|
||||||
};
|
|
||||||
|
|
||||||
const withDbPushLock = (fn: () => void) => {
|
|
||||||
const start = Date.now();
|
|
||||||
let fd: number | null = null;
|
|
||||||
while (fd === null) {
|
|
||||||
try {
|
|
||||||
fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx");
|
|
||||||
fs.writeFileSync(fd, String(process.pid));
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as NodeJS.ErrnoException;
|
|
||||||
if (err.code !== "EEXIST") throw error;
|
|
||||||
if (Date.now() - start > 30_000) {
|
|
||||||
throw new Error("Timed out waiting for Prisma db push lock");
|
|
||||||
}
|
|
||||||
sleepSync(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(DB_PUSH_LOCK_PATH);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a test Prisma client pointing to the test database
|
* Get a test Prisma client pointing to the test database
|
||||||
@@ -74,19 +32,10 @@ export const setupTestDb = () => {
|
|||||||
|
|
||||||
// Run Prisma migrations to create the test database
|
// Run Prisma migrations to create the test database
|
||||||
try {
|
try {
|
||||||
withDbPushLock(() => {
|
execSync("npx prisma db push --skip-generate", {
|
||||||
execSync("npx prisma db push --skip-generate --force-reset", {
|
cwd: path.resolve(__dirname, "../../"),
|
||||||
cwd: path.resolve(__dirname, "../../"),
|
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||||
env: {
|
stdio: "pipe",
|
||||||
...process.env,
|
|
||||||
DATABASE_URL: databaseUrl,
|
|
||||||
// Work around Prisma schema engine failures on this repo's schema
|
|
||||||
// (seen as a blank "Schema engine error:" from `prisma db push`).
|
|
||||||
// `RUST_LOG=info` reliably avoids the failure mode.
|
|
||||||
RUST_LOG: "info",
|
|
||||||
},
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup test database:", error);
|
console.error("Failed to setup test database:", error);
|
||||||
@@ -105,42 +54,19 @@ 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
|
* Initialize test database with required data
|
||||||
*/
|
*/
|
||||||
export const initTestDb = async (prisma: PrismaClient) => {
|
export const initTestDb = async (prisma: PrismaClient) => {
|
||||||
// Create a test user first
|
|
||||||
const testUser = await createTestUser(prisma);
|
|
||||||
|
|
||||||
// Ensure Trash collection exists
|
// Ensure Trash collection exists
|
||||||
const trash = await prisma.collection.findUnique({
|
const trash = await prisma.collection.findUnique({
|
||||||
where: { id: "trash" },
|
where: { id: "trash" },
|
||||||
});
|
});
|
||||||
if (!trash) {
|
if (!trash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: { id: "trash", name: "Trash", userId: testUser.id },
|
data: { id: "trash", name: "Trash" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return testUser;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
/**
|
|
||||||
* Security tests for user data sandboxing
|
|
||||||
*
|
|
||||||
* Verifies that:
|
|
||||||
* 1. Drawings cache keys are scoped by userId (prevents cross-user data leakage)
|
|
||||||
* 2. Drawing CRUD operations enforce userId filtering
|
|
||||||
* 3. Collection operations enforce userId filtering
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
||||||
import bcrypt from "bcrypt";
|
|
||||||
import {
|
|
||||||
getTestPrisma,
|
|
||||||
cleanupTestDb,
|
|
||||||
setupTestDb,
|
|
||||||
createTestDrawingPayload,
|
|
||||||
} from "./testUtils";
|
|
||||||
import { PrismaClient } from "../generated/client";
|
|
||||||
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
|
|
||||||
// These tests verify the data isolation logic at the database query level
|
|
||||||
describe("User Data Sandboxing", () => {
|
|
||||||
let userA: { id: string; email: string };
|
|
||||||
let userB: { id: string; email: string };
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
setupTestDb();
|
|
||||||
prisma = getTestPrisma();
|
|
||||||
|
|
||||||
// Create two test users
|
|
||||||
const hashA = await bcrypt.hash("passwordA", 10);
|
|
||||||
const hashB = await bcrypt.hash("passwordB", 10);
|
|
||||||
|
|
||||||
userA = await prisma.user.upsert({
|
|
||||||
where: { email: "usera@test.com" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
email: "usera@test.com",
|
|
||||||
passwordHash: hashA,
|
|
||||||
name: "User A",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
userB = await prisma.user.upsert({
|
|
||||||
where: { email: "userb@test.com" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
email: "userb@test.com",
|
|
||||||
passwordHash: hashB,
|
|
||||||
name: "User B",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await prisma.drawing.deleteMany({});
|
|
||||||
await prisma.collection.deleteMany({});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Drawing isolation", () => {
|
|
||||||
it("should not return User A's drawings when querying as User B", async () => {
|
|
||||||
// Create a drawing for User A
|
|
||||||
await prisma.drawing.create({
|
|
||||||
data: {
|
|
||||||
name: "User A Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
userId: userA.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query as User B - should get 0 results
|
|
||||||
const userBDrawings = await prisma.drawing.findMany({
|
|
||||||
where: { userId: userB.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(userBDrawings).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only return the owning user's drawings", async () => {
|
|
||||||
// Create drawings for both users
|
|
||||||
await prisma.drawing.create({
|
|
||||||
data: {
|
|
||||||
name: "User A Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
userId: userA.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.drawing.create({
|
|
||||||
data: {
|
|
||||||
name: "User B Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
userId: userB.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userADrawings = await prisma.drawing.findMany({
|
|
||||||
where: { userId: userA.id },
|
|
||||||
});
|
|
||||||
const userBDrawings = await prisma.drawing.findMany({
|
|
||||||
where: { userId: userB.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(userADrawings).toHaveLength(1);
|
|
||||||
expect(userADrawings[0].name).toBe("User A Drawing");
|
|
||||||
|
|
||||||
expect(userBDrawings).toHaveLength(1);
|
|
||||||
expect(userBDrawings[0].name).toBe("User B Drawing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not allow User B to access User A's drawing by ID", async () => {
|
|
||||||
const drawing = await prisma.drawing.create({
|
|
||||||
data: {
|
|
||||||
name: "User A Secret Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
userId: userA.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate the findFirst query used in GET /drawings/:id
|
|
||||||
const result = await prisma.drawing.findFirst({
|
|
||||||
where: {
|
|
||||||
id: drawing.id,
|
|
||||||
userId: userB.id, // User B trying to access
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Collection isolation", () => {
|
|
||||||
it("should not return User A's collections when querying as User B", async () => {
|
|
||||||
await prisma.collection.create({
|
|
||||||
data: {
|
|
||||||
name: "User A Collection",
|
|
||||||
userId: userA.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userBCollections = await prisma.collection.findMany({
|
|
||||||
where: { userId: userB.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(userBCollections).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not allow User B to modify User A's collection", async () => {
|
|
||||||
const collection = await prisma.collection.create({
|
|
||||||
data: {
|
|
||||||
name: "User A Collection",
|
|
||||||
userId: userA.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate the findFirst query used in PUT /collections/:id
|
|
||||||
const result = await prisma.collection.findFirst({
|
|
||||||
where: {
|
|
||||||
id: collection.id,
|
|
||||||
userId: userB.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Cache key user scoping", () => {
|
|
||||||
it("should generate different cache keys for different users with same query params", () => {
|
|
||||||
// This tests the buildDrawingsCacheKey function logic inline
|
|
||||||
// The function was updated to include userId in the cache key
|
|
||||||
const buildDrawingsCacheKey = (keyParts: {
|
|
||||||
userId: string;
|
|
||||||
searchTerm: string;
|
|
||||||
collectionFilter: string;
|
|
||||||
includeData: boolean;
|
|
||||||
}) =>
|
|
||||||
JSON.stringify([
|
|
||||||
keyParts.userId,
|
|
||||||
keyParts.searchTerm,
|
|
||||||
keyParts.collectionFilter,
|
|
||||||
keyParts.includeData ? "full" : "summary",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const keyA = buildDrawingsCacheKey({
|
|
||||||
userId: "user-a-id",
|
|
||||||
searchTerm: "",
|
|
||||||
collectionFilter: "default",
|
|
||||||
includeData: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const keyB = buildDrawingsCacheKey({
|
|
||||||
userId: "user-b-id",
|
|
||||||
searchTerm: "",
|
|
||||||
collectionFilter: "default",
|
|
||||||
includeData: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(keyA).not.toBe(keyB);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should generate same cache key for same user with same query params", () => {
|
|
||||||
const buildDrawingsCacheKey = (keyParts: {
|
|
||||||
userId: string;
|
|
||||||
searchTerm: string;
|
|
||||||
collectionFilter: string;
|
|
||||||
includeData: boolean;
|
|
||||||
}) =>
|
|
||||||
JSON.stringify([
|
|
||||||
keyParts.userId,
|
|
||||||
keyParts.searchTerm,
|
|
||||||
keyParts.collectionFilter,
|
|
||||||
keyParts.includeData ? "full" : "summary",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const key1 = buildDrawingsCacheKey({
|
|
||||||
userId: "same-user",
|
|
||||||
searchTerm: "test",
|
|
||||||
collectionFilter: "default",
|
|
||||||
includeData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const key2 = buildDrawingsCacheKey({
|
|
||||||
userId: "same-user",
|
|
||||||
searchTerm: "test",
|
|
||||||
collectionFilter: "default",
|
|
||||||
includeData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(key1).toBe(key2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-2319
File diff suppressed because it is too large
Load Diff
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* Configuration validation and environment variable management
|
|
||||||
*/
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
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 resolveJwtSecret = (nodeEnv: string): string => {
|
|
||||||
const provided = process.env.JWT_SECRET;
|
|
||||||
if (provided && provided.trim().length > 0) {
|
|
||||||
return provided;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodeEnv === "production") {
|
|
||||||
throw new Error("Missing required environment variable: JWT_SECRET");
|
|
||||||
}
|
|
||||||
|
|
||||||
const generated = crypto.randomBytes(32).toString("hex");
|
|
||||||
console.warn(
|
|
||||||
"[security] JWT_SECRET is not set (non-production). Using an ephemeral secret; tokens will be invalidated on restart."
|
|
||||||
);
|
|
||||||
return generated;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseFrontendUrl = (raw: string | undefined): string | undefined => {
|
|
||||||
if (!raw || raw.trim().length === 0) return undefined;
|
|
||||||
const normalized = raw
|
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0)
|
|
||||||
.join(",");
|
|
||||||
return normalized.length > 0 ? normalized : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveDatabaseUrl = (rawUrl?: string) => {
|
|
||||||
const backendRoot = path.resolve(__dirname, "../");
|
|
||||||
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
|
||||||
|
|
||||||
if (!rawUrl || rawUrl.trim().length === 0) {
|
|
||||||
return `file:${defaultDbPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawUrl.startsWith("file:")) {
|
|
||||||
return rawUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = rawUrl.replace(/^file:/, "");
|
|
||||||
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
||||||
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
||||||
const hasLeadingPrismaDir =
|
|
||||||
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
|
||||||
|
|
||||||
const absolutePath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
|
||||||
|
|
||||||
return `file:${absolutePath}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure DATABASE_URL is resolved before any PrismaClient is created.
|
|
||||||
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
|
||||||
|
|
||||||
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: process.env.DATABASE_URL,
|
|
||||||
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
|
|
||||||
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Configuration validated successfully");
|
|
||||||
+693
-1477
File diff suppressed because it is too large
Load Diff
@@ -1,341 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
|
||||||
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
|
||||||
|
|
||||||
type AuthEnabledCache = {
|
|
||||||
value: boolean;
|
|
||||||
fetchedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let authEnabledCache: AuthEnabledCache | null = null;
|
|
||||||
const AUTH_ENABLED_TTL_MS = 0;
|
|
||||||
|
|
||||||
const getAuthEnabled = async (): Promise<boolean> => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (authEnabledCache && now - authEnabledCache.fetchedAt < AUTH_ENABLED_TTL_MS) {
|
|
||||||
return authEnabledCache.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemConfig = await prisma.systemConfig.upsert({
|
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
|
||||||
authEnabled: false,
|
|
||||||
registrationEnabled: false,
|
|
||||||
},
|
|
||||||
select: { authEnabled: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
|
|
||||||
return systemConfig.authEnabled;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBootstrapActingUser = async () => {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: BOOTSTRAP_USER_ID },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user) return user;
|
|
||||||
|
|
||||||
return prisma.user.create({
|
|
||||||
data: {
|
|
||||||
id: BOOTSTRAP_USER_ID,
|
|
||||||
email: "bootstrap@excalidash.local",
|
|
||||||
username: null,
|
|
||||||
passwordHash: "",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
role: "ADMIN",
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extend Express Request type to include user
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
username?: string | null;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
mustResetPassword?: boolean;
|
|
||||||
impersonatorId?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JwtPayload {
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
type: "access" | "refresh";
|
|
||||||
impersonatorId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>;
|
|
||||||
const impersonatorOk =
|
|
||||||
typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string";
|
|
||||||
return (
|
|
||||||
typeof payload.userId === "string" &&
|
|
||||||
typeof payload.email === "string" &&
|
|
||||||
(payload.type === "access" || payload.type === "refresh") &&
|
|
||||||
impersonatorOk
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeRequestPath = (req: Request): string => {
|
|
||||||
const raw = (req.originalUrl || req.url || "").split("?")[0] || "";
|
|
||||||
// In some deployments the backend may see a /api prefix.
|
|
||||||
return raw.replace(/^\/api(?=\/)/, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAllowedWhileMustResetPassword = (req: Request): boolean => {
|
|
||||||
const path = normalizeRequestPath(req);
|
|
||||||
|
|
||||||
// Permit fetching current user and changing password.
|
|
||||||
if (req.method === "GET" && path === "/auth/me") return true;
|
|
||||||
if (req.method === "POST" && path === "/auth/change-password") return true;
|
|
||||||
if (req.method === "POST" && path === "/auth/must-reset-password") return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require authentication middleware
|
|
||||||
* Protects routes that require a valid JWT token
|
|
||||||
*/
|
|
||||||
export const requireAuth = async (
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<void> => {
|
|
||||||
// Single-user mode: authentication disabled -> treat all requests as the bootstrap user.
|
|
||||||
try {
|
|
||||||
const authEnabled = await getAuthEnabled();
|
|
||||||
if (!authEnabled) {
|
|
||||||
const user = await getBootstrapActingUser();
|
|
||||||
req.user = {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
mustResetPassword: user.mustResetPassword,
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading auth mode:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: "Internal server error",
|
|
||||||
message: "Failed to read authentication mode",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
res.status(401).json({
|
|
||||||
error: "Unauthorized",
|
|
||||||
message: "User account not found or inactive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) {
|
|
||||||
res.status(403).json({
|
|
||||||
error: "Forbidden",
|
|
||||||
code: "MUST_RESET_PASSWORD",
|
|
||||||
message: "You must reset your password before using the app",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach user to request
|
|
||||||
req.user = {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
mustResetPassword: user.mustResetPassword,
|
|
||||||
impersonatorId: payload.impersonatorId,
|
|
||||||
};
|
|
||||||
|
|
||||||
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> => {
|
|
||||||
try {
|
|
||||||
const authEnabled = await getAuthEnabled();
|
|
||||||
if (!authEnabled) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading auth mode:", error);
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user && user.isActive) {
|
|
||||||
req.user = {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
mustResetPassword: user.mustResetPassword,
|
|
||||||
impersonatorId: payload.impersonatorId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail for optional auth
|
|
||||||
console.error("Error in optional auth:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
+16
-30
@@ -30,9 +30,7 @@ let activeConfig: SecurityConfig = { ...defaultConfig };
|
|||||||
* Configure security settings
|
* Configure security settings
|
||||||
* @param config Partial configuration to merge with defaults
|
* @param config Partial configuration to merge with defaults
|
||||||
*/
|
*/
|
||||||
export const configureSecuritySettings = (
|
export const configureSecuritySettings = (config: Partial<SecurityConfig>): void => {
|
||||||
config: Partial<SecurityConfig>
|
|
||||||
): void => {
|
|
||||||
activeConfig = { ...activeConfig, ...config };
|
activeConfig = { ...activeConfig, ...config };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,13 +318,10 @@ export const appStateSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
currentItemRoundness: z
|
currentItemRoundness: z
|
||||||
.union([
|
.object({
|
||||||
z.enum(["sharp", "round"]),
|
type: z.enum(["round", "sharp"]),
|
||||||
z.object({
|
value: z.number().finite().min(0).max(1),
|
||||||
type: z.enum(["round", "sharp"]),
|
})
|
||||||
value: z.number().finite().min(0).max(1),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
currentItemFontSize: z
|
currentItemFontSize: z
|
||||||
@@ -432,19 +427,10 @@ export const sanitizeDrawingData = (data: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Dangerous URL protocols to block entirely
|
// Dangerous URL protocols to block entirely
|
||||||
const dangerousProtocols = [
|
const dangerousProtocols = [/^javascript:/i, /^vbscript:/i, /^data:text\/html/i];
|
||||||
/^javascript:/i,
|
|
||||||
/^vbscript:/i,
|
|
||||||
/^data:text\/html/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Suspicious patterns for security validation within data URLs
|
// Suspicious patterns for security validation within data URLs
|
||||||
const suspiciousPatterns = [
|
const suspiciousPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i];
|
||||||
/<script/i,
|
|
||||||
/javascript:/i,
|
|
||||||
/on\w+\s*=/i,
|
|
||||||
/<iframe/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Maximum size for dataURL (configurable, default 10MB to prevent DoS)
|
// Maximum size for dataURL (configurable, default 10MB to prevent DoS)
|
||||||
const MAX_DATAURL_SIZE = activeConfig.maxDataUrlSize;
|
const MAX_DATAURL_SIZE = activeConfig.maxDataUrlSize;
|
||||||
@@ -462,8 +448,8 @@ export const sanitizeDrawingData = (data: {
|
|||||||
const normalizedValue = value.toLowerCase();
|
const normalizedValue = value.toLowerCase();
|
||||||
|
|
||||||
// First, check for dangerous protocols - block these entirely
|
// First, check for dangerous protocols - block these entirely
|
||||||
const hasDangerousProtocol = dangerousProtocols.some(
|
const hasDangerousProtocol = dangerousProtocols.some((pattern) =>
|
||||||
(pattern) => pattern.test(value)
|
pattern.test(value)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasDangerousProtocol) {
|
if (hasDangerousProtocol) {
|
||||||
@@ -479,8 +465,8 @@ export const sanitizeDrawingData = (data: {
|
|||||||
|
|
||||||
if (isSafeImageType) {
|
if (isSafeImageType) {
|
||||||
// Check for suspicious content and size limits
|
// Check for suspicious content and size limits
|
||||||
const hasSuspiciousContent = suspiciousPatterns.some(
|
const hasSuspiciousContent = suspiciousPatterns.some((pattern) =>
|
||||||
(pattern) => pattern.test(value)
|
pattern.test(value)
|
||||||
);
|
);
|
||||||
const isTooLarge = value.length > MAX_DATAURL_SIZE;
|
const isTooLarge = value.length > MAX_DATAURL_SIZE;
|
||||||
|
|
||||||
@@ -584,11 +570,11 @@ const getCsrfSecret = (): Buffer => {
|
|||||||
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
||||||
console.warn(
|
console.warn(
|
||||||
`[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
|
`[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
|
||||||
`Using an ephemeral per-process secret.\n` +
|
`Using an ephemeral per-process secret.\n` +
|
||||||
` - Tokens will expire on container restart\n` +
|
` - Tokens will expire on container restart\n` +
|
||||||
` - Horizontal scaling (k8s) will NOT work\n` +
|
` - Horizontal scaling (k8s) will NOT work\n` +
|
||||||
` - Generate a secret: openssl rand -base64 32\n` +
|
` - Generate a secret: openssl rand -base64 32\n` +
|
||||||
` - Set environment variable: CSRF_SECRET=<generated-secret>`
|
` - Set environment variable: CSRF_SECRET=<generated-secret>`
|
||||||
);
|
);
|
||||||
return cachedCsrfSecret;
|
return cachedCsrfSecret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audit logging utility for security events
|
|
||||||
*/
|
|
||||||
import { PrismaClient } from "../generated/client";
|
|
||||||
|
|
||||||
let prisma: PrismaClient | null = null;
|
|
||||||
const getPrisma = () => {
|
|
||||||
if (prisma) return prisma;
|
|
||||||
prisma = new PrismaClient();
|
|
||||||
return prisma;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AuditLogData {
|
|
||||||
userId?: string;
|
|
||||||
action: string;
|
|
||||||
resource?: string;
|
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditLogResult {
|
|
||||||
id: string;
|
|
||||||
userId: string | null;
|
|
||||||
action: string;
|
|
||||||
resource: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
userAgent: string | null;
|
|
||||||
details: unknown | null;
|
|
||||||
createdAt: Date;
|
|
||||||
user: { id: string; email: string; name: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 getPrisma().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<AuditLogResult[]> => {
|
|
||||||
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 getPrisma().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: (() => {
|
|
||||||
if (!log.details) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(log.details) as unknown;
|
|
||||||
} catch {
|
|
||||||
return 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 [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -8,8 +8,6 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
# Required for authentication: set a strong secret (min 32 chars)
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production-min-32-chars}
|
|
||||||
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
# - CSRF_SECRET=${CSRF_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -174,37 +174,6 @@ export async function getDrawing(
|
|||||||
return (await response.json()) as DrawingRecord;
|
return (await response.json()) as DrawingRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDrawing(
|
|
||||||
request: APIRequestContext,
|
|
||||||
id: string,
|
|
||||||
data: Partial<DrawingRecord>
|
|
||||||
): Promise<DrawingRecord> {
|
|
||||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
|
||||||
|
|
||||||
let response = await request.put(`${API_URL}/drawings/${id}`, {
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok() && response.status() === 403) {
|
|
||||||
await refreshCsrfInfo(request);
|
|
||||||
const retryHeaders = await withCsrfHeaders(request, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
response = await request.put(`${API_URL}/drawings/${id}`, {
|
|
||||||
headers: retryHeaders,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok()) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Failed to update drawing ${id}: ${response.status()} ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await response.json()) as DrawingRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDrawing(
|
export async function deleteDrawing(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user