Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d613ea550 | |||
| 0ffe410eeb | |||
| fd5470ada5 | |||
| 75cbe97bc0 | |||
| 12da89b815 | |||
| 9e248f9751 | |||
| fe58cf7e89 | |||
| 6061d4ab94 | |||
| 6fe2ab3d28 | |||
| e05edff84d | |||
| da131834ce | |||
| 08d2165a70 | |||
| 2cbd11cf0d | |||
| 1c71a08bbe | |||
| bb028ef2db | |||
| 1117dc584e | |||
| 70103e18fb | |||
| fd013de325 | |||
| 6bee0e2ded | |||
| 35bbbb9599 | |||
| 2aa749a2f0 | |||
| 02736d663a | |||
| de254d46f2 | |||
| dd0f381ed1 | |||
| c40a5f46a0 | |||
| 8fcca43b0d | |||
| f20412cdfb | |||
| a366acfedc | |||
| 154dcbb151 | |||
| 2e74d2ad1a | |||
| 173c050f58 | |||
| 8161a563f0 | |||
| 812f1cbf58 | |||
| 26017fa5d2 | |||
| 06f4c0f537 | |||
| 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 | |||
| 260a898e3e | |||
| 15ac634d15 | |||
| 1a52fe80f3 | |||
| 20ef4ee295 | |||
| d1dbde95e4 |
@@ -7,3 +7,9 @@ dist
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
backend
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/coverage
|
||||
frontend/test-results
|
||||
frontend/playwright-report
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
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 &
|
||||
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:6767" npm run dev &
|
||||
BACKEND_PID=$!
|
||||
cd ..
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
# 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
|
||||
if curl -s http://localhost:6767 > /dev/null; then
|
||||
echo "Frontend is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../frontend
|
||||
@@ -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
|
||||
./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
|
||||
#===============================================================================
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||

