Compare commits

..

2 Commits

Author SHA1 Message Date
Zimeng Xiong e9d349bb0e fix express proxy headers 2026-01-30 14:28:38 -08:00
Zimeng Xiong 6a84cc4ab7 repro issue 2026-01-30 14:19:24 -08:00
86 changed files with 1605 additions and 12400 deletions
-6
View File
@@ -7,9 +7,3 @@ dist
.env
.DS_Store
*.log
backend
frontend/node_modules
frontend/dist
frontend/coverage
frontend/test-results
frontend/playwright-report
+2 -2
View File
@@ -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:6767" npm run dev &
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" 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:6767 > /dev/null; then
if curl -s http://localhost:5173 > /dev/null; then
echo "Frontend is ready!"
break
fi
-69
View File
@@ -1,69 +0,0 @@
# Fork Summary
This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags.
## Security Features Added
1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`)
2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use
3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance
## UX Improvements Added
1. **Profile Page** - View and edit personal information, change password (`/profile`)
2. **Select All Button** - Quick selection of all drawings in current view
3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle
4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle)
## Backward Compatibility
✅ All security features disabled by default
✅ No breaking changes to existing code
✅ Graceful degradation (missing tables don't cause errors)
✅ Optional database migration
## Enable Security Features
Set in `backend/.env`:
```bash
ENABLE_PASSWORD_RESET=true
ENABLE_REFRESH_TOKEN_ROTATION=true
ENABLE_AUDIT_LOGGING=true
```
Then run migration:
```bash
cd backend && npx prisma migrate deploy
```
## Migration Strategy
**For base project:** Keep features disabled (default) - no migration needed, zero risk.
**For this fork:** Enable features via environment variables when ready.
## Database Changes
Migration adds 3 optional tables (only used when features enabled):
- `PasswordResetToken` - For password reset flow
- `RefreshToken` - For token rotation tracking
- `AuditLog` - For security event logging
## Code Changes
### Backend
- Feature flags in `backend/src/config.ts`
- Conditional logic in auth endpoints
- Graceful error handling for missing tables
- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST)
- Audit logging utility (`backend/src/utils/audit.ts`)
### Frontend
- Password reset pages (`/reset-password`, `/reset-password-confirm`)
- Profile page (`/profile`)
- Select All button in Dashboard
- Sort dropdown with icons
- Auto-hide header in Editor with toggle
- Updated API client for token rotation
All changes are backward compatible and optional.
+2 -8
View File
@@ -99,8 +99,6 @@ 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.
## Docker Build
[Install Docker](https://docs.docker.com/desktop/)
@@ -122,17 +120,14 @@ 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.
- `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.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
```yaml
# docker-compose.yml example
backend:
environment:
# Single URL
- FRONTEND_URL=https://excalidash.example.com
# Or multiple URLs (comma-separated) for local + network access
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
frontend:
environment:
# For standard Docker Compose (default)
@@ -143,7 +138,7 @@ frontend:
### Multi-Container / Kubernetes Deployments
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.
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
```bash
# Generate a secure secret
@@ -154,7 +149,6 @@ 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
```
+40 -6
View File
@@ -1,9 +1,43 @@
Multi user setup is opt-in, single user by default
CSRF Protection (8a78b2b)
Multi-user support for excalidash
- Admin dashboard
- Password reset, force user password reset (admin only), account lockout recovery
- Rate limits
- 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
Deprecates .json and .sqlite database backups in favor of .excalidash archives (user scoped, prevents exporting of senstive information). Legacy import is maintained.
Upload Progress Indicator (8f9b9b4)
- Added a visual upload progress bar when users upload files
- New UploadContext for managing upload state across components
- New UploadStatus component displaying real-time upload progress
- Save status indicator when navigating back from the editor
- Improved error handling and recovery for failed uploads
Bug Fixes
- Fixed broken e2e tests (cae8f3c)
- Replaced deprecated substr() with substring()
- Fixed stale state issues in error handling
- Fixed missing useEffect dependencies
- Fixed CSS class conflicts in progress bar styling
- Added error recovery for save state in Editor
Infrastructure
- Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README
### Kubernetes
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
```bash
openssl rand -base64 32
Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
If not set, the backend will refuse to start.
```
+1 -1
View File
@@ -1 +1 @@
0.4.6
0.3.1
-4
View File
@@ -9,7 +9,3 @@ dist
*.log
prisma/dev.db
prisma/dev.db-journal
src/generated
coverage
*.test.ts
*.spec.ts
+1 -8
View File
@@ -2,11 +2,4 @@
PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
# Set to "true" or "1" to enable:
# ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false
FRONTEND_URL=http://localhost:6767
+6 -9
View File
@@ -3,15 +3,12 @@ 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 && npm cache clean --force
RUN npm ci
# Copy prisma schema
COPY prisma ./prisma/
@@ -28,7 +25,7 @@ RUN npx tsc
# Production stage
FROM node:20-alpine
# Install runtime packages and create non-root user
# Install OpenSSL for Prisma and su-exec, create non-root user
RUN apk add --no-cache openssl su-exec && \
addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
@@ -39,10 +36,7 @@ WORKDIR /app
COPY package*.json ./
# Install production dependencies only
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
npm ci --omit=dev && \
npm cache clean --force && \
apk del .build-deps
RUN npm ci --only=production
# Copy prisma schema and migrations for runtime and hydration template
COPY prisma ./prisma/
@@ -54,6 +48,9 @@ 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
-25
View File
@@ -1,30 +1,6 @@
#!/bin/sh
set -e
JWT_SECRET_FILE="/app/prisma/.jwt_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
# 1. Hydrate volume if empty (Running as root)
if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..."
@@ -42,7 +18,6 @@ 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}"
# Ensure database file has proper permissions
if [ -f "/app/prisma/dev.db" ]; then
+15 -330
View File
File diff suppressed because it is too large Load Diff
+5 -18
View File
@@ -1,12 +1,10 @@
{
"name": "backend",
"version": "0.4.6",
"version": "0.3.1",
"description": "",
"main": "index.js",
"scripts": {
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -17,38 +15,27 @@
"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",
"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",
@@ -1,96 +0,0 @@
-- NOTE:
-- This migration assigns all pre-existing data to a bootstrap admin user so that
-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible.
-- The bootstrap admin user starts inactive and must be activated via the app's
-- initial registration flow.
-- Constants
-- Keep in sync with backend/src/auth.ts
-- (SQLite doesn't support variables; we inline the values instead.)
-- BOOTSTRAP_USER_ID = 'bootstrap-admin'
-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin'
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"mustResetPassword" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- Bootstrap state:
-- - Insert a singleton config row (registration disabled by default)
-- - Insert an inactive bootstrap admin user and assign all existing data to it
INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt")
VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt")
VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Collection" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Collection" ("createdAt", "id", "name", "userId", "updatedAt")
SELECT "createdAt", "id", "name", 'bootstrap-admin', "updatedAt" FROM "Collection";
DROP TABLE "Collection";
ALTER TABLE "new_Collection" RENAME TO "Collection";
CREATE TABLE "new_Drawing" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"elements" TEXT NOT NULL,
"appState" TEXT NOT NULL,
"files" TEXT NOT NULL DEFAULT '{}',
"preview" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"userId" TEXT NOT NULL,
"collectionId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "userId", "updatedAt", "version")
SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "updatedAt", "version" FROM "Drawing";
DROP TABLE "Drawing";
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
CREATE TABLE "new_Library" (
"id" TEXT NOT NULL PRIMARY KEY,
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- Migrate the singleton library to the bootstrap user's library key.
INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt")
SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default';
DROP TABLE "Library";
ALTER TABLE "new_Library" RENAME TO "Library";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
@@ -1,40 +0,0 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
@@ -1,5 +0,0 @@
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
-- SQLite supports simple ADD COLUMN for non-null with default.
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20;
@@ -1,9 +0,0 @@
-- 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");
+1 -71
View File
@@ -12,45 +12,12 @@ 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)
drawings Drawing[]
collections Collection[]
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemConfig {
id String @id @default("default")
authEnabled Boolean @default(false)
registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
authLoginRateLimitMax Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Collection {
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 {
@@ -61,52 +28,15 @@ 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])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, updatedAt])
@@index([userId, collectionId, updatedAt])
}
model Library {
id String @id // User-specific library ID (e.g., "user_<userId>")
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
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())
}
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env node
/**
* CLI admin password recovery for ExcaliDash.
*
* Examples:
* node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!"
* node scripts/admin-recover.cjs --identifier admin@example.com --generate
*
* Notes:
* - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db).
* - Sets the password hash and clears mustResetPassword by default.
* - If there are no active admins, this script can promote the target user to ADMIN.
*/
require("dotenv").config();
const path = require("path");
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
`file:${path.resolve(__dirname, "../prisma/dev.db")}`;
const { PrismaClient } = require("../src/generated/client");
const bcrypt = require("bcrypt");
const parseArgs = (argv) => {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
};
const generatePassword = () => {
// 24 chars base64url-ish
const buf = require("crypto").randomBytes(18);
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const identifier = typeof args.identifier === "string" ? args.identifier.trim() : "";
const providedPassword = typeof args.password === "string" ? args.password : null;
const generate = Boolean(args.generate);
const setMustReset = Boolean(args["must-reset"]);
const activate = Boolean(args.activate);
const promote = Boolean(args.promote);
const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]);
if (!identifier) {
console.error("Missing --identifier (email or username).");
process.exitCode = 2;
return;
}
let newPassword = providedPassword;
if (!newPassword) {
if (!generate) {
console.error('Provide --password "<new password>" or pass --generate.');
process.exitCode = 2;
return;
}
newPassword = generatePassword();
}
if (newPassword.length < 8) {
console.error("Password must be at least 8 characters.");
process.exitCode = 2;
return;
}
const prisma = new PrismaClient();
try {
const activeAdminCount = await prisma.user.count({
where: { role: "ADMIN", isActive: true },
});
const trimmed = identifier.toLowerCase();
const user = await prisma.user.findFirst({
where: {
OR: [{ email: trimmed }, { username: identifier }],
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
if (!user) {
console.error("User not found:", identifier);
process.exitCode = 1;
return;
}
const shouldPromote = promote || activeAdminCount === 0;
if (user.role !== "ADMIN" && !shouldPromote) {
console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user.");
console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins.");
process.exitCode = 1;
return;
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
if (disableLoginRateLimit) {
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authLoginRateLimitEnabled: false },
create: {
id: "default",
authEnabled: true,
registrationEnabled: false,
authLoginRateLimitEnabled: false,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
}
const updated = await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
mustResetPassword: setMustReset ? true : false,
isActive: activate ? true : user.isActive,
role: shouldPromote ? "ADMIN" : user.role,
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
console.log("Updated admin account:");
console.log(`- id: ${updated.id}`);
console.log(`- email: ${updated.email}`);
console.log(`- username: ${updated.username || ""}`);
console.log(`- isActive: ${updated.isActive}`);
console.log(`- mustResetPassword: ${updated.mustResetPassword}`);
console.log(`- role: ${updated.role}`);
if (disableLoginRateLimit) {
console.log("");
console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false).");
console.log("Remember to re-enable it from the Admin dashboard after you regain access.");
}
if (generate || !providedPassword) {
console.log("");
console.log("New password:");
console.log(newPassword);
} else {
console.log("");
console.log("Password updated.");
}
} finally {
await prisma.$disconnect().catch(() => {});
}
};
main().catch((err) => {
console.error("Admin recovery failed:", err);
process.exitCode = 1;
});
-118
View File
@@ -1,118 +0,0 @@
/* eslint-disable no-console */
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const backendRoot = path.resolve(__dirname, "..");
const resolveDatabaseUrl = (rawUrl) => {
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || String(rawUrl).trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!String(rawUrl).startsWith("file:")) {
return String(rawUrl);
}
const filePath = String(rawUrl).replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
const databaseUrl = resolveDatabaseUrl(process.env.DATABASE_URL);
process.env.DATABASE_URL = databaseUrl;
const nodeEnv = process.env.NODE_ENV || "development";
const runCapture = (cmd) => {
try {
const stdout = execSync(cmd, {
cwd: backendRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, DATABASE_URL: databaseUrl },
});
return { ok: true, stdout: stdout || "", stderr: "" };
} catch (error) {
const err = error;
const stderr =
err && err.stderr
? Buffer.isBuffer(err.stderr)
? err.stderr.toString("utf8")
: String(err.stderr)
: "";
const stdout =
err && err.stdout
? Buffer.isBuffer(err.stdout)
? err.stdout.toString("utf8")
: String(err.stdout)
: "";
return { ok: false, stdout, stderr, error: err };
}
};
const run = (cmd) => {
execSync(cmd, {
cwd: backendRoot,
stdio: "inherit",
env: { ...process.env, DATABASE_URL: databaseUrl },
});
};
const getDbFilePath = () => {
if (!databaseUrl.startsWith("file:")) return null;
return databaseUrl.replace(/^file:/, "");
};
const backupDbIfPresent = () => {
const dbPath = getDbFilePath();
if (!dbPath) return null;
if (!fs.existsSync(dbPath)) return null;
const dir = path.dirname(dbPath);
const base = path.basename(dbPath, path.extname(dbPath));
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(dir, `${base}.${stamp}.backup`);
fs.copyFileSync(dbPath, backupPath);
return backupPath;
};
const isNonProd = nodeEnv !== "production";
const isFileDb = databaseUrl.startsWith("file:");
const deploy = runCapture("npx prisma migrate deploy");
if (deploy.ok) {
if (deploy.stdout) process.stdout.write(deploy.stdout);
} else {
if (deploy.stdout) process.stdout.write(deploy.stdout);
if (deploy.stderr) process.stderr.write(deploy.stderr);
const stderr = deploy.stderr || "";
const isP3005 = stderr.includes("P3005");
// Common when an older dev.db exists but migrations weren't used previously.
if (isNonProd && isFileDb && isP3005) {
const backupPath = backupDbIfPresent();
console.warn(
`[predev] Prisma migrate baseline required (P3005). Resetting local SQLite database.\n` +
` DATABASE_URL=${databaseUrl}\n` +
(backupPath ? ` Backup: ${backupPath}\n` : "") +
` If you need to preserve local data, restore the backup and baseline manually.`,
);
run("npx prisma migrate reset --force --skip-seed");
} else {
throw deploy.error;
}
}
@@ -51,13 +51,12 @@ describe("Issue #38: CSRF with trust proxy settings", () => {
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
.set("User-Agent", "Mozilla/5.0 Test");
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
expect(response1.body.ip).toBe("172.17.0.3");
// With trust proxy: 1, Express takes second-to-last IP (the external proxy)
expect(response1.body.ip).toBe("10.0.0.5");
console.log(
"trust proxy: 1 → IP:",
response1.body.ip,
"(not the real client IP)",
"(external proxy IP - WRONG)",
);
// With trust proxy: true
@@ -161,12 +160,10 @@ describe("Issue #38: CSRF with trust proxy settings", () => {
});
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
// In supertest without real socket, trust proxy: 1 returns last IP
// Key point: it's NOT the real client IP (192.168.0.100)
await request(app)
.get("/test")
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
console.log(" With trust proxy: 1, Express sees:", seenIp);
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
expect(seenIp).toBe("192.168.1.4"); // Proxy IP, not client IP
});
});
@@ -315,11 +315,10 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
let testUser: { id: string };
beforeAll(async () => {
setupTestDb();
testUser = await initTestDb(prisma);
await initTestDb(prisma);
});
afterAll(async () => {
@@ -344,7 +343,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -383,7 +381,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -407,7 +404,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify({}),
userId: testUser.id,
},
});
@@ -1,444 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import request from "supertest";
import fs from "fs";
import path from "path";
import os from "os";
import JSZip from "jszip";
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
type LegacyDbOptions = {
tableStyle: "prisma" | "plural-lower";
includeCollections: boolean;
includeMigrationsTable: boolean;
includeTrashDrawing: boolean;
};
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "excalidash-legacy-"));
const openWritableDb = (filePath: string): any => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DatabaseSync } = require("node:sqlite") as any;
return new DatabaseSync(filePath, { enableForeignKeyConstraints: false });
} catch (_err) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require("better-sqlite3") as any;
return new Database(filePath);
}
};
const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
const dir = createTempDir();
const filePath = path.join(dir, "legacy-export.db");
const db = openWritableDb(filePath);
const tableDrawing = opts.tableStyle === "plural-lower" ? "drawings" : "Drawing";
const tableCollection = opts.tableStyle === "plural-lower" ? "collections" : "Collection";
try {
if (opts.includeCollections) {
db.exec(`
CREATE TABLE "${tableCollection}" (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
createdAt TEXT,
updatedAt TEXT
);
`);
db.prepare(`INSERT INTO "${tableCollection}" (id, name, createdAt, updatedAt) VALUES (?, ?, ?, ?)`).run(
"legacy-collection-1",
"Legacy Collection",
new Date("2024-01-01T00:00:00.000Z").toISOString(),
new Date("2024-01-02T00:00:00.000Z").toISOString(),
);
}
db.exec(`
CREATE TABLE "${tableDrawing}" (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
elements TEXT NOT NULL,
appState TEXT NOT NULL,
files TEXT,
preview TEXT,
version INTEGER,
collectionId TEXT,
collectionName TEXT,
createdAt TEXT,
updatedAt TEXT
);
`);
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
const insertDrawing = db.prepare(
`INSERT INTO "${tableDrawing}"
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
insertDrawing.run(
"legacy-drawing-1",
"Legacy Drawing 1",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
opts.includeCollections ? "legacy-collection-1" : null,
opts.includeCollections ? "Legacy Collection" : null,
now,
now,
);
insertDrawing.run(
"legacy-drawing-2",
"Legacy Drawing 2 (unorganized)",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
2,
null,
null,
now,
now,
);
if (opts.includeTrashDrawing) {
insertDrawing.run(
"legacy-drawing-trash",
"Legacy Trash Drawing",
JSON.stringify([]),
JSON.stringify({}),
JSON.stringify({}),
null,
1,
"trash",
"Trash",
now,
now,
);
}
if (opts.includeMigrationsTable) {
db.exec(`
CREATE TABLE "_prisma_migrations" (
id TEXT PRIMARY KEY NOT NULL,
checksum TEXT NOT NULL,
finished_at TEXT,
migration_name TEXT NOT NULL,
logs TEXT,
rolled_back_at TEXT,
started_at TEXT NOT NULL,
applied_steps_count INTEGER NOT NULL DEFAULT 0
);
`);
db.prepare(
`INSERT INTO "_prisma_migrations"
(id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?)`
).run(
"m1",
"checksum",
new Date("2024-01-04T00:00:00.000Z").toISOString(),
"20240104000000_initial",
null,
null,
new Date("2024-01-04T00:00:00.000Z").toISOString(),
1,
);
}
} finally {
db.close();
}
return filePath;
};
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 csrfHeaderName: string;
let csrfToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
fs.mkdirSync(uploadsDir, { recursive: true });
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
({ app } = await import("../index"));
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
expect(typeof csrfHeaderName).toBe("string");
expect(typeof csrfToken).toBe("string");
});
beforeEach(async () => {
await cleanupTestDb(prisma);
});
afterAll(async () => {
await prisma.$disconnect();
});
it("verifies a v0.1.xv0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "prisma",
includeCollections: true,
includeMigrationsTable: true,
includeTrashDrawing: false,
});
const res = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.drawings).toBe(2);
expect(res.body.collections).toBe(1);
expect(res.body.latestMigration).toBe("20240104000000_initial");
expect(typeof res.body.currentLatestMigration === "string").toBe(true);
});
it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "prisma",
includeCollections: true,
includeMigrationsTable: false,
includeTrashDrawing: true,
});
const res = await request(app)
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.collections?.created).toBeGreaterThanOrEqual(1);
expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3);
const importedDrawings = await prisma.drawing.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, collectionId: true, userId: true },
});
// In single-user mode, imports land on the bootstrap acting user.
expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true);
expect(importedDrawings.map((d) => d.id)).toEqual(
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
);
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
expect(trash).toBeTruthy();
});
it("supports older exports with plural/lowercase table names (drawings/collections)", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "plural-lower",
includeCollections: true,
includeMigrationsTable: false,
includeTrashDrawing: false,
});
const verify = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(verify.status).toBe(200);
expect(verify.body.drawings).toBe(2);
expect(verify.body.collections).toBe(1);
const res = await request(app)
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it("fails verification if the legacy DB is missing a Drawing table", async () => {
const dir = createTempDir();
const filePath = path.join(dir, "invalid.db");
const db = openWritableDb(filePath);
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
db.close();
const res = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", filePath);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid legacy DB");
});
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await request(app)
.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 request(app)
.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 request(app)
.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 request(app)
.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");
});
});
+7 -81
View File
@@ -2,53 +2,11 @@
* Test utilities for backend integration tests
*/
import { PrismaClient } from "../generated/client";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
// Use a unique test database per test-file import to avoid cross-file contention
// when Vitest runs test files in parallel.
const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`;
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME);
const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock");
const sleepSync = (ms: number) => {
const shared = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(shared, 0, 0, ms);
};
const withDbPushLock = (fn: () => void) => {
const start = Date.now();
let fd: number | null = null;
while (fd === null) {
try {
fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx");
fs.writeFileSync(fd, String(process.pid));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "EEXIST") throw error;
if (Date.now() - start > 30_000) {
throw new Error("Timed out waiting for Prisma db push lock");
}
sleepSync(50);
}
}
try {
fn();
} finally {
try {
fs.closeSync(fd);
} catch {
// ignore
}
try {
fs.unlinkSync(DB_PUSH_LOCK_PATH);
} catch {
// ignore
}
}
};
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
/**
* Get a test Prisma client pointing to the test database
@@ -74,19 +32,10 @@ export const setupTestDb = () => {
// Run Prisma migrations to create the test database
try {
withDbPushLock(() => {
execSync("npx prisma db push --skip-generate --force-reset", {
cwd: path.resolve(__dirname, "../../"),
env: {
...process.env,
DATABASE_URL: databaseUrl,
// Work around Prisma schema engine failures on this repo's schema
// (seen as a blank "Schema engine error:" from `prisma db push`).
// `RUST_LOG=info` reliably avoids the failure mode.
RUST_LOG: "info",
},
stdio: "pipe",
});
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: "pipe",
});
} catch (error) {
console.error("Failed to setup test database:", error);
@@ -105,42 +54,19 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
});
};
/**
* Create a test user for testing
*/
export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => {
const bcrypt = require("bcrypt");
const passwordHash = await bcrypt.hash("testpassword", 10);
return await prisma.user.upsert({
where: { email },
update: {},
create: {
email,
passwordHash,
name: "Test User",
},
});
};
/**
* Initialize test database with required data
*/
export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first
const testUser = await createTestUser(prisma);
// Ensure Trash collection exists
const trash = await prisma.collection.findUnique({
where: { id: "trash" },
});
if (!trash) {
await prisma.collection.create({
data: { id: "trash", name: "Trash", userId: testUser.id },
data: { id: "trash", name: "Trash" },
});
}
return testUser;
};
/**
@@ -1,240 +0,0 @@
/**
* Security tests for user data sandboxing
*
* Verifies that:
* 1. Drawings cache keys are scoped by userId (prevents cross-user data leakage)
* 2. Drawing CRUD operations enforce userId filtering
* 3. Collection operations enforce userId filtering
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import bcrypt from "bcrypt";
import {
getTestPrisma,
setupTestDb,
} from "./testUtils";
import { PrismaClient } from "../generated/client";
let prisma: PrismaClient;
// These tests verify the data isolation logic at the database query level
describe("User Data Sandboxing", () => {
let userA: { id: string; email: string };
let userB: { id: string; email: string };
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
// Create two test users
const hashA = await bcrypt.hash("passwordA", 10);
const hashB = await bcrypt.hash("passwordB", 10);
userA = await prisma.user.upsert({
where: { email: "usera@test.com" },
update: {},
create: {
email: "usera@test.com",
passwordHash: hashA,
name: "User A",
},
});
userB = await prisma.user.upsert({
where: { email: "userb@test.com" },
update: {},
create: {
email: "userb@test.com",
passwordHash: hashB,
name: "User B",
},
});
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({});
});
describe("Drawing isolation", () => {
it("should not return User A's drawings when querying as User B", async () => {
// Create a drawing for User A
await prisma.drawing.create({
data: {
name: "User A Drawing",
elements: "[]",
appState: "{}",
userId: userA.id,
},
});
// Query as User B - should get 0 results
const userBDrawings = await prisma.drawing.findMany({
where: { userId: userB.id },
});
expect(userBDrawings).toHaveLength(0);
});
it("should only return the owning user's drawings", async () => {
// Create drawings for both users
await prisma.drawing.create({
data: {
name: "User A Drawing",
elements: "[]",
appState: "{}",
userId: userA.id,
},
});
await prisma.drawing.create({
data: {
name: "User B Drawing",
elements: "[]",
appState: "{}",
userId: userB.id,
},
});
const userADrawings = await prisma.drawing.findMany({
where: { userId: userA.id },
});
const userBDrawings = await prisma.drawing.findMany({
where: { userId: userB.id },
});
expect(userADrawings).toHaveLength(1);
expect(userADrawings[0].name).toBe("User A Drawing");
expect(userBDrawings).toHaveLength(1);
expect(userBDrawings[0].name).toBe("User B Drawing");
});
it("should not allow User B to access User A's drawing by ID", async () => {
const drawing = await prisma.drawing.create({
data: {
name: "User A Secret Drawing",
elements: "[]",
appState: "{}",
userId: userA.id,
},
});
// Simulate the findFirst query used in GET /drawings/:id
const result = await prisma.drawing.findFirst({
where: {
id: drawing.id,
userId: userB.id, // User B trying to access
},
});
expect(result).toBeNull();
});
});
describe("Collection isolation", () => {
it("should not return User A's collections when querying as User B", async () => {
await prisma.collection.create({
data: {
name: "User A Collection",
userId: userA.id,
},
});
const userBCollections = await prisma.collection.findMany({
where: { userId: userB.id },
});
expect(userBCollections).toHaveLength(0);
});
it("should not allow User B to modify User A's collection", async () => {
const collection = await prisma.collection.create({
data: {
name: "User A Collection",
userId: userA.id,
},
});
// Simulate the findFirst query used in PUT /collections/:id
const result = await prisma.collection.findFirst({
where: {
id: collection.id,
userId: userB.id,
},
});
expect(result).toBeNull();
});
});
describe("Cache key user scoping", () => {
it("should generate different cache keys for different users with same query params", () => {
// This tests the buildDrawingsCacheKey function logic inline
// The function was updated to include userId in the cache key
const buildDrawingsCacheKey = (keyParts: {
userId: string;
searchTerm: string;
collectionFilter: string;
includeData: boolean;
}) =>
JSON.stringify([
keyParts.userId,
keyParts.searchTerm,
keyParts.collectionFilter,
keyParts.includeData ? "full" : "summary",
]);
const keyA = buildDrawingsCacheKey({
userId: "user-a-id",
searchTerm: "",
collectionFilter: "default",
includeData: false,
});
const keyB = buildDrawingsCacheKey({
userId: "user-b-id",
searchTerm: "",
collectionFilter: "default",
includeData: false,
});
expect(keyA).not.toBe(keyB);
});
it("should generate same cache key for same user with same query params", () => {
const buildDrawingsCacheKey = (keyParts: {
userId: string;
searchTerm: string;
collectionFilter: string;
includeData: boolean;
}) =>
JSON.stringify([
keyParts.userId,
keyParts.searchTerm,
keyParts.collectionFilter,
keyParts.includeData ? "full" : "summary",
]);
const key1 = buildDrawingsCacheKey({
userId: "same-user",
searchTerm: "test",
collectionFilter: "default",
includeData: true,
});
const key2 = buildDrawingsCacheKey({
userId: "same-user",
searchTerm: "test",
collectionFilter: "default",
includeData: true,
});
expect(key1).toBe(key2);
});
});
});

Some files were not shown because too many files have changed in this diff Show More