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

|
||||

|
||||
[](https://hub.docker.com)
|
||||
|
||||
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
||||
@@ -21,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
|
||||
- [Installation](#installation)
|
||||
- [Docker Hub (Recommended)](#dockerhub-recommended)
|
||||
- [Docker Build](#docker-build)
|
||||
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
|
||||
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
|
||||
- [Development](#development)
|
||||
- [Clone the Repository](#clone-the-repository)
|
||||
- [Frontend](#frontend)
|
||||
@@ -74,7 +77,10 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
|
||||
# Installation
|
||||
|
||||
> [!CAUTION]
|
||||
> NOT for production use. This is just a side project (and also the first release), and it likely contains some bugs. DO NOT open ports to the internet (e.g. CORS is set to allow all)
|
||||
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
|
||||
|
||||
> [!CAUTION]
|
||||
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
|
||||
|
||||
## Docker Hub (Recommended)
|
||||
|
||||
@@ -110,6 +116,47 @@ docker compose up -d
|
||||
# Access the frontend at localhost:6767
|
||||
```
|
||||
|
||||
### Reverse Proxy / Traefik Setups (Docker)
|
||||
|
||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
||||
|
||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
backend:
|
||||
environment:
|
||||
# Single URL
|
||||
- FRONTEND_URL=https://excalidash.example.com
|
||||
# Or multiple URLs (comma-separated) for local + network access
|
||||
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||
frontend:
|
||||
environment:
|
||||
# For standard Docker Compose (default)
|
||||
# - BACKEND_URL=backend:8000
|
||||
# For Kubernetes, use the service DNS name:
|
||||
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
|
||||
```
|
||||
|
||||
### Multi-Container / Kubernetes Deployments
|
||||
|
||||
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
|
||||
|
||||
```bash
|
||||
# Generate a secure secret
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml or k8s deployment
|
||||
backend:
|
||||
environment:
|
||||
- CSRF_SECRET=your-generated-secret-here
|
||||
```
|
||||
|
||||
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
||||
|
||||
# Development
|
||||
|
||||
## Clone the Repository
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
CSRF Protection (8a78b2b)
|
||||
|
||||
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
|
||||
- Added new backend/src/security.ts module for security utilities
|
||||
- Frontend API layer now handles CSRF tokens automatically
|
||||
- Added integration tests for CSRF validation
|
||||
|
||||
Upload Progress Indicator (8f9b9b4)
|
||||
|
||||
- Added a visual upload progress bar when users upload files
|
||||
- New UploadContext for managing upload state across components
|
||||
- New UploadStatus component displaying real-time upload progress
|
||||
- Save status indicator when navigating back from the editor
|
||||
- Improved error handling and recovery for failed uploads
|
||||
|
||||
Bug Fixes
|
||||
|
||||
- Fixed broken e2e tests (cae8f3c)
|
||||
- Replaced deprecated substr() with substring()
|
||||
- Fixed stale state issues in error handling
|
||||
- Fixed missing useEffect dependencies
|
||||
- Fixed CSS class conflicts in progress bar styling
|
||||
- Added error recovery for save state in Editor
|
||||
|
||||
Infrastructure
|
||||
|
||||
- Updated docker-compose configurations with new environment variables
|
||||
- E2E test suite improvements and reliability fixes
|
||||
- Added Kubernetes deployment note in README
|
||||
|
||||
### Kubernetes
|
||||
|
||||
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
|
||||
Add it to your deployment:
|
||||
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
|
||||
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
|
||||
|
||||
If not set, the backend will refuse to start.
|
||||
```
|
||||
@@ -2,3 +2,4 @@
|
||||
PORT=8000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=file:/app/prisma/dev.db
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
+12
-4
@@ -25,8 +25,10 @@ RUN npx tsc
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
# 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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -36,8 +38,9 @@ COPY package*.json ./
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy prisma schema and migrations
|
||||
# Copy prisma schema and migrations for runtime and hydration template
|
||||
COPY prisma ./prisma/
|
||||
COPY prisma ./prisma_template/
|
||||
|
||||
# Copy built application from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
@@ -48,10 +51,15 @@ COPY --from=builder /app/src/generated ./dist/generated
|
||||
# Generate Prisma Client in production (updates node_modules)
|
||||
RUN npx prisma generate
|
||||
|
||||
# Run migrations and start server
|
||||
# Create necessary directories (ownership will be set in entrypoint)
|
||||
RUN mkdir -p /app/uploads /app/prisma
|
||||
|
||||
# Copy and set permissions for entrypoint script
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run migrations
|
||||
npx prisma migrate deploy
|
||||
# 1. Hydrate volume if empty (Running as root)
|
||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||
echo "Mount is empty. Hydrating /app/prisma..."
|
||||
cp -R /app/prisma_template/. /app/prisma/
|
||||
else
|
||||
# Volume exists but may be missing new migrations from an upgrade
|
||||
# Always sync schema and migrations from template to ensure upgrades work
|
||||
echo "Syncing schema and migrations from template..."
|
||||
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
|
||||
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
|
||||
fi
|
||||
|
||||
# Start the application
|
||||
node dist/index.js
|
||||
# 2. Fix permissions unconditionally (Running as root)
|
||||
echo "Fixing filesystem permissions..."
|
||||
chown -R nodejs:nodejs /app/uploads
|
||||
chown -R nodejs:nodejs /app/prisma
|
||||
chmod 755 /app/uploads
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
# 3. Run Migrations (Drop privileges to nodejs)
|
||||
echo "Running database migrations..."
|
||||
su-exec nodejs npx prisma migrate deploy
|
||||
|
||||
# 4. Start Application (Drop privileges to nodejs)
|
||||
echo "Starting application as nodejs..."
|
||||
exec su-exec nodejs node dist/index.js
|
||||
|
||||
Generated
+2165
-25
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.3.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -14,14 +16,18 @@
|
||||
"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",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.3.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"prisma": "^5.22.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
@@ -29,9 +35,11 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Library" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
|
||||
"items" TEXT NOT NULL DEFAULT '[]',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
Binary file not shown.
@@ -4,7 +4,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/client"
|
||||
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
|
||||
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -33,3 +33,10 @@ model Drawing {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Library {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Issue #38: CSRF fails with multiple reverse proxies
|
||||
*
|
||||
* This test demonstrates how trust proxy settings affect CSRF validation
|
||||
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import {
|
||||
createCsrfToken,
|
||||
validateCsrfToken,
|
||||
getCsrfTokenHeader,
|
||||
} from "../security";
|
||||
|
||||
// mock the getClientId function behavior
|
||||
const getClientIdFromRequest = (req: express.Request): string => {
|
||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||
const userAgent = req.headers["user-agent"] || "unknown";
|
||||
return `${ip}:${userAgent}`.slice(0, 256);
|
||||
};
|
||||
|
||||
describe("Issue #38: CSRF with trust proxy settings", () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
});
|
||||
|
||||
it("demonstrates the trust proxy issue with multiple proxies", async () => {
|
||||
// ext proxy -> frontend nginx -> backend
|
||||
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
|
||||
|
||||
// With trust proxy: 1 (current setting)
|
||||
const app1 = express();
|
||||
app1.set("trust proxy", 1);
|
||||
app1.use(express.json());
|
||||
|
||||
app1.get("/test-ip", (req, res) => {
|
||||
res.json({
|
||||
ip: req.ip,
|
||||
clientId: getClientIdFromRequest(req),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate request through multiple proxies
|
||||
const response1 = await request(app1)
|
||||
.get("/test-ip")
|
||||
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||
.set("User-Agent", "Mozilla/5.0 Test");
|
||||
|
||||
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
|
||||
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
|
||||
expect(response1.body.ip).toBe("172.17.0.3");
|
||||
console.log(
|
||||
"trust proxy: 1 → IP:",
|
||||
response1.body.ip,
|
||||
"(not the real client IP)",
|
||||
);
|
||||
|
||||
// With trust proxy: true
|
||||
const app2 = express();
|
||||
app2.set("trust proxy", true);
|
||||
app2.use(express.json());
|
||||
|
||||
app2.get("/test-ip", (req, res) => {
|
||||
res.json({
|
||||
ip: req.ip,
|
||||
clientId: getClientIdFromRequest(req),
|
||||
});
|
||||
});
|
||||
|
||||
const response2 = await request(app2)
|
||||
.get("/test-ip")
|
||||
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||
.set("User-Agent", "Mozilla/5.0 Test");
|
||||
|
||||
// With trust proxy: true, Express takes leftmost IP
|
||||
expect(response2.body.ip).toBe("203.0.113.42");
|
||||
console.log(
|
||||
"trust proxy: true → IP:",
|
||||
response2.body.ip,
|
||||
"(real client IP - CORRECT)",
|
||||
);
|
||||
});
|
||||
|
||||
it("simulates CSRF failure scenario from issue #38", async () => {
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||
|
||||
// Request 1: Fetch CSRF token
|
||||
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
|
||||
const clientIp1 = "203.0.113.42";
|
||||
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
|
||||
|
||||
// With trust proxy: 1, Express sees the external proxy IP
|
||||
const clientId1 = `${externalProxyIp1}:${userAgent}`;
|
||||
const token = createCsrfToken(clientId1);
|
||||
|
||||
console.log(
|
||||
" X-Forwarded-For:",
|
||||
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
|
||||
);
|
||||
console.log(" Express sees IP:", externalProxyIp1);
|
||||
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
|
||||
|
||||
// Request 2: Try to create drawing with token
|
||||
// External proxy IP might differ slightly
|
||||
const externalProxyIp2 = "10.0.0.6";
|
||||
|
||||
const clientId2 = `${externalProxyIp2}:${userAgent}`;
|
||||
|
||||
console.log(
|
||||
" X-Forwarded-For:",
|
||||
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
|
||||
);
|
||||
console.log(" Express sees IP:", externalProxyIp2);
|
||||
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
|
||||
|
||||
// CSRF validation fails because clientId changed
|
||||
const isValid = validateCsrfToken(clientId2, token);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
console.log(" Expected:", clientId1.slice(0, 50) + "...");
|
||||
console.log(" Got:", clientId2.slice(0, 50) + "...");
|
||||
});
|
||||
|
||||
it("shows the fix works with trust proxy: true", async () => {
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||
const realClientIp = "203.0.113.42";
|
||||
|
||||
const clientId1 = `${realClientIp}:${userAgent}`;
|
||||
const token = createCsrfToken(clientId1);
|
||||
|
||||
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
|
||||
console.log(" Express sees IP:", realClientIp);
|
||||
|
||||
// Request 2: Use token (even if middle proxy IPs differ)
|
||||
const clientId2 = `${realClientIp}:${userAgent}`;
|
||||
|
||||
console.log("Create drawing");
|
||||
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
|
||||
console.log("Express sees IP:", realClientIp, "(same!)");
|
||||
|
||||
const isValid = validateCsrfToken(clientId2, token);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
console.log("\nCSRF Validation: SUCCESS");
|
||||
});
|
||||
|
||||
it("demonstrates the Synology NAS scenario from issue #38", async () => {
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.use(express.json());
|
||||
|
||||
let seenIp: string | undefined;
|
||||
app.get("/test", (req, res) => {
|
||||
seenIp = req.ip;
|
||||
res.json({ ip: req.ip });
|
||||
});
|
||||
|
||||
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
|
||||
// In supertest without real socket, trust proxy: 1 returns last IP
|
||||
// Key point: it's NOT the real client IP (192.168.0.100)
|
||||
await request(app)
|
||||
.get("/test")
|
||||
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
|
||||
console.log(" With trust proxy: 1, Express sees:", seenIp);
|
||||
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* CSRF Tests - Horizontal Scaling (K8s) Validation
|
||||
*
|
||||
* PR #20 review concern:
|
||||
* "Worried that in memory token store might not work on horizontal scaling"
|
||||
*
|
||||
* Fix:
|
||||
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
|
||||
* - Any pod can validate any token as long as all pods share the same secret.
|
||||
*
|
||||
* These tests prove:
|
||||
* - Tokens validate correctly for the issuing client id
|
||||
* - Tokens do NOT validate for a different client id
|
||||
* - Tokens expire after 24 hours
|
||||
* - Tokens validate across separate module instances (simulated pods)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
||||
|
||||
const SHARED_SECRET = "test-shared-csrf-secret";
|
||||
|
||||
beforeAll(() => {
|
||||
// Must be shared across instances/pods for horizontal scaling.
|
||||
process.env.CSRF_SECRET = SHARED_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("CSRF - stateless HMAC tokens", () => {
|
||||
it("creates a token in payload.signature format and validates for same client id", async () => {
|
||||
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||
|
||||
const clientId = "test-client-1";
|
||||
const token = createCsrfToken(clientId);
|
||||
|
||||
expect(typeof token).toBe("string");
|
||||
// base64url(payload).base64url(signature)
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects validation for a different client id (token binding)", async () => {
|
||||
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||
|
||||
const token = createCsrfToken("client-a");
|
||||
expect(validateCsrfToken("client-b", token)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed tokens", async () => {
|
||||
const { validateCsrfToken } = await import("../security");
|
||||
|
||||
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
|
||||
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
|
||||
expect(validateCsrfToken("client", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
|
||||
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
|
||||
"../security"
|
||||
);
|
||||
|
||||
const clientId = "client-revoke";
|
||||
const token = createCsrfToken(clientId);
|
||||
|
||||
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||
revokeCsrfToken(clientId);
|
||||
// Stateless token remains valid until expiry
|
||||
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("expires tokens after 24 hours", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
||||
|
||||
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||
|
||||
const clientId = "client-expiry";
|
||||
const token = createCsrfToken(clientId);
|
||||
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||
|
||||
// 24h + 1ms later
|
||||
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
|
||||
expect(validateCsrfToken(clientId, token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF - horizontal scaling (simulated pods)", () => {
|
||||
it("validates across module instances (pod A issues, pod B validates)", async () => {
|
||||
const clientId = "user-123";
|
||||
|
||||
vi.resetModules();
|
||||
const podA = await import("../security");
|
||||
const token = podA.createCsrfToken(clientId);
|
||||
|
||||
// Simulate a different pod (new Node.js process / fresh module state)
|
||||
vi.resetModules();
|
||||
const podB = await import("../security");
|
||||
|
||||
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
|
||||
const clientId = "user-round-robin";
|
||||
|
||||
const pods: Array<{
|
||||
createCsrfToken: (clientId: string) => string;
|
||||
validateCsrfToken: (clientId: string, token: string) => boolean;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
vi.resetModules();
|
||||
pods.push(await import("../security"));
|
||||
}
|
||||
|
||||
// Token issued on one pod
|
||||
const token = pods[0].createCsrfToken(clientId);
|
||||
|
||||
// Validate on alternating pods (simulates a non-sticky load balancer)
|
||||
const attempts = 60;
|
||||
let failures = 0;
|
||||
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const pod = pods[i % pods.length];
|
||||
if (!pod.validateCsrfToken(clientId, token)) failures++;
|
||||
}
|
||||
|
||||
expect(failures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF - referer origin parsing", () => {
|
||||
it("extracts exact origin from a referer URL", async () => {
|
||||
const { getOriginFromReferer } = await import("../security");
|
||||
|
||||
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
|
||||
"http://localhost:5173"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow prefix tricks (origin must be parsed)", async () => {
|
||||
const { getOriginFromReferer } = await import("../security");
|
||||
|
||||
expect(
|
||||
getOriginFromReferer("https://example.com.evil.com/anything")
|
||||
).toBe("https://example.com.evil.com");
|
||||
|
||||
// `startsWith("https://example.com")` would incorrectly allow this.
|
||||
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
|
||||
"https://evil.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for invalid or non-http(s) referers", async () => {
|
||||
const { getOriginFromReferer } = await import("../security");
|
||||
|
||||
expect(getOriginFromReferer("")).toBeNull();
|
||||
expect(getOriginFromReferer("not a url")).toBeNull();
|
||||
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
|
||||
expect(getOriginFromReferer(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Security hardening tests
|
||||
*
|
||||
* Tests for input validation and sanitization improvements:
|
||||
* - Route parameter ID validation
|
||||
* - Collection name validation/sanitization
|
||||
* - Library items validation
|
||||
* - Socket.io input validation helpers
|
||||
* - Path traversal protection in archive file names
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeText } from "../security";
|
||||
|
||||
// Replicate the validation functions from index.ts to test them in isolation
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
|
||||
|
||||
const isValidResourceId = (id: string): boolean => {
|
||||
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
|
||||
};
|
||||
|
||||
describe("Route Parameter ID Validation", () => {
|
||||
it("should accept valid UUID v4", () => {
|
||||
expect(isValidResourceId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||
expect(isValidResourceId("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept safe alphanumeric IDs", () => {
|
||||
expect(isValidResourceId("trash")).toBe(true);
|
||||
expect(isValidResourceId("default")).toBe(true);
|
||||
expect(isValidResourceId("my-collection-123")).toBe(true);
|
||||
expect(isValidResourceId("element_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject IDs with path traversal", () => {
|
||||
expect(isValidResourceId("../etc/passwd")).toBe(false);
|
||||
expect(isValidResourceId("..\\windows\\system32")).toBe(false);
|
||||
expect(isValidResourceId("foo/bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject IDs with SQL injection attempts", () => {
|
||||
expect(isValidResourceId("'; DROP TABLE drawings; --")).toBe(false);
|
||||
expect(isValidResourceId("1 OR 1=1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject IDs with script injection", () => {
|
||||
expect(isValidResourceId("<script>alert(1)</script>")).toBe(false);
|
||||
expect(isValidResourceId('"><img src=x onerror=alert(1)>')).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty or excessively long IDs", () => {
|
||||
expect(isValidResourceId("")).toBe(false);
|
||||
expect(isValidResourceId("a".repeat(129))).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept IDs at maximum length", () => {
|
||||
expect(isValidResourceId("a".repeat(128))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Collection Name Validation", () => {
|
||||
it("should sanitize collection names with HTML", () => {
|
||||
const result = sanitizeText('<script>alert("xss")</script>My Collection', 255);
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).toContain("My Collection");
|
||||
});
|
||||
|
||||
it("should preserve normal collection names", () => {
|
||||
const result = sanitizeText("My Drawings Collection", 255);
|
||||
expect(result).toBe("My Drawings Collection");
|
||||
});
|
||||
|
||||
it("should truncate overly long names", () => {
|
||||
const longName = "A".repeat(300);
|
||||
const result = sanitizeText(longName, 255);
|
||||
expect(result.length).toBeLessThanOrEqual(255);
|
||||
});
|
||||
|
||||
it("should strip control characters", () => {
|
||||
const result = sanitizeText("Name\x00With\x07Control\x1FChars", 255);
|
||||
expect(result).not.toContain("\x00");
|
||||
expect(result).not.toContain("\x07");
|
||||
expect(result).not.toContain("\x1F");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Library Items Validation", () => {
|
||||
it("should accept valid item counts", () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => ({ id: `item-${i}` }));
|
||||
expect(items.length).toBeLessThanOrEqual(10000);
|
||||
});
|
||||
|
||||
it("should flag excessive item counts", () => {
|
||||
const items = Array.from({ length: 10001 }, (_, i) => ({ id: `item-${i}` }));
|
||||
expect(items.length).toBeGreaterThan(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Archive Path Sanitization", () => {
|
||||
const sanitizeArchiveName = (name: string): string => {
|
||||
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
|
||||
};
|
||||
|
||||
it("should replace path traversal sequences", () => {
|
||||
const result = sanitizeArchiveName("../../etc/passwd");
|
||||
expect(result).not.toContain("..");
|
||||
expect(result).not.toContain("/");
|
||||
});
|
||||
|
||||
it("should replace dangerous characters", () => {
|
||||
const result = sanitizeArchiveName('my<drawing>:name/"test"\\path|file?name*');
|
||||
expect(result).not.toContain("<");
|
||||
expect(result).not.toContain(">");
|
||||
expect(result).not.toContain(":");
|
||||
expect(result).not.toContain('"');
|
||||
expect(result).not.toContain("\\");
|
||||
expect(result).not.toContain("|");
|
||||
expect(result).not.toContain("?");
|
||||
expect(result).not.toContain("*");
|
||||
});
|
||||
|
||||
it("should preserve normal names", () => {
|
||||
const result = sanitizeArchiveName("My Drawing 2024");
|
||||
expect(result).toBe("My Drawing 2024");
|
||||
});
|
||||
|
||||
it("should handle double-dot paths", () => {
|
||||
const result = sanitizeArchiveName("..folder../..test..");
|
||||
expect(result).not.toContain("..");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Socket.io Input Validation Helpers", () => {
|
||||
const isValidDrawingId = (id: unknown): id is string =>
|
||||
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
|
||||
|
||||
it("should accept valid drawing IDs", () => {
|
||||
expect(isValidDrawingId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
|
||||
expect(isValidDrawingId("my-drawing-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-string inputs", () => {
|
||||
expect(isValidDrawingId(123)).toBe(false);
|
||||
expect(isValidDrawingId(null)).toBe(false);
|
||||
expect(isValidDrawingId(undefined)).toBe(false);
|
||||
expect(isValidDrawingId({})).toBe(false);
|
||||
expect(isValidDrawingId([])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty strings", () => {
|
||||
expect(isValidDrawingId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject strings with injection attempts", () => {
|
||||
expect(isValidDrawingId("<script>alert(1)</script>")).toBe(false);
|
||||
expect(isValidDrawingId("../../../etc/passwd")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Test utilities for backend integration tests
|
||||
*/
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
// 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
|
||||
*/
|
||||
export const getTestPrisma = () => {
|
||||
const databaseUrl = `file:${TEST_DB_PATH}`;
|
||||
process.env.DATABASE_URL = databaseUrl;
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup the test database by running migrations
|
||||
*/
|
||||
export const setupTestDb = () => {
|
||||
const databaseUrl = `file:${TEST_DB_PATH}`;
|
||||
process.env.DATABASE_URL = databaseUrl;
|
||||
|
||||
// Run Prisma migrations to create the test database
|
||||
try {
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up the test database between tests
|
||||
*/
|
||||
export const cleanupTestDb = async (prisma: PrismaClient) => {
|
||||
// Delete all drawings and collections (except Trash)
|
||||
await prisma.drawing.deleteMany({});
|
||||
await prisma.collection.deleteMany({
|
||||
where: { id: { not: "trash" } },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize test database with required data
|
||||
*/
|
||||
export const initTestDb = async (prisma: PrismaClient) => {
|
||||
// Ensure Trash collection exists
|
||||
const trash = await prisma.collection.findUnique({
|
||||
where: { id: "trash" },
|
||||
});
|
||||
if (!trash) {
|
||||
await prisma.collection.create({
|
||||
data: { id: "trash", name: "Trash" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a sample base64 PNG image data URL
|
||||
* This creates a small but valid PNG for testing
|
||||
*/
|
||||
export const generateSampleImageDataUrl = (size: "small" | "medium" | "large" = "small"): string => {
|
||||
// Minimal 1x1 red PNG (smallest valid PNG possible)
|
||||
const smallPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
if (size === "small") {
|
||||
return `data:image/png;base64,${smallPng}`;
|
||||
}
|
||||
|
||||
// For medium/large, repeat the pattern to create larger payloads
|
||||
const repetitions = size === "medium" ? 1000 : 10000;
|
||||
const paddedBase64 = smallPng.repeat(repetitions);
|
||||
|
||||
return `data:image/png;base64,${paddedBase64}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a large image data URL that exceeds the 10000 char limit
|
||||
* This is specifically designed to catch the truncation bug from issue #17
|
||||
*/
|
||||
export const generateLargeImageDataUrl = (): string => {
|
||||
// Create a base64 string that's definitely larger than 10000 characters
|
||||
// This simulates a real image that would get truncated by the old code
|
||||
const baseImage = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
// Repeat to create a ~50KB payload
|
||||
const largeBase64 = baseImage.repeat(500);
|
||||
return `data:image/png;base64,${largeBase64}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a sample Excalidraw files object with embedded images
|
||||
*/
|
||||
export const createSampleFilesObject = (imageCount: number = 1, size: "small" | "large" = "small") => {
|
||||
const files: Record<string, any> = {};
|
||||
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const fileId = `file-${i}-${Date.now()}`;
|
||||
files[fileId] = {
|
||||
id: fileId,
|
||||
mimeType: "image/png",
|
||||
dataURL: size === "large" ? generateLargeImageDataUrl() : generateSampleImageDataUrl("small"),
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a minimal valid Excalidraw drawing payload
|
||||
*/
|
||||
export const createTestDrawingPayload = (options: {
|
||||
name?: string;
|
||||
files?: Record<string, any> | null;
|
||||
elements?: any[];
|
||||
appState?: any;
|
||||
} = {}) => {
|
||||
return {
|
||||
name: options.name ?? "Test Drawing",
|
||||
elements: options.elements ?? [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
seed: 12345,
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
isDeleted: false,
|
||||
boundElements: null,
|
||||
updated: Date.now(),
|
||||
link: null,
|
||||
locked: false,
|
||||
},
|
||||
],
|
||||
appState: options.appState ?? {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
gridSize: null,
|
||||
},
|
||||
files: options.files ?? null,
|
||||
preview: null,
|
||||
collectionId: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two files objects to check if image data was preserved
|
||||
*/
|
||||
export const compareFilesObjects = (original: Record<string, any>, received: Record<string, any>): {
|
||||
isEqual: boolean;
|
||||
differences: string[];
|
||||
} => {
|
||||
const differences: string[] = [];
|
||||
|
||||
const originalKeys = Object.keys(original);
|
||||
const receivedKeys = Object.keys(received);
|
||||
|
||||
if (originalKeys.length !== receivedKeys.length) {
|
||||
differences.push(`Key count mismatch: original=${originalKeys.length}, received=${receivedKeys.length}`);
|
||||
}
|
||||
|
||||
for (const key of originalKeys) {
|
||||
if (!(key in received)) {
|
||||
differences.push(`Missing key: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const origFile = original[key];
|
||||
const recvFile = received[key];
|
||||
|
||||
// Check dataURL specifically - this is where truncation would occur
|
||||
if (origFile.dataURL !== recvFile.dataURL) {
|
||||
differences.push(
|
||||
`DataURL mismatch for ${key}: ` +
|
||||
`original length=${origFile.dataURL?.length ?? 0}, ` +
|
||||
`received length=${recvFile.dataURL?.length ?? 0}`
|
||||
);
|
||||
|
||||
// Check if it was truncated
|
||||
if (recvFile.dataURL && origFile.dataURL?.startsWith(recvFile.dataURL.substring(0, 100))) {
|
||||
differences.push(`TRUNCATION DETECTED: dataURL was cut short`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isEqual: differences.length === 0,
|
||||
differences,
|
||||
};
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums'
|
||||
export * from './enums';
|
||||
/**
|
||||
* Model Collection
|
||||
*
|
||||
*/
|
||||
export type Collection = Prisma.CollectionModel
|
||||
/**
|
||||
* Model Drawing
|
||||
*
|
||||
*/
|
||||
export type Drawing = Prisma.DrawingModel
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import * as $Class from "./internal/class"
|
||||
import * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
export * as $Enums from './enums'
|
||||
export * from "./enums"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient()
|
||||
* // Fetch zero or more Collections
|
||||
* const collections = await prisma.collection.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model Collection
|
||||
*
|
||||
*/
|
||||
export type Collection = Prisma.CollectionModel
|
||||
/**
|
||||
* Model Drawing
|
||||
*
|
||||
*/
|
||||
export type Drawing = Prisma.DrawingModel
|
||||
@@ -1,272 +0,0 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import type * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[]
|
||||
notIn?: string[]
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[]
|
||||
notIn?: Date[] | string[]
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[]
|
||||
notIn?: string[]
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[]
|
||||
notIn?: Date[] | string[]
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
notIn?: number[]
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | null
|
||||
notIn?: string[] | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
notIn?: number[]
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | null
|
||||
notIn?: string[] | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[]
|
||||
notIn?: string[]
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[]
|
||||
notIn?: Date[] | string[]
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[]
|
||||
notIn?: string[]
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
notIn?: number[]
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[]
|
||||
notIn?: Date[] | string[]
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | null
|
||||
notIn?: string[] | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
notIn?: number[]
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[]
|
||||
notIn?: number[]
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | null
|
||||
notIn?: string[] | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | null
|
||||
notIn?: number[] | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user