|
||||
[](https://hub.docker.com)
|
||||
|
||||
_Original repo can be found [here](https://github.com/ZimengXiong/ExcaliDash)_
|
||||
|
||||
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
||||
|
||||
## Screenshots
|
||||
@@ -99,6 +101,10 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
# Access the frontend at localhost:6767
|
||||
```
|
||||
|
||||
For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly.
|
||||
|
||||
By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers.
|
||||
|
||||
## Docker Build
|
||||
|
||||
[Install Docker](https://docs.docker.com/desktop/)
|
||||
@@ -120,14 +126,20 @@ docker compose up -d
|
||||
|
||||
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.
|
||||
- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers.
|
||||
- `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
|
||||
# Trust exactly one reverse-proxy hop
|
||||
- TRUST_PROXY=1
|
||||
# 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)
|
||||
@@ -138,7 +150,7 @@ frontend:
|
||||
|
||||
### 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.
|
||||
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set both `JWT_SECRET` and `CSRF_SECRET` to the same values across all instances.
|
||||
|
||||
```bash
|
||||
# Generate a secure secret
|
||||
@@ -149,11 +161,37 @@ openssl rand -base64 32
|
||||
# docker-compose.yml or k8s deployment
|
||||
backend:
|
||||
environment:
|
||||
- JWT_SECRET=your-generated-jwt-secret-here
|
||||
- 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.
|
||||
|
||||
### Authentication Modes (Local + OIDC)
|
||||
|
||||
ExcaliDash supports three auth modes via backend `AUTH_MODE`:
|
||||
|
||||
- `local` (default): native email/password login only.
|
||||
- `hybrid`: native login + OIDC login.
|
||||
- `oidc_enforced`: OIDC-only login (native login/register disabled).
|
||||
|
||||
For OIDC modes (`hybrid` or `oidc_enforced`), set:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
environment:
|
||||
- AUTH_MODE=oidc_enforced
|
||||
- OIDC_PROVIDER_NAME=Authentik
|
||||
- OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
|
||||
- OIDC_CLIENT_ID=your-client-id
|
||||
- OIDC_CLIENT_SECRET=your-client-secret
|
||||
- OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
|
||||
- OIDC_SCOPES=openid profile email
|
||||
```
|
||||
|
||||
In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`.
|
||||
Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned.
|
||||
|
||||
# Development
|
||||
|
||||
## Clone the Repository
|
||||
@@ -194,6 +232,27 @@ npx prisma db push
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Simulate Auth Onboarding (Development)
|
||||
|
||||
To simulate first-run authentication choice flows in local development:
|
||||
|
||||
```bash
|
||||
cd ExcaliDash/backend
|
||||
|
||||
# Preview what would change (no data modifications)
|
||||
npm run dev:simulate-auth-onboarding:dry-run
|
||||
|
||||
# Simulate "fresh install" onboarding state
|
||||
# (wipes drawings/collections/libraries and removes non-bootstrap users)
|
||||
npm run dev:simulate-auth-onboarding:fresh
|
||||
|
||||
# Simulate "migration" onboarding state (ensures legacy data exists)
|
||||
npm run dev:simulate-auth-onboarding:migration
|
||||
```
|
||||
|
||||
After running a simulation while the backend is already running, wait about 5 seconds
|
||||
(auth mode cache TTL) or restart the backend before refreshing the UI.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
+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
|
||||
- Added new backend/src/security.ts module for security utilities
|
||||
- Frontend API layer now handles CSRF tokens automatically
|
||||
- Added integration tests for CSRF validation
|
||||
Multi-user support for excalidash
|
||||
- Admin dashboard
|
||||
- Password reset, force user password reset (admin only), account lockout recovery
|
||||
- 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.
|
||||
```
|
||||
|
||||
@@ -9,3 +9,7 @@ dist
|
||||
*.log
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
src/generated
|
||||
coverage
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
|
||||
+25
-1
@@ -2,4 +2,28 @@
|
||||
PORT=8000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=file:/app/prisma/dev.db
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
FRONTEND_URL=https://draw.louiscreates.com
|
||||
API_BASE_PATH=/api
|
||||
# Keep disabled unless traffic always comes through a trusted reverse proxy.
|
||||
TRUST_PROXY=false
|
||||
AUTH_MODE=local
|
||||
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
|
||||
|
||||
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
|
||||
# OIDC_CLIENT_ID=your-client-id
|
||||
# OIDC_CLIENT_SECRET=your-client-secret
|
||||
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_EMAIL_CLAIM=email
|
||||
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
|
||||
# OIDC_REQUIRE_EMAIL_VERIFIED=true
|
||||
# OIDC_JIT_PROVISIONING=true
|
||||
# OIDC_FIRST_USER_ADMIN=true
|
||||
|
||||
+9
-6
@@ -3,12 +3,15 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Native build deps for modules that may compile from source (e.g., better-sqlite3 on arm64)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
# Copy prisma schema
|
||||
COPY prisma ./prisma/
|
||||
@@ -25,7 +28,7 @@ RUN npx tsc
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install OpenSSL for Prisma and su-exec, create non-root user
|
||||
# Install runtime packages and create non-root user
|
||||
RUN apk add --no-cache openssl su-exec && \
|
||||
addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
@@ -36,7 +39,10 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
||||
npm ci --omit=dev && \
|
||||
npm cache clean --force && \
|
||||
apk del .build-deps
|
||||
|
||||
# Copy prisma schema and migrations for runtime and hydration template
|
||||
COPY prisma ./prisma/
|
||||
@@ -48,9 +54,6 @@ COPY --from=builder /app/dist ./dist
|
||||
# Copy the generated Prisma Client from builder to maintain the same structure
|
||||
COPY --from=builder /app/src/generated ./dist/generated
|
||||
|
||||
# Generate Prisma Client in production (updates node_modules)
|
||||
RUN npx prisma generate
|
||||
|
||||
# Create necessary directories (ownership will be set in entrypoint)
|
||||
RUN mkdir -p /app/uploads /app/prisma
|
||||
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
|
||||
CSRF_SECRET_FILE="/app/prisma/.csrf_secret"
|
||||
|
||||
# Ensure JWT secret exists for production startup.
|
||||
# Backward compatibility: older installs may not have JWT_SECRET configured.
|
||||
if [ -z "${JWT_SECRET:-}" ]; then
|
||||
echo "JWT_SECRET not provided, resolving persisted secret..."
|
||||
if [ -f "${JWT_SECRET_FILE}" ]; then
|
||||
JWT_SECRET="$(tr -d '\r\n' < "${JWT_SECRET_FILE}")"
|
||||
fi
|
||||
|
||||
if [ -z "${JWT_SECRET}" ]; then
|
||||
echo "No persisted JWT secret found. Generating a new secret..."
|
||||
JWT_SECRET="$(openssl rand -hex 32)"
|
||||
umask 077
|
||||
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
|
||||
fi
|
||||
else
|
||||
# Persist explicitly provided secret to support future restarts without env injection.
|
||||
umask 077
|
||||
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
|
||||
fi
|
||||
|
||||
export JWT_SECRET
|
||||
|
||||
# Ensure CSRF secret exists for stable token validation across restarts.
|
||||
# (Still recommend setting explicitly for multi-instance deployments.)
|
||||
if [ -z "${CSRF_SECRET:-}" ]; then
|
||||
echo "CSRF_SECRET not provided, resolving persisted secret..."
|
||||
if [ -f "${CSRF_SECRET_FILE}" ]; then
|
||||
CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")"
|
||||
fi
|
||||
|
||||
if [ -z "${CSRF_SECRET}" ]; then
|
||||
echo "No persisted CSRF secret found. Generating a new secret..."
|
||||
CSRF_SECRET="$(openssl rand -base64 32)"
|
||||
umask 077
|
||||
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
||||
fi
|
||||
else
|
||||
umask 077
|
||||
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
||||
fi
|
||||
|
||||
export CSRF_SECRET
|
||||
|
||||
# 1. Hydrate volume if empty (Running as root)
|
||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||
echo "Mount is empty. Hydrating /app/prisma..."
|
||||
@@ -18,11 +64,13 @@ echo "Fixing filesystem permissions..."
|
||||
chown -R nodejs:nodejs /app/uploads
|
||||
chown -R nodejs:nodejs /app/prisma
|
||||
chmod 755 /app/uploads
|
||||
chmod 600 "${JWT_SECRET_FILE}"
|
||||
chmod 600 "${CSRF_SECRET_FILE}"
|
||||
|
||||
# Ensure database file has proper permissions
|
||||
if [ -f "/app/prisma/dev.db" ]; then
|
||||
echo "Database file found, ensuring write permissions..."
|
||||
chmod 666 /app/prisma/dev.db
|
||||
chmod 600 /app/prisma/dev.db
|
||||
fi
|
||||
|
||||
# 3. Run Migrations (Drop privileges to nodejs)
|
||||
|
||||
Generated
+391
-21
File diff suppressed because it is too large
Load Diff
+22
-5
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.6",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"predev": "node scripts/predev-migrate.cjs",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"admin:recover": "node scripts/admin-recover.cjs",
|
||||
"dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh",
|
||||
"dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration",
|
||||
"dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
@@ -15,27 +20,39 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/socket.io": "^3.0.1",
|
||||
"archiver": "^7.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.3.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "^2.0.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"prisma": "^5.22.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/socket.io": "^3.0.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"nodemon": "^3.1.11",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
Generated
+3783
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Track whether initial auth mode choice has been explicitly completed.
|
||||
ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthIdentity" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"issuer" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"emailAtLink" TEXT NOT NULL,
|
||||
"lastLoginAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId");
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "DrawingShareLink" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"drawingId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DrawingShareGrant" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"drawingId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"shareLinkId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
|
||||
@@ -12,12 +12,48 @@ datasource db {
|
||||
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)
|
||||
authIdentities AuthIdentity[]
|
||||
drawings Drawing[]
|
||||
collections Collection[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
drawingShareGrants DrawingShareGrant[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
id String @id @default("default")
|
||||
authEnabled Boolean @default(false)
|
||||
authOnboardingCompleted 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 {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
drawings Drawing[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
}
|
||||
|
||||
model Drawing {
|
||||
@@ -28,15 +64,103 @@ model Drawing {
|
||||
files String @default("{}") // Stored as JSON string
|
||||
preview String? // SVG string for thumbnail
|
||||
version Int @default(1)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
collectionId String?
|
||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||
shareLinks DrawingShareLink[]
|
||||
shareGrants DrawingShareGrant[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, collectionId, updatedAt])
|
||||
}
|
||||
|
||||
model DrawingShareLink {
|
||||
id String @id @default(uuid())
|
||||
drawingId String
|
||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
grants DrawingShareGrant[]
|
||||
|
||||
@@unique([drawingId, role])
|
||||
@@index([drawingId])
|
||||
}
|
||||
|
||||
model DrawingShareGrant {
|
||||
id String @id @default(uuid())
|
||||
drawingId String
|
||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shareLinkId String
|
||||
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([drawingId, userId, shareLinkId])
|
||||
@@index([drawingId, userId])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model Library {
|
||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
||||
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||
items String @default("[]") // Stored as JSON string array of library items
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted"
|
||||
resource String? // e.g., "drawing:123", "collection:456"
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
details String? // JSON string for additional details
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuthIdentity {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider String
|
||||
issuer String
|
||||
subject String
|
||||
emailAtLink String
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([issuer, subject])
|
||||
@@unique([provider, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user