Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e97fbbdf27 | |||
| 2e40deb82c | |||
| 4ebc99152a | |||
| 44317c4981 | |||
| 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 | |||
| 7c238701b7 | |||
| c5c8b15e75 | |||
| 9bc3c7c8fc | |||
| 0476315322 | |||
| e75b727a5a | |||
| c2aa742a79 | |||
| 49b413bf07 | |||
| 18c8595c2e | |||
| 2e6b94644f | |||
| b0bdc05071 | |||
| 2520d7e7a2 | |||
| 32985ea6fe | |||
| f8830a8b0f | |||
| c4352185d6 | |||
| f9986513f8 | |||
| 6f050aec7d | |||
| 05b787bc27 | |||
| 971046d568 | |||
| 602350d2e6 | |||
| f20d48fea2 | |||
| c53dc010de | |||
| 03e778a06f | |||
| fa73708d97 | |||
| ee8204532d | |||
| a347403a26 | |||
| 8becfd87bb | |||
| 1b78597649 | |||
| d93b6493c1 | |||
| d581eb3e88 | |||
| 4728ef151c | |||
| eb5f54a6d0 | |||
| c502f1c0bd | |||
| 8f9ac1f9c0 | |||
| 0787989496 | |||
| 9bc25a3dc2 | |||
| 3cc3fd18f4 | |||
| 997fa4af03 | |||
| b864e82318 | |||
| 2f22be2bd7 | |||
| fcfb850168 | |||
| 4a224c1f92 | |||
| d1d17e1288 | |||
| 9055661b51 | |||
| d25a32cdd3 | |||
| 8d65404514 | |||
| 1b6c32d773 | |||
| 352bcfca29 | |||
| 448c678ecc | |||
| e980b96091 | |||
| fabe0fcd54 | |||
| ef27256879 | |||
| c1da41474f | |||
| 815dcd5c80 | |||
| 29936417fc | |||
| 49e32f7d96 | |||
| cd9c242983 | |||
| 3835557e67 | |||
| 69bffab745 | |||
| ef412a3887 | |||
| 2e2b4ca455 | |||
| fb5fe1235c | |||
| e21cdbe6a8 | |||
| 94f33f0a56 | |||
| 5d5e22c8a1 | |||
| b3dbcc2376 |
@@ -0,0 +1,199 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: Backend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npm test
|
||||||
|
|
||||||
|
frontend-unit-tests:
|
||||||
|
name: Frontend Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm test
|
||||||
|
|
||||||
|
e2e-tests:
|
||||||
|
name: E2E Browser Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
- name: Setup backend database
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Install E2E test dependencies
|
||||||
|
run: |
|
||||||
|
cd e2e
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: |
|
||||||
|
cd e2e
|
||||||
|
npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Start servers and run E2E tests
|
||||||
|
run: |
|
||||||
|
# Start backend server in background
|
||||||
|
cd backend
|
||||||
|
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
echo "Waiting for backend server..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -s http://localhost:8000/health > /dev/null; then
|
||||||
|
echo "Backend is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Attempt $i: Backend not ready yet..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Start frontend server in background
|
||||||
|
cd frontend
|
||||||
|
npm run dev -- --host &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Wait for frontend to be ready
|
||||||
|
echo "Waiting for frontend server..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -s http://localhost:5173 > /dev/null; then
|
||||||
|
echo "Frontend is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Attempt $i: Frontend not ready yet..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
cd e2e
|
||||||
|
NO_SERVER=true CI=true npx playwright test
|
||||||
|
TEST_EXIT_CODE=$?
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
exit $TEST_EXIT_CODE
|
||||||
|
env:
|
||||||
|
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: e2e/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: e2e/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Security tests for data sanitization
|
||||||
|
security-tests:
|
||||||
|
name: Security Sanitization Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
- name: Run security tests
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npx ts-node src/securityTest.ts
|
||||||
+110
-1
@@ -1,3 +1,112 @@
|
|||||||
|
# Dependencies
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
backend/node_modules
|
||||||
|
|
||||||
|
# Database
|
||||||
|
backend/prisma/*.db
|
||||||
|
backend/prisma/**/*.db
|
||||||
|
backend/prisma/*.db-journal
|
||||||
|
backend/prisma/**/*.db-journal
|
||||||
|
backend/prisma/dev.db
|
||||||
|
backend/prisma/e2e-test.db
|
||||||
|
backend/prisma/*.backup
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
backend/uploads/
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
backend/src/generated/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.staging
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
backend/dist/
|
||||||
|
|
||||||
|
# E2E Testing
|
||||||
|
e2e/node_modules/
|
||||||
|
e2e/test-results/
|
||||||
|
e2e/test-results-user/
|
||||||
|
e2e/playwright-report/
|
||||||
|
e2e/playwright-report-user/
|
||||||
|
e2e/.playwright/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Test artifacts (in case they appear in other locations)
|
||||||
|
**/playwright-report/
|
||||||
|
**/test-results/
|
||||||
|
**/playwright/.cache/
|
||||||
|
|
||||||
|
# Docker volumes (if any temporary ones are created)
|
||||||
|
docker-volumes/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Vitest cache
|
||||||
|
.vitest/
|
||||||
|
|
||||||
|
# Playwright screenshots/videos on failure
|
||||||
|
**/screenshots/
|
||||||
|
**/videos/
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# IDE/Editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backend/prisma/*.db
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
@@ -148,7 +148,7 @@ ExcaliDash/
|
|||||||
**Backend (.env):**
|
**Backend (.env):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="file:./prisma/dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
||||||
|
|
||||||
# ExcaliDash v0.1.0
|
# ExcaliDash
|
||||||
|
|
||||||
[](LICENSE)
|

