Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbb23ca661 | |||
| f214e4f7b7 | |||
| 7aa33a1bdf | |||
| ea06cd9175 | |||
| 734f0a292d | |||
| 08135ee36a | |||
| f462b2e288 | |||
| 01fda32bcd | |||
| 94694deb91 | |||
| ef75f9ebdf | |||
| 5e782e4044 | |||
| 0253ebb6b8 | |||
| 1e617025df | |||
| e4941ad77f | |||
| 2e370f9821 | |||
| b075a0cf9e | |||
| 7977a3eb09 | |||
| 40a645b823 | |||
| dd966f6d01 | |||
| d832e55dfd | |||
| 887818c9b4 | |||
| bc13cc3483 | |||
| da299d00d5 | |||
| 302d9bd94b | |||
| d68fe6a2c0 | |||
| 7a54123e93 | |||
| 75a1f11a96 | |||
| 700e153740 | |||
| fd3b97225f | |||
| 0d1fe8e0e5 | |||
| b6d0150d44 | |||
| 55cd816cca | |||
| d67bd1daf8 | |||
| 4b56d3cfc6 | |||
| 88ed4360c0 | |||
| 7dfa69de2a | |||
| 4f53b899c9 | |||
| 9fe3a2193d | |||
| 804adb7347 | |||
| 9c6b7dd727 | |||
| f6e337aa98 | |||
| cbe83efe1f | |||
| 112d58a92a | |||
| b834f777b5 | |||
| 5f476542e2 | |||
| f1a1ff3a8a | |||
| 29af9fac62 | |||
| 2998fad8e7 | |||
| b6e9514eb3 | |||
| b175706da1 | |||
| 381dd95543 | |||
| 78ab52b762 | |||
| d9013b8f7a | |||
| 5d29cd919d | |||
| 9170930e8e | |||
| f7c9a1ab80 | |||
| af07a73a07 | |||
| 865285fbb7 | |||
| 77c22916a8 | |||
| 08d1479a01 | |||
| 7ea1c3ebf0 | |||
| 81918b00cd | |||
| 3b384dc5fb | |||
| 5d819b0234 | |||
| 260a898e3e | |||
| 15ac634d15 | |||
| 1a52fe80f3 | |||
| 20ef4ee295 | |||
| d1dbde95e4 |
@@ -1,44 +0,0 @@
|
|||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Start backend server in background
|
# Start backend server in background
|
||||||
cd backend
|
cd backend
|
||||||
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:6767" npm run dev &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
# Wait for frontend to be ready
|
# Wait for frontend to be ready
|
||||||
echo "Waiting for frontend server..."
|
echo "Waiting for frontend server..."
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
if curl -s http://localhost:5173 > /dev/null; then
|
if curl -s http://localhost:6767 > /dev/null; then
|
||||||
echo "Frontend is ready!"
|
echo "Frontend is ready!"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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.
|
||||||
@@ -511,6 +511,17 @@ release-docker: ## Build and push release Docker images
|
|||||||
pre-release-docker: ## Build and push pre-release Docker images
|
pre-release-docker: ## Build and push pre-release Docker images
|
||||||
./publish-docker-prerelease.sh
|
./publish-docker-prerelease.sh
|
||||||
|
|
||||||
|
dev-release: ## Build and push custom dev release (usage: make dev-release NAME=issue38)
|
||||||
|
@if [ -z "$(NAME)" ]; then \
|
||||||
|
echo "$(RED)ERROR: NAME parameter is required!$(NC)"; \
|
||||||
|
echo "$(YELLOW)Usage: make dev-release NAME=<custom-name>$(NC)"; \
|
||||||
|
echo "$(YELLOW)Example: make dev-release NAME=issue38$(NC)"; \
|
||||||
|
echo "$(YELLOW) This will create tags like: 0.3.1-dev-issue38$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(BLUE)Building custom dev release: $(NAME)$(NC)"
|
||||||
|
@./publish-docker-dev.sh $(NAME)
|
||||||
|
|
||||||
#===============================================================================
|
#===============================================================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
#===============================================================================
|
#===============================================================================
|
||||||
|
|||||||
@@ -120,14 +120,17 @@ docker compose up -d
|
|||||||
|
|
||||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
||||||
|
|
||||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks.
|
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml example
|
# docker-compose.yml example
|
||||||
backend:
|
backend:
|
||||||
environment:
|
environment:
|
||||||
|
# Single URL
|
||||||
- FRONTEND_URL=https://excalidash.example.com
|
- FRONTEND_URL=https://excalidash.example.com
|
||||||
|
# Or multiple URLs (comma-separated) for local + network access
|
||||||
|
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||||
frontend:
|
frontend:
|
||||||
environment:
|
environment:
|
||||||
# For standard Docker Compose (default)
|
# For standard Docker Compose (default)
|
||||||
|
|||||||
+6
-40
@@ -1,43 +1,9 @@
|
|||||||
CSRF Protection (8a78b2b)
|
Multi user setup is opt-in, single user by default
|
||||||
|
|
||||||
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
|
Multi-user support for excalidash
|
||||||
- Added new backend/src/security.ts module for security utilities
|
- Admin dashboard
|
||||||
- Frontend API layer now handles CSRF tokens automatically
|
- Password reset, force user password reset (admin only), account lockout recovery
|
||||||
- Added integration tests for CSRF validation
|
- Rate limits
|
||||||
|
|
||||||
Upload Progress Indicator (8f9b9b4)
|
Deprecates .json and .sqlite database backups in favor of .excalidash archives (user scoped, prevents exporting of senstive information). Legacy import is maintained.
|
||||||
|
|
||||||
- Added a visual upload progress bar when users upload files
|
|
||||||
- New UploadContext for managing upload state across components
|
|
||||||
- New UploadStatus component displaying real-time upload progress
|
|
||||||
- Save status indicator when navigating back from the editor
|
|
||||||
- Improved error handling and recovery for failed uploads
|
|
||||||
|
|
||||||
Bug Fixes
|
|
||||||
|
|
||||||
- Fixed broken e2e tests (cae8f3c)
|
|
||||||
- Replaced deprecated substr() with substring()
|
|
||||||
- Fixed stale state issues in error handling
|
|
||||||
- Fixed missing useEffect dependencies
|
|
||||||
- Fixed CSS class conflicts in progress bar styling
|
|
||||||
- Added error recovery for save state in Editor
|
|
||||||
|
|
||||||
Infrastructure
|
|
||||||
|
|
||||||
- Updated docker-compose configurations with new environment variables
|
|
||||||
- E2E test suite improvements and reliability fixes
|
|
||||||
- Added Kubernetes deployment note in README
|
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
|
|
||||||
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
|
|
||||||
Add it to your deployment:
|
|
||||||
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
|
|
||||||
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
|
|
||||||
|
|
||||||
If not set, the backend will refuse to start.
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -3,3 +3,10 @@ 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
+306
-11
@@ -1,29 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"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": {
|
||||||
@@ -1001,6 +1012,15 @@
|
|||||||
"@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",
|
||||||
@@ -1101,6 +1121,16 @@
|
|||||||
"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",
|
||||||
@@ -1114,6 +1144,12 @@
|
|||||||
"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",
|
||||||
@@ -1230,6 +1266,12 @@
|
|||||||
"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",
|
||||||
@@ -1622,6 +1664,20 @@
|
|||||||
"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",
|
||||||
@@ -1790,6 +1846,12 @@
|
|||||||
"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",
|
||||||
@@ -2220,9 +2282,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2283,6 +2345,15 @@
|
|||||||
"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",
|
||||||
@@ -2584,6 +2655,7 @@
|
|||||||
"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",
|
||||||
@@ -2622,6 +2694,24 @@
|
|||||||
"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",
|
||||||
@@ -2956,6 +3046,15 @@
|
|||||||
"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",
|
||||||
@@ -3054,6 +3153,12 @@
|
|||||||
"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",
|
||||||
@@ -3066,6 +3171,15 @@
|
|||||||
"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",
|
||||||
@@ -3244,6 +3358,91 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -3286,10 +3485,61 @@
|
|||||||
"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.21",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
|
"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": {
|
||||||
@@ -3567,6 +3817,26 @@
|
|||||||
"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",
|
||||||
@@ -3670,6 +3940,12 @@
|
|||||||
"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",
|
||||||
@@ -3895,9 +4171,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -4184,6 +4460,12 @@
|
|||||||
"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",
|
||||||
@@ -5036,6 +5318,19 @@
|
|||||||
"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",
|
||||||
|
|||||||
+14
-1
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"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"
|
||||||
@@ -16,19 +18,30 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- 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
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resource" TEXT,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"details" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 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
@@ -0,0 +1,5 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Improve dashboard query performance for user-scoped collection and drawing listings.
|
||||||
|
CREATE INDEX IF NOT EXISTS "Collection_userId_updatedAt_idx"
|
||||||
|
ON "Collection" ("userId", "updatedAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Drawing_userId_updatedAt_idx"
|
||||||
|
ON "Drawing" ("userId", "updatedAt");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Drawing_userId_collectionId_updatedAt_idx"
|
||||||
|
ON "Drawing" ("userId", "collectionId", "updatedAt");
|
||||||
@@ -12,12 +12,45 @@ 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
|
||||||
|
|
||||||
|
@@index([userId, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Drawing {
|
model Drawing {
|
||||||
@@ -28,15 +61,52 @@ 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())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId, updatedAt])
|
||||||
|
@@index([userId, collectionId, updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Issue #38: CSRF fails with multiple reverse proxies
|
||||||
|
*
|
||||||
|
* This test demonstrates how trust proxy settings affect CSRF validation
|
||||||
|
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import {
|
||||||
|
createCsrfToken,
|
||||||
|
validateCsrfToken,
|
||||||
|
getCsrfTokenHeader,
|
||||||
|
} from "../security";
|
||||||
|
|
||||||
|
// mock the getClientId function behavior
|
||||||
|
const getClientIdFromRequest = (req: express.Request): string => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Issue #38: CSRF with trust proxy settings", () => {
|
||||||
|
let app: express.Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the trust proxy issue with multiple proxies", async () => {
|
||||||
|
// ext proxy -> frontend nginx -> backend
|
||||||
|
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
|
||||||
|
|
||||||
|
// With trust proxy: 1 (current setting)
|
||||||
|
const app1 = express();
|
||||||
|
app1.set("trust proxy", 1);
|
||||||
|
app1.use(express.json());
|
||||||
|
|
||||||
|
app1.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate request through multiple proxies
|
||||||
|
const response1 = await request(app1)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
|
||||||
|
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
|
||||||
|
expect(response1.body.ip).toBe("172.17.0.3");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: 1 → IP:",
|
||||||
|
response1.body.ip,
|
||||||
|
"(not the real client IP)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// With trust proxy: true
|
||||||
|
const app2 = express();
|
||||||
|
app2.set("trust proxy", true);
|
||||||
|
app2.use(express.json());
|
||||||
|
|
||||||
|
app2.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response2 = await request(app2)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: true, Express takes leftmost IP
|
||||||
|
expect(response2.body.ip).toBe("203.0.113.42");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: true → IP:",
|
||||||
|
response2.body.ip,
|
||||||
|
"(real client IP - CORRECT)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simulates CSRF failure scenario from issue #38", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
|
||||||
|
// Request 1: Fetch CSRF token
|
||||||
|
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
|
||||||
|
const clientIp1 = "203.0.113.42";
|
||||||
|
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
|
||||||
|
|
||||||
|
// With trust proxy: 1, Express sees the external proxy IP
|
||||||
|
const clientId1 = `${externalProxyIp1}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp1);
|
||||||
|
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// Request 2: Try to create drawing with token
|
||||||
|
// External proxy IP might differ slightly
|
||||||
|
const externalProxyIp2 = "10.0.0.6";
|
||||||
|
|
||||||
|
const clientId2 = `${externalProxyIp2}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp2);
|
||||||
|
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// CSRF validation fails because clientId changed
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
console.log(" Expected:", clientId1.slice(0, 50) + "...");
|
||||||
|
console.log(" Got:", clientId2.slice(0, 50) + "...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the fix works with trust proxy: true", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
const realClientIp = "203.0.113.42";
|
||||||
|
|
||||||
|
const clientId1 = `${realClientIp}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
|
||||||
|
console.log(" Express sees IP:", realClientIp);
|
||||||
|
|
||||||
|
// Request 2: Use token (even if middle proxy IPs differ)
|
||||||
|
const clientId2 = `${realClientIp}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log("Create drawing");
|
||||||
|
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
|
||||||
|
console.log("Express sees IP:", realClientIp, "(same!)");
|
||||||
|
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
console.log("\nCSRF Validation: SUCCESS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the Synology NAS scenario from issue #38", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
let seenIp: string | undefined;
|
||||||
|
app.get("/test", (req, res) => {
|
||||||
|
seenIp = req.ip;
|
||||||
|
res.json({ ip: req.ip });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
|
||||||
|
// In supertest without real socket, trust proxy: 1 returns last IP
|
||||||
|
// Key point: it's NOT the real client IP (192.168.0.100)
|
||||||
|
await request(app)
|
||||||
|
.get("/test")
|
||||||
|
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
|
||||||
|
console.log(" With trust proxy: 1, Express sees:", seenIp);
|
||||||
|
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -315,10 +315,11 @@ 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();
|
||||||
await initTestDb(prisma);
|
testUser = await initTestDb(prisma);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -343,6 +344,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,6 +407,7 @@ 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,290 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
|
||||||
|
|
||||||
|
type LegacyDbOptions = {
|
||||||
|
tableStyle: "prisma" | "plural-lower";
|
||||||
|
includeCollections: boolean;
|
||||||
|
includeMigrationsTable: boolean;
|
||||||
|
includeTrashDrawing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "excalidash-legacy-"));
|
||||||
|
|
||||||
|
const openWritableDb = (filePath: string): any => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { DatabaseSync } = require("node:sqlite") as any;
|
||||||
|
return new DatabaseSync(filePath, { enableForeignKeyConstraints: false });
|
||||||
|
} catch (_err) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const Database = require("better-sqlite3") as any;
|
||||||
|
return new Database(filePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
|
||||||
|
const dir = createTempDir();
|
||||||
|
const filePath = path.join(dir, "legacy-export.db");
|
||||||
|
const db = openWritableDb(filePath);
|
||||||
|
|
||||||
|
const tableDrawing = opts.tableStyle === "plural-lower" ? "drawings" : "Drawing";
|
||||||
|
const tableCollection = opts.tableStyle === "plural-lower" ? "collections" : "Collection";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (opts.includeCollections) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE "${tableCollection}" (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
createdAt TEXT,
|
||||||
|
updatedAt TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.prepare(`INSERT INTO "${tableCollection}" (id, name, createdAt, updatedAt) VALUES (?, ?, ?, ?)`).run(
|
||||||
|
"legacy-collection-1",
|
||||||
|
"Legacy Collection",
|
||||||
|
new Date("2024-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
new Date("2024-01-02T00:00:00.000Z").toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE "${tableDrawing}" (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
elements TEXT NOT NULL,
|
||||||
|
appState TEXT NOT NULL,
|
||||||
|
files TEXT,
|
||||||
|
preview TEXT,
|
||||||
|
version INTEGER,
|
||||||
|
collectionId TEXT,
|
||||||
|
collectionName TEXT,
|
||||||
|
createdAt TEXT,
|
||||||
|
updatedAt TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
|
||||||
|
const insertDrawing = db.prepare(
|
||||||
|
`INSERT INTO "${tableDrawing}"
|
||||||
|
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
insertDrawing.run(
|
||||||
|
"legacy-drawing-1",
|
||||||
|
"Legacy Drawing 1",
|
||||||
|
JSON.stringify([]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON.stringify({}),
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
opts.includeCollections ? "legacy-collection-1" : null,
|
||||||
|
opts.includeCollections ? "Legacy Collection" : null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
insertDrawing.run(
|
||||||
|
"legacy-drawing-2",
|
||||||
|
"Legacy Drawing 2 (unorganized)",
|
||||||
|
JSON.stringify([]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON.stringify({}),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.includeTrashDrawing) {
|
||||||
|
insertDrawing.run(
|
||||||
|
"legacy-drawing-trash",
|
||||||
|
"Legacy Trash Drawing",
|
||||||
|
JSON.stringify([]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON.stringify({}),
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
"trash",
|
||||||
|
"Trash",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.includeMigrationsTable) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE "_prisma_migrations" (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
migration_name TEXT NOT NULL,
|
||||||
|
logs TEXT,
|
||||||
|
rolled_back_at TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
applied_steps_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO "_prisma_migrations"
|
||||||
|
(id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
"m1",
|
||||||
|
"checksum",
|
||||||
|
new Date("2024-01-04T00:00:00.000Z").toISOString(),
|
||||||
|
"20240104000000_initial",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new Date("2024-01-04T00:00:00.000Z").toISOString(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Import compatibility (legacy exports)", () => {
|
||||||
|
const uploadsDir = path.resolve(__dirname, "../../uploads");
|
||||||
|
const userAgent = "vitest-import-compat";
|
||||||
|
let prisma: ReturnType<typeof getTestPrisma>;
|
||||||
|
let app: any;
|
||||||
|
let csrfHeaderName: string;
|
||||||
|
let csrfToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
setupTestDb();
|
||||||
|
prisma = getTestPrisma();
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
|
||||||
|
({ app } = await import("../index"));
|
||||||
|
|
||||||
|
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
|
||||||
|
csrfHeaderName = csrfRes.body.header;
|
||||||
|
csrfToken = csrfRes.body.token;
|
||||||
|
expect(typeof csrfHeaderName).toBe("string");
|
||||||
|
expect(typeof csrfToken).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanupTestDb(prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a v0.1.x–v0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => {
|
||||||
|
const legacyDb = createLegacySqliteDb({
|
||||||
|
tableStyle: "prisma",
|
||||||
|
includeCollections: true,
|
||||||
|
includeMigrationsTable: true,
|
||||||
|
includeTrashDrawing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy/verify")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
expect(res.body.drawings).toBe(2);
|
||||||
|
expect(res.body.collections).toBe(1);
|
||||||
|
expect(res.body.latestMigration).toBe("20240104000000_initial");
|
||||||
|
expect(typeof res.body.currentLatestMigration === "string").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => {
|
||||||
|
const legacyDb = createLegacySqliteDb({
|
||||||
|
tableStyle: "prisma",
|
||||||
|
includeCollections: true,
|
||||||
|
includeMigrationsTable: false,
|
||||||
|
includeTrashDrawing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.collections?.created).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
const importedDrawings = await prisma.drawing.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, collectionId: true, userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// In single-user mode, imports land on the bootstrap acting user.
|
||||||
|
expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true);
|
||||||
|
expect(importedDrawings.map((d) => d.id)).toEqual(
|
||||||
|
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
|
||||||
|
expect(trash).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports older exports with plural/lowercase table names (drawings/collections)", async () => {
|
||||||
|
const legacyDb = createLegacySqliteDb({
|
||||||
|
tableStyle: "plural-lower",
|
||||||
|
includeCollections: true,
|
||||||
|
includeMigrationsTable: false,
|
||||||
|
includeTrashDrawing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verify = await request(app)
|
||||||
|
.post("/import/sqlite/legacy/verify")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(verify.status).toBe(200);
|
||||||
|
expect(verify.body.drawings).toBe(2);
|
||||||
|
expect(verify.body.collections).toBe(1);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails verification if the legacy DB is missing a Drawing table", async () => {
|
||||||
|
const dir = createTempDir();
|
||||||
|
const filePath = path.join(dir, "invalid.db");
|
||||||
|
const db = openWritableDb(filePath);
|
||||||
|
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy/verify")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", filePath);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe("Invalid legacy DB");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,53 @@
|
|||||||
* 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 separate test database
|
// Use a unique test database per test-file import to avoid cross-file contention
|
||||||
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
|
// when Vitest runs test files in parallel.
|
||||||
|
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
|
||||||
@@ -32,10 +74,19 @@ export const setupTestDb = () => {
|
|||||||
|
|
||||||
// Run Prisma migrations to create the test database
|
// Run Prisma migrations to create the test database
|
||||||
try {
|
try {
|
||||||
execSync("npx prisma db push --skip-generate", {
|
withDbPushLock(() => {
|
||||||
cwd: path.resolve(__dirname, "../../"),
|
execSync("npx prisma db push --skip-generate --force-reset", {
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
cwd: path.resolve(__dirname, "../../"),
|
||||||
stdio: "pipe",
|
env: {
|
||||||
|
...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);
|
||||||
@@ -54,19 +105,42 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user for testing
|
||||||
|
*/
|
||||||
|
export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => {
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const passwordHash = await bcrypt.hash("testpassword", 10);
|
||||||
|
|
||||||
|
return await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
name: "Test User",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize test database with required data
|
* 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" },
|
data: { id: "trash", name: "Trash", userId: testUser.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return testUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
setupTestDb,
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import jwt, { SignOptions } from "jsonwebtoken";
|
||||||
|
import ms, { type StringValue } from "ms";
|
||||||
|
import { PrismaClient, Prisma } from "./generated/client";
|
||||||
|
import { config } from "./config";
|
||||||
|
import { requireAuth, optionalAuth } from "./middleware/auth";
|
||||||
|
import { getCsrfTokenHeader, sanitizeText, validateCsrfToken } from "./security";
|
||||||
|
import rateLimit, { MemoryStore } from "express-rate-limit";
|
||||||
|
import { registerAccountRoutes } from "./auth/accountRoutes";
|
||||||
|
import { registerAdminRoutes } from "./auth/adminRoutes";
|
||||||
|
import { registerCoreRoutes } from "./auth/coreRoutes";
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
type: "access" | "refresh";
|
||||||
|
impersonatorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
|
||||||
|
if (typeof decoded !== "object" || decoded === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = decoded as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof payload.userId === "string" &&
|
||||||
|
typeof payload.email === "string" &&
|
||||||
|
(payload.type === "access" || payload.type === "refresh")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
||||||
|
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
||||||
|
|
||||||
|
const ensureSystemConfig = async () => {
|
||||||
|
return prisma.systemConfig.upsert({
|
||||||
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
authEnabled: false,
|
||||||
|
registrationEnabled: false,
|
||||||
|
authLoginRateLimitEnabled: true,
|
||||||
|
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
||||||
|
authLoginRateLimitMax: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
|
||||||
|
const systemConfig = await ensureSystemConfig();
|
||||||
|
if (!systemConfig.authEnabled) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: "Not found",
|
||||||
|
message: "Authentication is disabled",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginRateLimitConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
windowMs: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_RATE_LIMIT: LoginRateLimitConfig = {
|
||||||
|
enabled: true,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT };
|
||||||
|
let loginAttemptLimiter: ReturnType<typeof rateLimit> | null = null;
|
||||||
|
let loginLimiterInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const parseLoginRateLimitConfig = (systemConfig: Awaited<ReturnType<typeof ensureSystemConfig>>): LoginRateLimitConfig => {
|
||||||
|
const enabled = typeof systemConfig.authLoginRateLimitEnabled === "boolean" ? systemConfig.authLoginRateLimitEnabled : DEFAULT_LOGIN_RATE_LIMIT.enabled;
|
||||||
|
const windowMs =
|
||||||
|
Number.isFinite(Number(systemConfig.authLoginRateLimitWindowMs)) && Number(systemConfig.authLoginRateLimitWindowMs) > 0
|
||||||
|
? Number(systemConfig.authLoginRateLimitWindowMs)
|
||||||
|
: DEFAULT_LOGIN_RATE_LIMIT.windowMs;
|
||||||
|
const max =
|
||||||
|
Number.isFinite(Number(systemConfig.authLoginRateLimitMax)) && Number(systemConfig.authLoginRateLimitMax) > 0
|
||||||
|
? Number(systemConfig.authLoginRateLimitMax)
|
||||||
|
: DEFAULT_LOGIN_RATE_LIMIT.max;
|
||||||
|
return { enabled, windowMs, max };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAuthIdentifier = (req: Request): string | null => {
|
||||||
|
const body = (req.body || {}) as Record<string, unknown>;
|
||||||
|
const raw =
|
||||||
|
(typeof body.email === "string" && body.email) ||
|
||||||
|
(typeof body.username === "string" && body.username) ||
|
||||||
|
(typeof body.identifier === "string" && body.identifier) ||
|
||||||
|
null;
|
||||||
|
if (!raw) return null;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => {
|
||||||
|
const store = new MemoryStore();
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: cfg.windowMs,
|
||||||
|
max: cfg.max,
|
||||||
|
message: {
|
||||||
|
error: "Too many requests",
|
||||||
|
message: "Too many login attempts, please try again later",
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
validate: {
|
||||||
|
trustProxy: false,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
const identifier = resolveAuthIdentifier(req as Request);
|
||||||
|
if (identifier) return `login:${identifier}`;
|
||||||
|
const ip = (req as Request).ip || "unknown";
|
||||||
|
return `login-ip:${ip}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loginAttemptLimiter = limiter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initLoginAttemptLimiter = async () => {
|
||||||
|
const systemConfig = await ensureSystemConfig();
|
||||||
|
loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig);
|
||||||
|
buildLoginAttemptLimiter(loginRateLimitConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureLoginAttemptLimiter = async () => {
|
||||||
|
if (loginAttemptLimiter) return;
|
||||||
|
if (!loginLimiterInitPromise) {
|
||||||
|
loginLimiterInitPromise = initLoginAttemptLimiter().finally(() => {
|
||||||
|
loginLimiterInitPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loginLimiterInitPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLoginRateLimitConfig = (
|
||||||
|
systemConfig: Pick<Awaited<ReturnType<typeof ensureSystemConfig>>, "authLoginRateLimitEnabled" | "authLoginRateLimitWindowMs" | "authLoginRateLimitMax">
|
||||||
|
): LoginRateLimitConfig => {
|
||||||
|
loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig as Awaited<ReturnType<typeof ensureSystemConfig>>);
|
||||||
|
buildLoginAttemptLimiter(loginRateLimitConfig);
|
||||||
|
return loginRateLimitConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetLoginAttemptKey = async (identifier: string): Promise<void> => {
|
||||||
|
await ensureLoginAttemptLimiter();
|
||||||
|
const key = `login:${identifier}`;
|
||||||
|
try {
|
||||||
|
await loginAttemptLimiter?.resetKey(key);
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.debug("Rate limit reset skipped:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginAttemptRateLimiter = async (req: Request, res: Response, next: express.NextFunction) => {
|
||||||
|
await ensureLoginAttemptLimiter();
|
||||||
|
if (!loginRateLimitConfig.enabled) return next();
|
||||||
|
return (loginAttemptLimiter as ReturnType<typeof rateLimit>)(req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountActionRateLimiter = rateLimit({
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
message: {
|
||||||
|
error: "Too many requests",
|
||||||
|
message: "Too many requests, please try again later",
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
validate: {
|
||||||
|
trustProxy: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateTempPassword = (): string => {
|
||||||
|
const buf = crypto.randomBytes(18);
|
||||||
|
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findUserByIdentifier = async (identifier: string) => {
|
||||||
|
const trimmed = identifier.trim();
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
|
|
||||||
|
const looksLikeEmail = trimmed.includes("@");
|
||||||
|
if (looksLikeEmail) {
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { email: trimmed.toLowerCase() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): req is Request & { user: NonNullable<Request["user"]> } => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (req.user.role !== "ADMIN") {
|
||||||
|
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientId = (req: Request): string => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireCsrf = (req: Request, res: Response): boolean => {
|
||||||
|
const headerName = getCsrfTokenHeader();
|
||||||
|
const tokenHeader = req.headers[headerName];
|
||||||
|
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "CSRF token missing",
|
||||||
|
message: `Missing ${headerName} header`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateCsrfToken(getClientId(req), token)) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "CSRF token invalid",
|
||||||
|
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const countActiveAdmins = async () => {
|
||||||
|
return prisma.user.count({
|
||||||
|
where: { role: "ADMIN", isActive: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTokens = (
|
||||||
|
userId: string,
|
||||||
|
email: string,
|
||||||
|
options?: { impersonatorId?: string }
|
||||||
|
) => {
|
||||||
|
const signOptions: SignOptions = {
|
||||||
|
expiresIn: config.jwtAccessExpiresIn as StringValue,
|
||||||
|
};
|
||||||
|
const accessToken = jwt.sign(
|
||||||
|
{ userId, email, type: "access", impersonatorId: options?.impersonatorId },
|
||||||
|
config.jwtSecret,
|
||||||
|
signOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshSignOptions: SignOptions = {
|
||||||
|
expiresIn: config.jwtRefreshExpiresIn as StringValue,
|
||||||
|
};
|
||||||
|
const refreshToken = jwt.sign(
|
||||||
|
{ userId, email, type: "refresh", impersonatorId: options?.impersonatorId },
|
||||||
|
config.jwtSecret,
|
||||||
|
refreshSignOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => {
|
||||||
|
const parsed = ms(expiresIn as StringValue);
|
||||||
|
const ttlMs = typeof parsed === "number" && parsed > 0 ? parsed : fallbackMs;
|
||||||
|
return new Date(Date.now() + ttlMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMissingRefreshTokenTableError = (error: unknown): boolean => {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code === "P2021") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof error === "object" && error && "message" in error
|
||||||
|
? String((error as any).message)
|
||||||
|
: "";
|
||||||
|
return /no such table:\s*RefreshToken/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefreshTokenExpiresAt = (): Date =>
|
||||||
|
resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
registerCoreRoutes({
|
||||||
|
router,
|
||||||
|
prisma,
|
||||||
|
requireAuth,
|
||||||
|
optionalAuth,
|
||||||
|
loginAttemptRateLimiter,
|
||||||
|
ensureAuthEnabled,
|
||||||
|
ensureSystemConfig,
|
||||||
|
findUserByIdentifier,
|
||||||
|
sanitizeText,
|
||||||
|
requireCsrf,
|
||||||
|
isJwtPayload,
|
||||||
|
config,
|
||||||
|
generateTokens,
|
||||||
|
getRefreshTokenExpiresAt,
|
||||||
|
isMissingRefreshTokenTableError,
|
||||||
|
bootstrapUserId: BOOTSTRAP_USER_ID,
|
||||||
|
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminRoutes({
|
||||||
|
router,
|
||||||
|
prisma,
|
||||||
|
requireAuth,
|
||||||
|
accountActionRateLimiter,
|
||||||
|
ensureAuthEnabled,
|
||||||
|
ensureSystemConfig,
|
||||||
|
parseLoginRateLimitConfig,
|
||||||
|
applyLoginRateLimitConfig,
|
||||||
|
resetLoginAttemptKey,
|
||||||
|
requireAdmin,
|
||||||
|
findUserByIdentifier,
|
||||||
|
countActiveAdmins,
|
||||||
|
sanitizeText,
|
||||||
|
generateTempPassword,
|
||||||
|
generateTokens,
|
||||||
|
getRefreshTokenExpiresAt,
|
||||||
|
config,
|
||||||
|
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAccountRoutes({
|
||||||
|
router,
|
||||||
|
prisma,
|
||||||
|
requireAuth,
|
||||||
|
loginAttemptRateLimiter,
|
||||||
|
accountActionRateLimiter,
|
||||||
|
ensureAuthEnabled,
|
||||||
|
sanitizeText,
|
||||||
|
config,
|
||||||
|
generateTokens,
|
||||||
|
getRefreshTokenExpiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user