Compare commits
29 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 |
@@ -0,0 +1 @@
|
||||
../../../frontend
|
||||
@@ -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
|
||||
@@ -101,6 +103,8 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
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/)
|
||||
@@ -123,6 +127,7 @@ 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. **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
|
||||
@@ -131,6 +136,8 @@ 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:
|
||||
@@ -160,6 +167,31 @@ backend:
|
||||
|
||||
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
|
||||
@@ -200,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
|
||||
|
||||
```
|
||||
|
||||
+18
-1
@@ -2,7 +2,11 @@
|
||||
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)
|
||||
@@ -10,3 +14,16 @@ JWT_SECRET=change-this-secret-in-production-min-32-chars
|
||||
# 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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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.
|
||||
@@ -25,6 +26,27 @@ 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..."
|
||||
@@ -43,11 +65,12 @@ 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
+63
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"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",
|
||||
@@ -3314,6 +3315,15 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "22.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||
@@ -3909,6 +3919,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -3932,6 +3951,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oidc-token-hash": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -3953,6 +3981,33 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -5789,6 +5844,12 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.4.2",
|
||||
"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"
|
||||
@@ -31,6 +34,7 @@
|
||||
"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",
|
||||
|
||||
Generated
+3783
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -21,11 +21,13 @@ model User {
|
||||
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
|
||||
}
|
||||
@@ -33,6 +35,7 @@ model User {
|
||||
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
|
||||
@@ -65,6 +68,8 @@ model Drawing {
|
||||
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
|
||||
|
||||
@@ -72,6 +77,38 @@ model Drawing {
|
||||
@@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 // User-specific library ID (e.g., "user_<userId>")
|
||||
items String @default("[]") // Stored as JSON string array of library items
|
||||
@@ -110,3 +147,20 @@ model AuditLog {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const resolveDatabaseUrl = (rawUrl) => {
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||
: path.resolve(
|
||||
hasLeadingPrismaDir ? backendRoot : prismaDir,
|
||||
normalizedRelative,
|
||||
);
|
||||
|
||||
return `file:${absolutePath}`;
|
||||
};
|
||||
@@ -91,7 +94,15 @@ const backupDbIfPresent = () => {
|
||||
const isNonProd = nodeEnv !== "production";
|
||||
const isFileDb = databaseUrl.startsWith("file:");
|
||||
|
||||
const deploy = runCapture("npx prisma migrate deploy");
|
||||
let deploy = runCapture("npx prisma migrate deploy");
|
||||
|
||||
if (!deploy.ok) {
|
||||
console.warn(
|
||||
`[predev] Prisma migrate deploy failed. Attempting pnpm exec...`,
|
||||
);
|
||||
deploy = runCapture("pnpm exec prisma migrate deploy");
|
||||
}
|
||||
|
||||
if (deploy.ok) {
|
||||
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
||||
} else {
|
||||
@@ -111,7 +122,16 @@ if (deploy.ok) {
|
||||
` If you need to preserve local data, restore the backup and baseline manually.`,
|
||||
);
|
||||
|
||||
run("npx prisma migrate reset --force --skip-seed");
|
||||
// check for npx, if not present try pnpm exec
|
||||
try {
|
||||
console.log(
|
||||
`[predev] Running: npx prisma migrate reset --force --skip-seed`,
|
||||
);
|
||||
run("npx prisma migrate reset --force --skip-seed");
|
||||
} catch {
|
||||
console.warn(`[predev] npx not found, trying pnpm exec...`);
|
||||
run("pnpm exec prisma migrate reset --force --skip-seed");
|
||||
}
|
||||
} else {
|
||||
throw deploy.error;
|
||||
}
|
||||
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require("dotenv").config();
|
||||
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
const { PrismaClient } = require("../src/generated/client");
|
||||
|
||||
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
||||
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
||||
const backendRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const resolveDatabaseUrl = (rawUrl) => {
|
||||
const backendRoot = path.resolve(__dirname, "..");
|
||||
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}`;
|
||||
};
|
||||
|
||||
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const parsed = {
|
||||
scenario: "",
|
||||
dryRun: false,
|
||||
allowProd: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--scenario") {
|
||||
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--allow-production") {
|
||||
parsed.allowProd = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
console.log(`Usage:
|
||||
node scripts/simulate-auth-onboarding.cjs --scenario fresh
|
||||
node scripts/simulate-auth-onboarding.cjs --scenario migration
|
||||
|
||||
Options:
|
||||
--dry-run Show what would change without modifying data
|
||||
--allow-production Override production safety check (not recommended)
|
||||
--help, -h Show this help
|
||||
`);
|
||||
};
|
||||
|
||||
const assertScenario = (scenario) => {
|
||||
if (scenario !== "fresh" && scenario !== "migration") {
|
||||
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
|
||||
}
|
||||
};
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const run = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
assertScenario(args.scenario);
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV || "development";
|
||||
if (nodeEnv === "production" && !args.allowProd) {
|
||||
throw new Error(
|
||||
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
|
||||
);
|
||||
}
|
||||
|
||||
// Keep migration history authoritative to avoid drift between db push and deploy.
|
||||
// Includes a self-heal path for the known duplicate-column failure on
|
||||
// 20260210153000_add_auth_onboarding_completed in local dev databases.
|
||||
if (nodeEnv !== "production") {
|
||||
const runDeploy = () =>
|
||||
execSync("npx prisma migrate deploy", {
|
||||
cwd: backendRoot,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
runDeploy();
|
||||
} catch (error) {
|
||||
const stdout =
|
||||
error && error.stdout
|
||||
? Buffer.isBuffer(error.stdout)
|
||||
? error.stdout.toString("utf8")
|
||||
: String(error.stdout)
|
||||
: "";
|
||||
const stderr =
|
||||
error && error.stderr
|
||||
? Buffer.isBuffer(error.stderr)
|
||||
? error.stderr.toString("utf8")
|
||||
: String(error.stderr)
|
||||
: "";
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
|
||||
const canAutoResolve =
|
||||
combined.includes("Error: P3009") &&
|
||||
combined.includes("20260210153000_add_auth_onboarding_completed") &&
|
||||
combined.includes("duplicate column name: authOnboardingCompleted");
|
||||
|
||||
if (!canAutoResolve) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
execSync(
|
||||
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
|
||||
{
|
||||
cwd: backendRoot,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
}
|
||||
);
|
||||
runDeploy();
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
const before = {
|
||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
||||
users: await prisma.user.count(),
|
||||
drawings: await prisma.drawing.count(),
|
||||
collections: await prisma.collection.count(),
|
||||
auth: await prisma.systemConfig.findUnique({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
select: {
|
||||
authEnabled: true,
|
||||
authOnboardingCompleted: true,
|
||||
registrationEnabled: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
|
||||
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
|
||||
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
|
||||
console.log("[simulate-auth-onboarding] before:", before);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.systemConfig.upsert({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: {
|
||||
authEnabled: false,
|
||||
authOnboardingCompleted: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
create: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
authOnboardingCompleted: false,
|
||||
registrationEnabled: false,
|
||||
authLoginRateLimitEnabled: true,
|
||||
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
||||
authLoginRateLimitMax: 20,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.updateMany({
|
||||
data: {
|
||||
isActive: false,
|
||||
mustResetPassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.upsert({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
update: {
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
create: {
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (args.scenario === "fresh") {
|
||||
await tx.drawing.deleteMany({});
|
||||
await tx.collection.deleteMany({});
|
||||
await tx.library.deleteMany({});
|
||||
await tx.user.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
not: BOOTSTRAP_USER_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Migration simulation:
|
||||
// 1) Reassign existing data ownership to bootstrap user
|
||||
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
|
||||
await tx.collection.updateMany({
|
||||
data: { userId: BOOTSTRAP_USER_ID },
|
||||
});
|
||||
await tx.drawing.updateMany({
|
||||
data: { userId: BOOTSTRAP_USER_ID },
|
||||
});
|
||||
|
||||
const collectionCount = await tx.collection.count();
|
||||
let targetCollectionId = null;
|
||||
|
||||
if (collectionCount === 0) {
|
||||
targetCollectionId = `sim-migration-col-${Date.now()}`;
|
||||
await tx.collection.create({
|
||||
data: {
|
||||
id: targetCollectionId,
|
||||
name: "Migrated Collection",
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const existing = await tx.collection.findFirst({
|
||||
where: { userId: BOOTSTRAP_USER_ID },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
targetCollectionId = existing ? existing.id : null;
|
||||
}
|
||||
|
||||
const drawingCount = await tx.drawing.count();
|
||||
if (drawingCount === 0) {
|
||||
await tx.drawing.create({
|
||||
data: {
|
||||
id: `sim-migration-draw-${Date.now()}`,
|
||||
name: "Migrated Drawing",
|
||||
elements: "[]",
|
||||
appState: "{}",
|
||||
files: "{}",
|
||||
preview: null,
|
||||
version: 1,
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
collectionId: targetCollectionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const after = {
|
||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
||||
users: await prisma.user.count(),
|
||||
drawings: await prisma.drawing.count(),
|
||||
collections: await prisma.collection.count(),
|
||||
auth: await prisma.systemConfig.findUnique({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
select: {
|
||||
authEnabled: true,
|
||||
authOnboardingCompleted: true,
|
||||
registrationEnabled: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[simulate-auth-onboarding] after:", after);
|
||||
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
|
||||
console.log(
|
||||
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("simulate-auth-onboarding failed:", error.message || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import { StringValue } from "ms";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import { config } from "../config";
|
||||
import { getTestPrisma, setupTestDb } from "./testUtils";
|
||||
|
||||
describe("Auth Enabled Toggle Authorization", () => {
|
||||
const userAgent = "vitest-auth-enabled";
|
||||
let prisma: PrismaClient;
|
||||
let app: any;
|
||||
let agent: any;
|
||||
let csrfHeaderName: string;
|
||||
let csrfToken: string;
|
||||
let regularUserToken: string;
|
||||
let adminUserToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
setupTestDb();
|
||||
prisma = getTestPrisma();
|
||||
|
||||
({ app } = await import("../index"));
|
||||
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { id: "default" },
|
||||
update: {
|
||||
authEnabled: true,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
create: {
|
||||
id: "default",
|
||||
authEnabled: true,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const passwordHash = await bcrypt.hash("password123", 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: "regular-user@test.local",
|
||||
passwordHash,
|
||||
name: "Regular User",
|
||||
role: "USER",
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signOptions: SignOptions = {
|
||||
expiresIn: config.jwtAccessExpiresIn as StringValue,
|
||||
};
|
||||
regularUserToken = jwt.sign(
|
||||
{ userId: user.id, email: user.email, type: "access" },
|
||||
config.jwtSecret,
|
||||
signOptions
|
||||
);
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: "admin-user@test.local",
|
||||
passwordHash,
|
||||
name: "Admin User",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
adminUserToken = jwt.sign(
|
||||
{ userId: admin.id, email: admin.email, type: "access" },
|
||||
config.jwtSecret,
|
||||
signOptions
|
||||
);
|
||||
|
||||
agent = request.agent(app);
|
||||
const csrfRes = await agent
|
||||
.get("/csrf-token")
|
||||
.set("User-Agent", userAgent);
|
||||
csrfHeaderName = csrfRes.body.header;
|
||||
csrfToken = csrfRes.body.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
|
||||
const response = await agent
|
||||
.post("/auth/auth-enabled")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({ enabled: false });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects non-admin auth-enabled toggle", async () => {
|
||||
const response = await agent
|
||||
.post("/auth/auth-enabled")
|
||||
.set("User-Agent", userAgent)
|
||||
.set("Authorization", `Bearer ${regularUserToken}`)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({ enabled: false });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body?.message).toContain("Admin access required");
|
||||
});
|
||||
|
||||
it("applies auth mode change immediately for subsequent requests", async () => {
|
||||
const warmStatusResponse = await request(app)
|
||||
.get("/auth/status")
|
||||
.set("User-Agent", userAgent);
|
||||
expect(warmStatusResponse.status).toBe(200);
|
||||
expect(warmStatusResponse.body?.authEnabled).toBe(true);
|
||||
|
||||
const toggleResponse = await agent
|
||||
.post("/auth/auth-enabled")
|
||||
.set("User-Agent", userAgent)
|
||||
.set("Authorization", `Bearer ${adminUserToken}`)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({ enabled: false });
|
||||
expect(toggleResponse.status).toBe(200);
|
||||
expect(toggleResponse.body?.authEnabled).toBe(false);
|
||||
|
||||
const drawingsResponse = await request(app)
|
||||
.get("/drawings")
|
||||
.set("User-Agent", userAgent);
|
||||
expect(drawingsResponse.status).toBe(200);
|
||||
expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import { getTestPrisma, setupTestDb } from "./testUtils";
|
||||
import { BOOTSTRAP_USER_ID } from "../auth/authMode";
|
||||
|
||||
describe("Auth onboarding decision", () => {
|
||||
const userAgent = "vitest-auth-onboarding";
|
||||
let prisma: PrismaClient;
|
||||
let app: any;
|
||||
let agent: any;
|
||||
let csrfHeaderName: string;
|
||||
let csrfToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
setupTestDb();
|
||||
prisma = getTestPrisma();
|
||||
|
||||
({ app } = await import("../index"));
|
||||
|
||||
agent = request.agent(app);
|
||||
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
|
||||
csrfHeaderName = csrfRes.body.header;
|
||||
csrfToken = csrfRes.body.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("reports migration onboarding mode when no active users and legacy data exists", async () => {
|
||||
await prisma.user.upsert({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
username: null,
|
||||
passwordHash: "",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
mustResetPassword: true,
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { id: "default" },
|
||||
update: { authEnabled: false, authOnboardingCompleted: false },
|
||||
create: {
|
||||
id: "default",
|
||||
authEnabled: false,
|
||||
authOnboardingCompleted: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.collection.upsert({
|
||||
where: { id: "legacy-collection" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "legacy-collection",
|
||||
name: "Legacy",
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.drawing.upsert({
|
||||
where: { id: "legacy-drawing" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "legacy-drawing",
|
||||
name: "Legacy Drawing",
|
||||
elements: "[]",
|
||||
appState: "{}",
|
||||
files: "{}",
|
||||
userId: BOOTSTRAP_USER_ID,
|
||||
collectionId: "legacy-collection",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body?.authEnabled).toBe(false);
|
||||
expect(response.body?.authOnboardingRequired).toBe(true);
|
||||
expect(response.body?.authOnboardingMode).toBe("migration");
|
||||
});
|
||||
|
||||
it("persists a single-user onboarding choice", async () => {
|
||||
await prisma.systemConfig.update({
|
||||
where: { id: "default" },
|
||||
data: { authEnabled: false, authOnboardingCompleted: false },
|
||||
});
|
||||
|
||||
const choiceResponse = await agent
|
||||
.post("/auth/onboarding-choice")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({ enableAuth: false });
|
||||
|
||||
expect(choiceResponse.status).toBe(200);
|
||||
expect(choiceResponse.body?.authEnabled).toBe(false);
|
||||
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
|
||||
|
||||
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
||||
expect(statusResponse.status).toBe(200);
|
||||
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("enables auth and bootstrap flow from onboarding choice", async () => {
|
||||
await prisma.drawing.deleteMany({});
|
||||
await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } });
|
||||
await prisma.systemConfig.update({
|
||||
where: { id: "default" },
|
||||
data: { authEnabled: false, authOnboardingCompleted: false },
|
||||
});
|
||||
|
||||
const choiceResponse = await agent
|
||||
.post("/auth/onboarding-choice")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({ enableAuth: true });
|
||||
|
||||
expect(choiceResponse.status).toBe(200);
|
||||
expect(choiceResponse.body?.authEnabled).toBe(true);
|
||||
expect(choiceResponse.body?.bootstrapRequired).toBe(true);
|
||||
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
|
||||
|
||||
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
||||
expect(statusResponse.status).toBe(200);
|
||||
expect(statusResponse.body?.authEnabled).toBe(true);
|
||||
expect(statusResponse.body?.bootstrapRequired).toBe(true);
|
||||
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("requires CSRF token for bootstrap registration", async () => {
|
||||
const noCsrfResponse = await agent
|
||||
.post("/auth/register")
|
||||
.set("User-Agent", userAgent)
|
||||
.send({
|
||||
email: "bootstrap-admin@test.local",
|
||||
password: "StrongPass1!",
|
||||
name: "Bootstrap Admin",
|
||||
});
|
||||
|
||||
expect(noCsrfResponse.status).toBe(403);
|
||||
expect(noCsrfResponse.body?.error).toBe("CSRF token missing");
|
||||
|
||||
const bootstrapResponse = await agent
|
||||
.post("/auth/register")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.send({
|
||||
email: "bootstrap-admin@test.local",
|
||||
password: "StrongPass1!",
|
||||
name: "Bootstrap Admin",
|
||||
});
|
||||
|
||||
expect(bootstrapResponse.status).toBe(201);
|
||||
expect(bootstrapResponse.body?.bootstrapped).toBe(true);
|
||||
expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCsrfToken, validateCsrfToken } from "../security";
|
||||
|
||||
describe("CSRF client identity stability", () => {
|
||||
it("keeps token validation stable when using cookie-based client IDs", () => {
|
||||
const cookieClientId = "cookie:fixed-client-id";
|
||||
const token = createCsrfToken(cookieClientId);
|
||||
|
||||
expect(validateCsrfToken(cookieClientId, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows why legacy IP-based IDs are unstable across proxy hops", () => {
|
||||
const userAgent = "Mozilla/5.0 test";
|
||||
const clientIdViaProxyA = `10.0.0.5:${userAgent}`;
|
||||
const clientIdViaProxyB = `10.0.0.6:${userAgent}`;
|
||||
const token = createCsrfToken(clientIdViaProxyA);
|
||||
|
||||
expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeDrawingData - preview svg handling", () => {
|
||||
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
|
||||
const preview = [
|
||||
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
|
||||
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
|
||||
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const result = sanitizeDrawingData({
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {},
|
||||
preview,
|
||||
});
|
||||
|
||||
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
|
||||
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
|
||||
expect(result.preview).toContain('stroke-linecap="round"');
|
||||
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
});
|
||||
|
||||
it("should preserve safe embedded image previews", () => {
|
||||
const preview = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
|
||||
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const result = sanitizeDrawingData({
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {},
|
||||
preview,
|
||||
});
|
||||
|
||||
expect(result.preview).toContain("<image");
|
||||
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("should remove embedded images with unsafe href values", () => {
|
||||
const preview = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
|
||||
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
|
||||
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const result = sanitizeDrawingData({
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {},
|
||||
preview,
|
||||
});
|
||||
|
||||
expect(result.preview).not.toContain("<image");
|
||||
expect(result.preview).toContain("<rect");
|
||||
});
|
||||
|
||||
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
|
||||
const preview = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
|
||||
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
|
||||
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
|
||||
"</pattern></defs>",
|
||||
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const result = sanitizeDrawingData({
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {},
|
||||
preview,
|
||||
});
|
||||
|
||||
expect(result.preview).toContain("<defs>");
|
||||
expect(result.preview).toContain("<pattern");
|
||||
expect(result.preview).toContain('id="p1"');
|
||||
expect(result.preview).toContain("<image");
|
||||
expect(result.preview).toContain('fill="url(#p1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImportedDrawing - with files", () => {
|
||||
it("should validate drawing with embedded images", () => {
|
||||
const files = createSampleFilesObject(2, "large");
|
||||
|
||||
@@ -3,6 +3,7 @@ import request from "supertest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import JSZip from "jszip";
|
||||
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
|
||||
|
||||
type LegacyDbOptions = {
|
||||
@@ -156,11 +157,117 @@ const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
|
||||
const dir = createTempDir();
|
||||
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
|
||||
const zip = new JSZip();
|
||||
|
||||
const manifest = {
|
||||
format: "excalidash",
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
unorganizedFolder: "Unorganized",
|
||||
collections: [] as any[],
|
||||
drawings: [
|
||||
{
|
||||
id: "duplicate-drawing-id",
|
||||
name: "Drawing One",
|
||||
filePath: "Unorganized/drawing-1.excalidraw",
|
||||
collectionId: null,
|
||||
},
|
||||
{
|
||||
id: "duplicate-drawing-id",
|
||||
name: "Drawing Two",
|
||||
filePath: "Unorganized/drawing-2.excalidraw",
|
||||
collectionId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
|
||||
zip.file(
|
||||
"Unorganized/drawing-1.excalidraw",
|
||||
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||||
);
|
||||
zip.file(
|
||||
"Unorganized/drawing-2.excalidraw",
|
||||
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||||
);
|
||||
|
||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
|
||||
const dir = createTempDir();
|
||||
const filePath = path.join(dir, "legacy-duplicate-ids.db");
|
||||
const db = openWritableDb(filePath);
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE "Drawing" (
|
||||
id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
elements TEXT NOT NULL,
|
||||
appState TEXT NOT NULL,
|
||||
files TEXT,
|
||||
preview TEXT,
|
||||
version INTEGER,
|
||||
collectionId TEXT,
|
||||
collectionName TEXT,
|
||||
createdAt TEXT,
|
||||
updatedAt TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
|
||||
const insertDrawing = db.prepare(
|
||||
`INSERT INTO "Drawing"
|
||||
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
insertDrawing.run(
|
||||
"legacy-duplicate-id",
|
||||
"Legacy Drawing A",
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({}),
|
||||
JSON.stringify({}),
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
insertDrawing.run(
|
||||
"legacy-duplicate-id",
|
||||
"Legacy Drawing B",
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({}),
|
||||
JSON.stringify({}),
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return filePath;
|
||||
};
|
||||
|
||||
describe("Import compatibility (legacy exports)", () => {
|
||||
const uploadsDir = path.resolve(__dirname, "../../uploads");
|
||||
const userAgent = "vitest-import-compat";
|
||||
let prisma: ReturnType<typeof getTestPrisma>;
|
||||
let app: any;
|
||||
let agent: any;
|
||||
let csrfHeaderName: string;
|
||||
let csrfToken: string;
|
||||
|
||||
@@ -172,7 +279,8 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
|
||||
({ app } = await import("../index"));
|
||||
|
||||
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
|
||||
agent = request.agent(app);
|
||||
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
|
||||
csrfHeaderName = csrfRes.body.header;
|
||||
csrfToken = csrfRes.body.token;
|
||||
expect(typeof csrfHeaderName).toBe("string");
|
||||
@@ -195,7 +303,7 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
includeTrashDrawing: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy/verify")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
@@ -217,7 +325,7 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
includeTrashDrawing: true,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
@@ -239,7 +347,9 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
|
||||
);
|
||||
|
||||
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
|
||||
const trash = await prisma.collection.findUnique({
|
||||
where: { id: "trash:bootstrap-admin" },
|
||||
});
|
||||
expect(trash).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -251,7 +361,7 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
includeTrashDrawing: false,
|
||||
});
|
||||
|
||||
const verify = await request(app)
|
||||
const verify = await agent
|
||||
.post("/import/sqlite/legacy/verify")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
@@ -261,7 +371,7 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
expect(verify.body.drawings).toBe(2);
|
||||
expect(verify.body.collections).toBe(1);
|
||||
|
||||
const res = await request(app)
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
@@ -278,7 +388,7 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
|
||||
db.close();
|
||||
|
||||
const res = await request(app)
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy/verify")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
@@ -287,4 +397,52 @@ describe("Import compatibility (legacy exports)", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Invalid legacy DB");
|
||||
});
|
||||
|
||||
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
|
||||
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||||
const res = await agent
|
||||
.post("/import/excalidash/verify")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.attach("archive", archive);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||
});
|
||||
|
||||
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
|
||||
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||||
const res = await agent
|
||||
.post("/import/excalidash")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.attach("archive", archive);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||
});
|
||||
|
||||
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
|
||||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy/verify")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.attach("db", legacyDb);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||
});
|
||||
|
||||
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
|
||||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||||
const res = await agent
|
||||
.post("/import/sqlite/legacy")
|
||||
.set("User-Agent", userAgent)
|
||||
.set(csrfHeaderName, csrfToken)
|
||||
.attach("db", legacyDb);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeDrawingUpdateData } from "../index";
|
||||
|
||||
describe("sanitizeDrawingUpdateData regression", () => {
|
||||
it("does not inject empty scene fields for preview-only updates", () => {
|
||||
const payload: {
|
||||
preview?: string | null;
|
||||
elements?: unknown[];
|
||||
appState?: Record<string, unknown>;
|
||||
files?: Record<string, unknown>;
|
||||
} = {
|
||||
preview: "<svg><rect width=\"10\" height=\"10\"/></svg>",
|
||||
};
|
||||
|
||||
const ok = sanitizeDrawingUpdateData(payload);
|
||||
expect(ok).toBe(true);
|
||||
expect(typeof payload.preview).toBe("string");
|
||||
expect(String(payload.preview)).toContain("<svg");
|
||||
expect(payload.elements).toBeUndefined();
|
||||
expect(payload.appState).toBeUndefined();
|
||||
expect(payload.files).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still sanitizes scene fields when scene data is provided", () => {
|
||||
const payload: {
|
||||
preview?: string | null;
|
||||
elements?: any[];
|
||||
appState?: Record<string, unknown>;
|
||||
files?: Record<string, unknown>;
|
||||
} = {
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
isDeleted: false,
|
||||
},
|
||||
],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {},
|
||||
preview: "<svg/>",
|
||||
};
|
||||
|
||||
const ok = sanitizeDrawingUpdateData(payload);
|
||||
expect(ok).toBe(true);
|
||||
expect(Array.isArray(payload.elements)).toBe(true);
|
||||
expect(typeof payload.appState).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,11 +98,9 @@ export const setupTestDb = () => {
|
||||
* Clean up the test database between tests
|
||||
*/
|
||||
export const cleanupTestDb = async (prisma: PrismaClient) => {
|
||||
// Delete all drawings and collections (except Trash)
|
||||
// Delete all drawings and collections.
|
||||
await prisma.drawing.deleteMany({});
|
||||
await prisma.collection.deleteMany({
|
||||
where: { id: { not: "trash" } },
|
||||
});
|
||||
await prisma.collection.deleteMany({});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -129,14 +127,15 @@ export const createTestUser = async (prisma: PrismaClient, email: string = "test
|
||||
export const initTestDb = async (prisma: PrismaClient) => {
|
||||
// Create a test user first
|
||||
const testUser = await createTestUser(prisma);
|
||||
const trashCollectionId = `trash:${testUser.id}`;
|
||||
|
||||
// Ensure Trash collection exists
|
||||
const trash = await prisma.collection.findUnique({
|
||||
where: { id: "trash" },
|
||||
const trash = await prisma.collection.findFirst({
|
||||
where: { id: trashCollectionId, userId: testUser.id },
|
||||
});
|
||||
if (!trash) {
|
||||
await prisma.collection.create({
|
||||
data: { id: "trash", name: "Trash", userId: testUser.id },
|
||||
data: { id: trashCollectionId, name: "Trash", userId: testUser.id },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+402
-308
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import {
|
||||
updateEmailSchema,
|
||||
updateProfileSchema,
|
||||
} from "./schemas";
|
||||
import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity";
|
||||
|
||||
type RegisterAccountRoutesDeps = {
|
||||
router: express.Router;
|
||||
@@ -33,6 +34,12 @@ type RegisterAccountRoutesDeps = {
|
||||
options?: { impersonatorId?: string }
|
||||
) => { accessToken: string; refreshToken: string };
|
||||
getRefreshTokenExpiresAt: () => Date;
|
||||
setAuthCookies: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
tokens: { accessToken: string; refreshToken: string }
|
||||
) => void;
|
||||
requireCsrf: (req: Request, res: Response) => boolean;
|
||||
};
|
||||
|
||||
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
@@ -47,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
config,
|
||||
generateTokens,
|
||||
getRefreshTokenExpiresAt,
|
||||
setAuthCookies,
|
||||
requireCsrf,
|
||||
} = deps;
|
||||
|
||||
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
|
||||
@@ -81,7 +90,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
});
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { userId: user.id, token: resetToken, expiresAt },
|
||||
data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt },
|
||||
});
|
||||
|
||||
if (config.enableAuditLogging) {
|
||||
@@ -137,8 +146,10 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
}
|
||||
|
||||
const { token, password } = parsed.data;
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
const resetToken = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })),
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
@@ -207,6 +218,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
@@ -258,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
}
|
||||
@@ -344,11 +357,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
|
||||
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||
if (config.enableRefreshTokenRotation) {
|
||||
const expiresAt = getRefreshTokenExpiresAt();
|
||||
try {
|
||||
await prisma.refreshToken.create({
|
||||
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
|
||||
data: {
|
||||
userId: updatedUser.id,
|
||||
token: hashTokenForStorage(refreshToken),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -380,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
}
|
||||
@@ -456,6 +475,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
}
|
||||
@@ -521,11 +541,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
|
||||
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||
if (config.enableRefreshTokenRotation) {
|
||||
const expiresAt = getRefreshTokenExpiresAt();
|
||||
try {
|
||||
await prisma.refreshToken.create({
|
||||
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
|
||||
data: {
|
||||
userId: updatedUser.id,
|
||||
token: hashTokenForStorage(refreshToken),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
loginRateLimitUpdateSchema,
|
||||
registrationToggleSchema,
|
||||
} from "./schemas";
|
||||
import { hashTokenForStorage } from "./tokenSecurity";
|
||||
|
||||
type RegisterAdminRoutesDeps = {
|
||||
router: express.Router;
|
||||
@@ -63,6 +64,12 @@ type RegisterAdminRoutesDeps = {
|
||||
enableRefreshTokenRotation: boolean;
|
||||
};
|
||||
defaultSystemConfigId: string;
|
||||
setAuthCookies: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
tokens: { accessToken: string; refreshToken: string }
|
||||
) => void;
|
||||
requireCsrf: (req: Request, res: Response) => boolean;
|
||||
};
|
||||
|
||||
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
@@ -85,11 +92,56 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
getRefreshTokenExpiresAt,
|
||||
config,
|
||||
defaultSystemConfigId,
|
||||
setAuthCookies,
|
||||
requireCsrf,
|
||||
} = deps;
|
||||
|
||||
const resolveImpersonationAdmin = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (req.user.role === "ADMIN") {
|
||||
return {
|
||||
id: req.user.id,
|
||||
email: req.user.email,
|
||||
name: req.user.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (!req.user.impersonatorId) {
|
||||
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const impersonator = await prisma.user.findUnique({
|
||||
where: { id: req.user.impersonatorId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") {
|
||||
res.status(403).json({ error: "Forbidden", message: "Admin access required" });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: impersonator.id,
|
||||
email: impersonator.email,
|
||||
name: impersonator.name,
|
||||
};
|
||||
};
|
||||
|
||||
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const parsed = registrationToggleSchema.safeParse(req.body);
|
||||
@@ -116,6 +168,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const parsed = adminRoleUpdateSchema.safeParse(req.body);
|
||||
@@ -199,6 +252,42 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
const actingAdmin = await resolveImpersonationAdmin(req, res);
|
||||
if (!actingAdmin) return;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { isActive: true, id: { not: actingAdmin.id } },
|
||||
orderBy: [{ name: "asc" }, { email: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
users,
|
||||
impersonator: {
|
||||
id: actingAdmin.id,
|
||||
email: actingAdmin.email,
|
||||
name: actingAdmin.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("List impersonation targets error:", error);
|
||||
res.status(500).json({
|
||||
error: "Internal server error",
|
||||
message: "Failed to list impersonation targets",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
@@ -219,6 +308,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const parsed = loginRateLimitUpdateSchema.safeParse(req.body);
|
||||
@@ -264,6 +354,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const parsed = loginRateLimitResetSchema.safeParse(req.body);
|
||||
@@ -301,10 +392,21 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const parsed = adminCreateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
const summarizedIssues = parsed.error.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
}));
|
||||
console.warn("[auth/users] validation failed", {
|
||||
issues: summarizedIssues,
|
||||
requestId: req.headers["x-request-id"],
|
||||
ip: req.ip || req.connection.remoteAddress || "unknown",
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Validation error",
|
||||
message: "Invalid user payload",
|
||||
@@ -385,6 +487,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const userId = String(req.params.id || "").trim();
|
||||
@@ -493,6 +596,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
if (req.user.impersonatorId) {
|
||||
@@ -582,7 +686,9 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await ensureAuthEnabled(res))) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireCsrf(req, res)) return;
|
||||
const actingAdmin = await resolveImpersonationAdmin(req, res);
|
||||
if (!actingAdmin) return;
|
||||
|
||||
const parsed = impersonateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -598,19 +704,27 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
return res.status(404).json({ error: "Not found", message: "User not found" });
|
||||
}
|
||||
|
||||
if (target.id === actingAdmin.id) {
|
||||
return res.status(409).json({
|
||||
error: "Conflict",
|
||||
message: "Already using the admin account. Use stop impersonation to return.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!target.isActive) {
|
||||
return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
|
||||
impersonatorId: req.user.id,
|
||||
impersonatorId: actingAdmin.id,
|
||||
});
|
||||
setAuthCookies(req, res, { accessToken, refreshToken });
|
||||
|
||||
if (config.enableRefreshTokenRotation) {
|
||||
const expiresAt = getRefreshTokenExpiresAt();
|
||||
try {
|
||||
await prisma.refreshToken.create({
|
||||
data: { userId: target.id, token: refreshToken, expiresAt },
|
||||
data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt },
|
||||
});
|
||||
} catch {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -621,12 +735,12 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
||||
|
||||
if (config.enableAuditLogging) {
|
||||
await logAuditEvent({
|
||||
userId: req.user.id,
|
||||
userId: actingAdmin.id,
|
||||
action: "impersonation_started",
|
||||
resource: `user:${target.id}`,
|
||||
ipAddress: req.ip || req.connection.remoteAddress || undefined,
|
||||
userAgent: req.headers["user-agent"] || undefined,
|
||||
details: { targetUserId: target.id },
|
||||
details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import {
|
||||
BOOTSTRAP_USER_ID,
|
||||
DEFAULT_SYSTEM_CONFIG_ID,
|
||||
createAuthModeService,
|
||||
} from "./authMode";
|
||||
|
||||
const createPrismaMock = () =>
|
||||
({
|
||||
systemConfig: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
|
||||
describe("authMode service", () => {
|
||||
let now = 1_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caches authEnabled reads within TTL", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||
findUnique
|
||||
.mockResolvedValueOnce({ authEnabled: true })
|
||||
.mockResolvedValueOnce({ authEnabled: false });
|
||||
|
||||
const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 });
|
||||
|
||||
await expect(service.getAuthEnabled()).resolves.toBe(true);
|
||||
|
||||
now += 1000;
|
||||
await expect(service.getAuthEnabled()).resolves.toBe(true);
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 6000;
|
||||
await expect(service.getAuthEnabled()).resolves.toBe(false);
|
||||
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears auth cache when requested", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||
findUnique.mockResolvedValue({ authEnabled: true });
|
||||
|
||||
const service = createAuthModeService(prisma);
|
||||
await service.getAuthEnabled();
|
||||
service.clearAuthEnabledCache();
|
||||
await service.getAuthEnabled();
|
||||
|
||||
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to upsert when system config row is missing", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||
findUnique.mockResolvedValue(null);
|
||||
upsert.mockResolvedValue({ authEnabled: false });
|
||||
|
||||
const service = createAuthModeService(prisma);
|
||||
await expect(service.getAuthEnabled()).resolves.toBe(false);
|
||||
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates/bootstrap user via upsert", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const userUpsert = prisma.user.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||
userUpsert.mockResolvedValue({
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
name: "Bootstrap Admin",
|
||||
role: "ADMIN",
|
||||
isActive: false,
|
||||
mustResetPassword: true,
|
||||
username: null,
|
||||
});
|
||||
|
||||
const service = createAuthModeService(prisma);
|
||||
const bootstrapUser = await service.getBootstrapActingUser();
|
||||
|
||||
expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID);
|
||||
expect(userUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: BOOTSTRAP_USER_ID },
|
||||
create: expect.objectContaining({
|
||||
id: BOOTSTRAP_USER_ID,
|
||||
email: "bootstrap@excalidash.local",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ensures system config defaults", async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const upsert = prisma.systemConfig.upsert as unknown as ReturnType<typeof vi.fn>;
|
||||
upsert.mockResolvedValue({ authEnabled: false });
|
||||
|
||||
const service = createAuthModeService(prisma);
|
||||
await service.ensureSystemConfig();
|
||||
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
create: expect.objectContaining({
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
registrationEnabled: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user