|
||||||
|

|
||||||
[](https://hub.docker.com)
|
[](https://hub.docker.com)
|
||||||
|
|
||||||
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
||||||
@@ -21,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Docker Hub (Recommended)](#dockerhub-recommended)
|
- [Docker Hub (Recommended)](#dockerhub-recommended)
|
||||||
- [Docker Build](#docker-build)
|
- [Docker Build](#docker-build)
|
||||||
|
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
|
||||||
|
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Clone the Repository](#clone-the-repository)
|
- [Clone the Repository](#clone-the-repository)
|
||||||
- [Frontend](#frontend)
|
- [Frontend](#frontend)
|
||||||
@@ -74,7 +77,10 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> NOT for production use. This is just a side project (and also the first release), and it likely contains some bugs. DO NOT open ports to the internet (e.g. CORS is set to allow all)
|
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
|
||||||
|
|
||||||
## Docker Hub (Recommended)
|
## Docker Hub (Recommended)
|
||||||
|
|
||||||
@@ -110,6 +116,47 @@ docker compose up -d
|
|||||||
# Access the frontend at localhost:6767
|
# Access the frontend at localhost:6767
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Reverse Proxy / Traefik Setups (Docker)
|
||||||
|
|
||||||
|
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. **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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml example
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
# Single URL
|
||||||
|
- 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:
|
||||||
|
environment:
|
||||||
|
# For standard Docker Compose (default)
|
||||||
|
# - BACKEND_URL=backend:8000
|
||||||
|
# For Kubernetes, use the service DNS name:
|
||||||
|
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Container / Kubernetes Deployments
|
||||||
|
|
||||||
|
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure secret
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml or k8s deployment
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- CSRF_SECRET=your-generated-secret-here
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
## Clone the Repository
|
## Clone the Repository
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
CSRF Protection (8a78b2b)
|
||||||
|
|
||||||
|
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
|
||||||
|
- Added new backend/src/security.ts module for security utilities
|
||||||
|
- Frontend API layer now handles CSRF tokens automatically
|
||||||
|
- Added integration tests for CSRF validation
|
||||||
|
|
||||||
|
Upload Progress Indicator (8f9b9b4)
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
```
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# Security Fixes Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document summarizes the comprehensive security fixes implemented to address two critical security vulnerabilities identified in ExcaliDash:
|
|
||||||
|
|
||||||
1. **Stored XSS Vector (High Severity)** - Data sanitization negligence
|
|
||||||
2. **Root Execution Privilege (Critical Severity)** - Container escape risk
|
|
||||||
|
|
||||||
## Security Issues Fixed
|
|
||||||
|
|
||||||
### Issue 1: Stored XSS Vector (High Severity) ✅ FIXED
|
|
||||||
|
|
||||||
**Problem**: Backend used lazy `z.object({}).passthrough()` validation for elements and appState, allowing arbitrary JSON storage without sanitization.
|
|
||||||
|
|
||||||
**Attack Vectors**:
|
|
||||||
|
|
||||||
- Malicious `.excalidraw` files containing `<script>` tags in element properties
|
|
||||||
- `javascript:` URIs in link attributes
|
|
||||||
- SVG previews with embedded malicious code
|
|
||||||
- Compromised clients sending XSS payloads
|
|
||||||
|
|
||||||
**Solution Implemented**:
|
|
||||||
|
|
||||||
- **Strict Zod Schemas**: Replaced `.passthrough()` with detailed validation schemas for elements and appState
|
|
||||||
- **HTML/JS Sanitization**: Implemented comprehensive sanitization layer removing script tags, event handlers, and malicious URLs
|
|
||||||
- **SVG Sanitization**: Special handling for SVG content to prevent script execution
|
|
||||||
- **URL Validation**: Whitelist-only approach for URL schemes (http, https, mailto, relative paths only)
|
|
||||||
- **Input Sanitization**: All string inputs are sanitized before database persistence
|
|
||||||
- **Import Validation**: Additional security checks for imported .excalidraw files with `X-Imported-File` header
|
|
||||||
|
|
||||||
### Issue 2: Root Execution Privilege (Critical Severity) ✅ FIXED
|
|
||||||
|
|
||||||
**Problem**: Container ran Node.js process as root without USER directive, providing immediate root access in case of RCE.
|
|
||||||
|
|
||||||
**Attack Vectors**:
|
|
||||||
|
|
||||||
- RCE vulnerabilities in `better-sqlite3` native bindings
|
|
||||||
- File upload processing vulnerabilities
|
|
||||||
- Import functionality exploits
|
|
||||||
|
|
||||||
**Solution Implemented**:
|
|
||||||
|
|
||||||
- **Non-Root User**: Created dedicated `nodejs` user with UID 1001
|
|
||||||
- **Permission Management**: Proper ownership and permissions for data directories
|
|
||||||
- **Dockerfile Security**: Added USER directive to switch to non-root execution
|
|
||||||
- **Entry Point Security**: Updated docker-entrypoint.sh to handle permissions correctly
|
|
||||||
|
|
||||||
### Additional Security Hardening ✅ IMPLEMENTED
|
|
||||||
|
|
||||||
**Security Headers**:
|
|
||||||
|
|
||||||
- Content Security Policy (CSP) with strict source restrictions
|
|
||||||
- X-Frame-Options: DENY (prevents clickjacking)
|
|
||||||
- X-Content-Type-Options: nosniff
|
|
||||||
- X-XSS-Protection: 1; mode=block
|
|
||||||
- Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
- Permissions-Policy: geolocation=(), microphone=(), camera=()
|
|
||||||
|
|
||||||
**Rate Limiting**:
|
|
||||||
|
|
||||||
- Implemented basic rate limiting (1000 requests per 15-minute window)
|
|
||||||
- Per-IP tracking to prevent DoS attacks
|
|
||||||
|
|
||||||
**Request Validation**:
|
|
||||||
|
|
||||||
- Maintained existing 50MB request size limits
|
|
||||||
- Enhanced validation for file imports
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
1. **`backend/src/security.ts`** - New security utilities module
|
|
||||||
|
|
||||||
- HTML/JS sanitization functions
|
|
||||||
- SVG sanitization functions
|
|
||||||
- Strict Zod schemas for elements and appState
|
|
||||||
- Drawing data validation and sanitization
|
|
||||||
- URL sanitization with whitelist validation
|
|
||||||
|
|
||||||
2. **`backend/src/index.ts`** - Updated backend security
|
|
||||||
|
|
||||||
- Replaced lazy `.passthrough()` schemas with strict validation
|
|
||||||
- Added security middleware with headers and rate limiting
|
|
||||||
- Enhanced POST /drawings endpoint with import validation
|
|
||||||
- Added malicious content detection and rejection
|
|
||||||
|
|
||||||
3. **`backend/Dockerfile`** - Container security hardening
|
|
||||||
|
|
||||||
- Created non-root `nodejs` user (UID 1001)
|
|
||||||
- Added USER directive for non-root execution
|
|
||||||
- Set proper file ownership and permissions
|
|
||||||
|
|
||||||
4. **`backend/docker-entrypoint.sh`** - Permission management
|
|
||||||
- Added proper directory permission setup
|
|
||||||
- User-aware permission handling
|
|
||||||
- Database file permission management
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
5. **`frontend/src/utils/importUtils.ts`** - Import security marking
|
|
||||||
- Added `X-Imported-File: true` header for imported files
|
|
||||||
- Enables additional backend validation for imported content
|
|
||||||
|
|
||||||
## Security Testing
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
|
|
||||||
**XSS Prevention Tests** (`backend/src/securityTest.ts`):
|
|
||||||
|
|
||||||
- ✅ HTML/JS injection prevention
|
|
||||||
- ✅ SVG malicious content blocking
|
|
||||||
- ✅ URL scheme validation (javascript:, data:, vbscript: blocked)
|
|
||||||
- ✅ Text sanitization with length limits
|
|
||||||
- ✅ Malicious drawing rejection
|
|
||||||
- ✅ Legitimate content preservation
|
|
||||||
|
|
||||||
**Container Security Tests**:
|
|
||||||
|
|
||||||
- ✅ Docker container runs as `uid=1001(nodejs)` instead of root
|
|
||||||
- ✅ Proper file permissions for data directories
|
|
||||||
- ✅ Non-root user execution verified
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
|
|
||||||
```
|
|
||||||
🧪 Security Test Suite Results:
|
|
||||||
|
|
||||||
✅ HTML/JS injection prevention - WORKING
|
|
||||||
✅ SVG malicious content blocking - WORKING
|
|
||||||
✅ URL scheme validation - WORKING
|
|
||||||
✅ Text sanitization with limits - WORKING
|
|
||||||
✅ Malicious drawing rejection - WORKING
|
|
||||||
✅ Legitimate content preservation - WORKING
|
|
||||||
✅ Container runs as non-root (uid=1001) - WORKING
|
|
||||||
|
|
||||||
🔒 XSS Prevention: IMPLEMENTED & FUNCTIONAL
|
|
||||||
🔒 Container Security: IMPLEMENTED & FUNCTIONAL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Benefits
|
|
||||||
|
|
||||||
### Before Fixes
|
|
||||||
|
|
||||||
- ❌ Any malicious script in drawing data would be stored and executed
|
|
||||||
- ❌ Container escape possible with immediate root access
|
|
||||||
- ❌ No protection against XSS, CSRF, or clickjacking attacks
|
|
||||||
- ❌ Unrestricted file uploads and imports
|
|
||||||
|
|
||||||
### After Fixes
|
|
||||||
|
|
||||||
- ✅ All drawing data is sanitized before storage
|
|
||||||
- ✅ Malicious content is detected and rejected
|
|
||||||
- ✅ Container runs with minimal privileges (UID 1001)
|
|
||||||
- ✅ Comprehensive security headers protect against common attacks
|
|
||||||
- ✅ Rate limiting prevents DoS attacks
|
|
||||||
- ✅ Strict validation for all imported content
|
|
||||||
|
|
||||||
## Security Impact
|
|
||||||
|
|
||||||
### Risk Reduction
|
|
||||||
|
|
||||||
- **XSS Risk**: High → **Eliminated**
|
|
||||||
- **Container Escape**: Critical → **Mitigated**
|
|
||||||
- **RCE Impact**: High → **Reduced** (non-root execution)
|
|
||||||
- **DoS Risk**: Medium → **Reduced** (rate limiting)
|
|
||||||
|
|
||||||
### Compliance
|
|
||||||
|
|
||||||
- Implements defense-in-depth security principles
|
|
||||||
- Follows secure coding practices
|
|
||||||
- Adheres to container security best practices
|
|
||||||
- Protects against OWASP Top 10 vulnerabilities
|
|
||||||
|
|
||||||
## Maintenance Notes
|
|
||||||
|
|
||||||
### Regular Security Tasks
|
|
||||||
|
|
||||||
1. **Security Test Suite**: Run `npm run security-test` to verify XSS prevention
|
|
||||||
2. **Container Security**: Verify non-root execution in production
|
|
||||||
3. **Dependency Updates**: Keep dependencies updated for security patches
|
|
||||||
4. **Security Audit**: Review and update sanitization rules as needed
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
|
|
||||||
- Monitor rate limiting logs for DoS attempts
|
|
||||||
- Track validation failures for potential attack patterns
|
|
||||||
- Review container logs for permission-related issues
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Both critical security issues have been successfully addressed with comprehensive fixes that:
|
|
||||||
|
|
||||||
1. **Eliminate XSS vulnerabilities** through strict validation and sanitization
|
|
||||||
2. **Reduce container escape risk** through non-root execution
|
|
||||||
3. **Add defense-in-depth** security measures
|
|
||||||
4. **Maintain full functionality** while improving security posture
|
|
||||||
|
|
||||||
The implementation includes thorough testing to ensure security measures work correctly while preserving legitimate functionality.
|
|
||||||
|
|
||||||
**Security Status**: ✅ **RESOLVED**
|
|
||||||
@@ -2,3 +2,11 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
|
FRONTEND_URL=http://localhost:6767
|
||||||
|
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
|
||||||
|
|||||||
+6
-9
@@ -25,8 +25,8 @@ RUN npx tsc
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenSSL for Prisma and create non-root user
|
# Install OpenSSL for Prisma and su-exec, create non-root user
|
||||||
RUN apk add --no-cache openssl && \
|
RUN apk add --no-cache openssl su-exec && \
|
||||||
addgroup -g 1001 -S nodejs && \
|
addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
@@ -51,17 +51,14 @@ COPY --from=builder /app/src/generated ./dist/generated
|
|||||||
# Generate Prisma Client in production (updates node_modules)
|
# Generate Prisma Client in production (updates node_modules)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Create necessary directories and set proper ownership
|
# Create necessary directories (ownership will be set in entrypoint)
|
||||||
RUN mkdir -p /app/uploads /app/prisma && \
|
RUN mkdir -p /app/uploads /app/prisma
|
||||||
chown -R nodejs:nodejs /app
|
|
||||||
|
|
||||||
# Copy and set permissions for entrypoint script
|
# Copy and set permissions for entrypoint script
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x docker-entrypoint.sh && \
|
RUN chmod +x docker-entrypoint.sh
|
||||||
chown nodejs:nodejs docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
|
||||||
USER nodejs
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Auto-hydrate prisma directory when bind-mounted volume is empty
|
# 1. Hydrate volume if empty (Running as root)
|
||||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||||
echo "Mount is empty. Hydrating /app/prisma from /app/prisma_template..."
|
echo "Mount is empty. Hydrating /app/prisma..."
|
||||||
cp -R /app/prisma_template/. /app/prisma/
|
cp -R /app/prisma_template/. /app/prisma/
|
||||||
|
else
|
||||||
|
# Volume exists but may be missing new migrations from an upgrade
|
||||||
|
# Always sync schema and migrations from template to ensure upgrades work
|
||||||
|
echo "Syncing schema and migrations from template..."
|
||||||
|
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
|
||||||
|
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure proper ownership and permissions for data directories
|
# 2. Fix permissions unconditionally (Running as root)
|
||||||
echo "Setting up data directory permissions..."
|
echo "Fixing filesystem permissions..."
|
||||||
mkdir -p /app/uploads
|
chown -R nodejs:nodejs /app/uploads
|
||||||
mkdir -p /app/prisma
|
chown -R nodejs:nodejs /app/prisma
|
||||||
|
chmod 755 /app/uploads
|
||||||
# Set ownership to the node user (UID 1000)
|
|
||||||
if [ "$(id -u)" = "0" ]; then
|
|
||||||
# If running as root (for some reason), fix ownership
|
|
||||||
chown -R nodejs:nodejs /app/uploads
|
|
||||||
chown -R nodejs:nodejs /app/prisma
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure database file has proper permissions
|
# Ensure database file has proper permissions
|
||||||
if [ -f "/app/prisma/dev.db" ]; then
|
if [ -f "/app/prisma/dev.db" ]; then
|
||||||
chmod 664 /app/prisma/dev.db 2>/dev/null || true
|
echo "Database file found, ensuring write permissions..."
|
||||||
|
chmod 666 /app/prisma/dev.db
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set appropriate permissions for uploads directory
|
# 3. Run Migrations (Drop privileges to nodejs)
|
||||||
chmod 755 /app/uploads
|
|
||||||
|
|
||||||
# Run migrations as the current user
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npx prisma migrate deploy
|
su-exec nodejs npx prisma migrate deploy
|
||||||
|
|
||||||
# Start the application
|
# 4. Start Application (Drop privileges to nodejs)
|
||||||
echo "Starting application as user $(whoami) (UID: $(id -u))"
|
echo "Starting application as nodejs..."
|
||||||
node dist/index.js
|
exec su-exec nodejs node dist/index.js
|
||||||
|
|||||||
Generated
+2205
-360
File diff suppressed because it is too large
Load Diff
+24
-6
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "0.3.2",
|
||||||
"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",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"admin:recover": "node scripts/admin-recover.cjs",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -14,27 +18,41 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/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",
|
||||||
"jsdom": "^27.2.0",
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.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",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"prisma": "^5.22.0",
|
"supertest": "^7.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Library" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
|
||||||
|
"items" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
|
||||||
Binary file not shown.
@@ -12,9 +12,40 @@ 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
|
||||||
@@ -28,8 +59,49 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Library {
|
||||||
|
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||||
|
items String @default("[]") // Stored as JSON string array of library items
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
used Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
revoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted"
|
||||||
|
resource String? // e.g., "drawing:123", "collection:456"
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
details String? // JSON string for additional details
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user