Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e97fbbdf27 | |||
| 2e40deb82c | |||
| 4ebc99152a | |||
| 44317c4981 |
@@ -7,9 +7,3 @@ dist
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
backend
|
|
||||||
frontend/node_modules
|
|
||||||
frontend/dist
|
|
||||||
frontend/coverage
|
|
||||||
frontend/test-results
|
|
||||||
frontend/playwright-report
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
# Start backend server in background
|
# Start backend server in background
|
||||||
cd backend
|
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=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
# Wait for frontend to be ready
|
# Wait for frontend to be ready
|
||||||
echo "Waiting for frontend server..."
|
echo "Waiting for frontend server..."
|
||||||
for i in {1..30}; do
|
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!"
|
echo "Frontend is ready!"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../../../frontend
|
|
||||||
@@ -6,8 +6,6 @@
|
|||||||

|

|
||||||
[](https://hub.docker.com)
|
[](https://hub.docker.com)
|
||||||
|
|
||||||
_Original repo can be found [here](https://github.com/ZimengXiong/ExcaliDash)_
|
|
||||||
|
|
||||||
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -101,10 +99,6 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
# Access the frontend at localhost:6767
|
# Access the frontend at localhost:6767
|
||||||
```
|
```
|
||||||
|
|
||||||
For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly.
|
|
||||||
|
|
||||||
By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers.
|
|
||||||
|
|
||||||
## Docker Build
|
## Docker Build
|
||||||
|
|
||||||
[Install Docker](https://docs.docker.com/desktop/)
|
[Install Docker](https://docs.docker.com/desktop/)
|
||||||
@@ -127,7 +121,6 @@ docker compose up -d
|
|||||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
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. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||||
- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers.
|
|
||||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
- `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
|
```yaml
|
||||||
@@ -136,8 +129,6 @@ backend:
|
|||||||
environment:
|
environment:
|
||||||
# Single URL
|
# Single URL
|
||||||
- FRONTEND_URL=https://excalidash.example.com
|
- FRONTEND_URL=https://excalidash.example.com
|
||||||
# Trust exactly one reverse-proxy hop
|
|
||||||
- TRUST_PROXY=1
|
|
||||||
# Or multiple URLs (comma-separated) for local + network access
|
# 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_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||||
frontend:
|
frontend:
|
||||||
@@ -150,7 +141,7 @@ frontend:
|
|||||||
|
|
||||||
### Multi-Container / Kubernetes Deployments
|
### 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
|
```bash
|
||||||
# Generate a secure secret
|
# Generate a secure secret
|
||||||
@@ -161,37 +152,11 @@ openssl rand -base64 32
|
|||||||
# docker-compose.yml or k8s deployment
|
# docker-compose.yml or k8s deployment
|
||||||
backend:
|
backend:
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=your-generated-jwt-secret-here
|
|
||||||
- CSRF_SECRET=your-generated-secret-here
|
- CSRF_SECRET=your-generated-secret-here
|
||||||
```
|
```
|
||||||
|
|
||||||
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
||||||
|
|
||||||
### Authentication Modes (Local + OIDC)
|
|
||||||
|
|
||||||
ExcaliDash supports three auth modes via backend `AUTH_MODE`:
|
|
||||||
|
|
||||||
- `local` (default): native email/password login only.
|
|
||||||
- `hybrid`: native login + OIDC login.
|
|
||||||
- `oidc_enforced`: OIDC-only login (native login/register disabled).
|
|
||||||
|
|
||||||
For OIDC modes (`hybrid` or `oidc_enforced`), set:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
backend:
|
|
||||||
environment:
|
|
||||||
- AUTH_MODE=oidc_enforced
|
|
||||||
- OIDC_PROVIDER_NAME=Authentik
|
|
||||||
- OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
|
|
||||||
- OIDC_CLIENT_ID=your-client-id
|
|
||||||
- OIDC_CLIENT_SECRET=your-client-secret
|
|
||||||
- OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
|
|
||||||
- OIDC_SCOPES=openid profile email
|
|
||||||
```
|
|
||||||
|
|
||||||
In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`.
|
|
||||||
Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned.
|
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
## Clone the Repository
|
## Clone the Repository
|
||||||
@@ -232,27 +197,6 @@ npx prisma db push
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Simulate Auth Onboarding (Development)
|
|
||||||
|
|
||||||
To simulate first-run authentication choice flows in local development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ExcaliDash/backend
|
|
||||||
|
|
||||||
# Preview what would change (no data modifications)
|
|
||||||
npm run dev:simulate-auth-onboarding:dry-run
|
|
||||||
|
|
||||||
# Simulate "fresh install" onboarding state
|
|
||||||
# (wipes drawings/collections/libraries and removes non-bootstrap users)
|
|
||||||
npm run dev:simulate-auth-onboarding:fresh
|
|
||||||
|
|
||||||
# Simulate "migration" onboarding state (ensures legacy data exists)
|
|
||||||
npm run dev:simulate-auth-onboarding:migration
|
|
||||||
```
|
|
||||||
|
|
||||||
After running a simulation while the backend is already running, wait about 5 seconds
|
|
||||||
(auth mode cache TTL) or restart the backend before refreshing the UI.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+40
-6
@@ -1,9 +1,43 @@
|
|||||||
Multi user setup is opt-in, single user by default
|
CSRF Protection (8a78b2b)
|
||||||
|
|
||||||
Multi-user support for excalidash
|
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
|
||||||
- Admin dashboard
|
- Added new backend/src/security.ts module for security utilities
|
||||||
- Password reset, force user password reset (admin only), account lockout recovery
|
- Frontend API layer now handles CSRF tokens automatically
|
||||||
- Rate limits
|
- 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.
|
||||||
|
```
|
||||||
|
|||||||
@@ -9,7 +9,3 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
prisma/dev.db-journal
|
prisma/dev.db-journal
|
||||||
src/generated
|
|
||||||
coverage
|
|
||||||
*.test.ts
|
|
||||||
*.spec.ts
|
|
||||||
|
|||||||
+1
-18
@@ -2,11 +2,7 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=https://draw.louiscreates.com
|
FRONTEND_URL=http://localhost:6767
|
||||||
API_BASE_PATH=/api
|
|
||||||
# Keep disabled unless traffic always comes through a trusted reverse proxy.
|
|
||||||
TRUST_PROXY=false
|
|
||||||
AUTH_MODE=local
|
|
||||||
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
||||||
|
|
||||||
# Optional Feature Flags (all default to false for backward compatibility)
|
# Optional Feature Flags (all default to false for backward compatibility)
|
||||||
@@ -14,16 +10,3 @@ JWT_SECRET=change-this-secret-in-production-min-32-chars
|
|||||||
# ENABLE_PASSWORD_RESET=false
|
# ENABLE_PASSWORD_RESET=false
|
||||||
# ENABLE_REFRESH_TOKEN_ROTATION=false
|
# ENABLE_REFRESH_TOKEN_ROTATION=false
|
||||||
# ENABLE_AUDIT_LOGGING=false
|
# ENABLE_AUDIT_LOGGING=false
|
||||||
|
|
||||||
# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced)
|
|
||||||
# OIDC_PROVIDER_NAME=Authentik
|
|
||||||
# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/
|
|
||||||
# OIDC_CLIENT_ID=your-client-id
|
|
||||||
# OIDC_CLIENT_SECRET=your-client-secret
|
|
||||||
# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback
|
|
||||||
# OIDC_SCOPES=openid profile email
|
|
||||||
# OIDC_EMAIL_CLAIM=email
|
|
||||||
# OIDC_EMAIL_VERIFIED_CLAIM=email_verified
|
|
||||||
# OIDC_REQUIRE_EMAIL_VERIFIED=true
|
|
||||||
# OIDC_JIT_PROVISIONING=true
|
|
||||||
# OIDC_FIRST_USER_ADMIN=true
|
|
||||||
|
|||||||
+6
-9
@@ -3,15 +3,12 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
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 files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm ci
|
||||||
|
|
||||||
# Copy prisma schema
|
# Copy prisma schema
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
@@ -28,7 +25,7 @@ RUN npx tsc
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
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 && \
|
RUN apk add --no-cache openssl su-exec && \
|
||||||
addgroup -g 1001 -S nodejs && \
|
addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
adduser -S nodejs -u 1001
|
||||||
@@ -39,10 +36,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
RUN npm ci --only=production
|
||||||
npm ci --omit=dev && \
|
|
||||||
npm cache clean --force && \
|
|
||||||
apk del .build-deps
|
|
||||||
|
|
||||||
# Copy prisma schema and migrations for runtime and hydration template
|
# Copy prisma schema and migrations for runtime and hydration template
|
||||||
COPY prisma ./prisma/
|
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 the generated Prisma Client from builder to maintain the same structure
|
||||||
COPY --from=builder /app/src/generated ./dist/generated
|
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)
|
# Create necessary directories (ownership will be set in entrypoint)
|
||||||
RUN mkdir -p /app/uploads /app/prisma
|
RUN mkdir -p /app/uploads /app/prisma
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
|
|
||||||
CSRF_SECRET_FILE="/app/prisma/.csrf_secret"
|
|
||||||
|
|
||||||
# Ensure JWT secret exists for production startup.
|
|
||||||
# Backward compatibility: older installs may not have JWT_SECRET configured.
|
|
||||||
if [ -z "${JWT_SECRET:-}" ]; then
|
|
||||||
echo "JWT_SECRET not provided, resolving persisted secret..."
|
|
||||||
if [ -f "${JWT_SECRET_FILE}" ]; then
|
|
||||||
JWT_SECRET="$(tr -d '\r\n' < "${JWT_SECRET_FILE}")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${JWT_SECRET}" ]; then
|
|
||||||
echo "No persisted JWT secret found. Generating a new secret..."
|
|
||||||
JWT_SECRET="$(openssl rand -hex 32)"
|
|
||||||
umask 077
|
|
||||||
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Persist explicitly provided secret to support future restarts without env injection.
|
|
||||||
umask 077
|
|
||||||
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export JWT_SECRET
|
|
||||||
|
|
||||||
# Ensure CSRF secret exists for stable token validation across restarts.
|
|
||||||
# (Still recommend setting explicitly for multi-instance deployments.)
|
|
||||||
if [ -z "${CSRF_SECRET:-}" ]; then
|
|
||||||
echo "CSRF_SECRET not provided, resolving persisted secret..."
|
|
||||||
if [ -f "${CSRF_SECRET_FILE}" ]; then
|
|
||||||
CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${CSRF_SECRET}" ]; then
|
|
||||||
echo "No persisted CSRF secret found. Generating a new secret..."
|
|
||||||
CSRF_SECRET="$(openssl rand -base64 32)"
|
|
||||||
umask 077
|
|
||||||
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
umask 077
|
|
||||||
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export CSRF_SECRET
|
|
||||||
|
|
||||||
# 1. Hydrate volume if empty (Running as root)
|
# 1. Hydrate volume if empty (Running as root)
|
||||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||||
echo "Mount is empty. Hydrating /app/prisma..."
|
echo "Mount is empty. Hydrating /app/prisma..."
|
||||||
@@ -64,13 +18,11 @@ echo "Fixing filesystem permissions..."
|
|||||||
chown -R nodejs:nodejs /app/uploads
|
chown -R nodejs:nodejs /app/uploads
|
||||||
chown -R nodejs:nodejs /app/prisma
|
chown -R nodejs:nodejs /app/prisma
|
||||||
chmod 755 /app/uploads
|
chmod 755 /app/uploads
|
||||||
chmod 600 "${JWT_SECRET_FILE}"
|
|
||||||
chmod 600 "${CSRF_SECRET_FILE}"
|
|
||||||
|
|
||||||
# Ensure database file has proper permissions
|
# Ensure database file has proper permissions
|
||||||
if [ -f "/app/prisma/dev.db" ]; then
|
if [ -f "/app/prisma/dev.db" ]; then
|
||||||
echo "Database file found, ensuring write permissions..."
|
echo "Database file found, ensuring write permissions..."
|
||||||
chmod 600 /app/prisma/dev.db
|
chmod 666 /app/prisma/dev.db
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Run Migrations (Drop privileges to nodejs)
|
# 3. Run Migrations (Drop privileges to nodejs)
|
||||||
|
|||||||
Generated
+17
-92
@@ -1,15 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.4.6",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.4.6",
|
"version": "0.3.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
@@ -24,25 +32,16 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"openid-client": "^5.7.1",
|
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@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/node": "^24.10.1",
|
||||||
"@types/socket.io": "^3.0.1",
|
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@@ -1008,7 +1007,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
|
||||||
"integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==",
|
"integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/readdir-glob": "*"
|
"@types/readdir-glob": "*"
|
||||||
@@ -1018,7 +1016,6 @@
|
|||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -1028,7 +1025,6 @@
|
|||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
@@ -1050,7 +1046,6 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -1090,7 +1085,6 @@
|
|||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
@@ -1102,7 +1096,6 @@
|
|||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -1115,14 +1108,12 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/jsdom": {
|
"node_modules/@types/jsdom": {
|
||||||
"version": "21.1.7",
|
"version": "21.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
|
||||||
"integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
|
"integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -1134,7 +1125,6 @@
|
|||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ms": "*",
|
"@types/ms": "*",
|
||||||
@@ -1152,21 +1142,18 @@
|
|||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/multer": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
@@ -1177,6 +1164,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1185,21 +1173,18 @@
|
|||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/readdir-glob": {
|
"node_modules/@types/readdir-glob": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
|
||||||
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
|
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -1209,7 +1194,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -1219,7 +1203,6 @@
|
|||||||
"version": "1.15.10",
|
"version": "1.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
@@ -1231,7 +1214,6 @@
|
|||||||
"version": "0.17.6",
|
"version": "0.17.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
@@ -1242,7 +1224,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz",
|
||||||
"integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==",
|
"integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io": "*"
|
"socket.io": "*"
|
||||||
@@ -1276,7 +1257,6 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
@@ -1290,7 +1270,6 @@
|
|||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
@@ -2676,6 +2655,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz",
|
||||||
"integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==",
|
"integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -3315,15 +3295,6 @@
|
|||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
|
||||||
"version": "4.15.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
|
||||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "22.1.0",
|
"version": "22.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||||
@@ -3919,15 +3890,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-hash": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -3951,15 +3913,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/oidc-token-hash": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^10.13.0 || >=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -3981,33 +3934,6 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openid-client": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jose": "^4.15.9",
|
|
||||||
"lru-cache": "^6.0.0",
|
|
||||||
"object-hash": "^2.2.0",
|
|
||||||
"oidc-token-hash": "^5.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/openid-client/node_modules/lru-cache": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"yallist": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -4164,6 +4090,7 @@
|
|||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "5.22.0"
|
"@prisma/engines": "5.22.0"
|
||||||
},
|
},
|
||||||
@@ -5176,6 +5103,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5334,6 +5262,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5424,6 +5353,7 @@
|
|||||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5517,6 +5447,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5844,12 +5775,6 @@
|
|||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/yn": {
|
"node_modules/yn": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
|||||||
+9
-13
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.4.6",
|
"version": "0.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/predev-migrate.cjs",
|
"predev": "node scripts/predev-migrate.cjs",
|
||||||
"dev": "nodemon src/index.ts",
|
"dev": "nodemon src/index.ts",
|
||||||
"admin:recover": "node scripts/admin-recover.cjs",
|
"admin:recover": "node scripts/admin-recover.cjs",
|
||||||
"dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh",
|
|
||||||
"dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration",
|
|
||||||
"dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -20,6 +17,14 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
@@ -34,25 +39,16 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"openid-client": "^5.7.1",
|
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@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/node": "^24.10.1",
|
||||||
"@types/socket.io": "^3.0.1",
|
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
Generated
-3783
File diff suppressed because it is too large
Load Diff
@@ -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,2 +0,0 @@
|
|||||||
-- Track whether initial auth mode choice has been explicitly completed.
|
|
||||||
ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "AuthIdentity" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"issuer" TEXT NOT NULL,
|
|
||||||
"subject" TEXT NOT NULL,
|
|
||||||
"emailAtLink" TEXT NOT NULL,
|
|
||||||
"lastLoginAt" DATETIME,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId");
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "DrawingShareLink" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"drawingId" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "DrawingShareGrant" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"drawingId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"shareLinkId" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
|
|
||||||
@@ -21,13 +21,11 @@ model User {
|
|||||||
role String @default("USER")
|
role String @default("USER")
|
||||||
mustResetPassword Boolean @default(false)
|
mustResetPassword Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
authIdentities AuthIdentity[]
|
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
drawingShareGrants DrawingShareGrant[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -35,7 +33,6 @@ model User {
|
|||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
id String @id @default("default")
|
id String @id @default("default")
|
||||||
authEnabled Boolean @default(false)
|
authEnabled Boolean @default(false)
|
||||||
authOnboardingCompleted Boolean @default(false)
|
|
||||||
registrationEnabled Boolean @default(false)
|
registrationEnabled Boolean @default(false)
|
||||||
authLoginRateLimitEnabled Boolean @default(true)
|
authLoginRateLimitEnabled Boolean @default(true)
|
||||||
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
|
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
|
||||||
@@ -52,8 +49,6 @@ model Collection {
|
|||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId, updatedAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Drawing {
|
model Drawing {
|
||||||
@@ -68,45 +63,8 @@ model Drawing {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
collectionId String?
|
collectionId String?
|
||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
shareLinks DrawingShareLink[]
|
|
||||||
shareGrants DrawingShareGrant[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId, updatedAt])
|
|
||||||
@@index([userId, collectionId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DrawingShareLink {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
drawingId String
|
|
||||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
|
||||||
role String
|
|
||||||
token String @unique
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
grants DrawingShareGrant[]
|
|
||||||
|
|
||||||
@@unique([drawingId, role])
|
|
||||||
@@index([drawingId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DrawingShareGrant {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
drawingId String
|
|
||||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
shareLinkId String
|
|
||||||
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
|
|
||||||
role String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([drawingId, userId, shareLinkId])
|
|
||||||
@@index([drawingId, userId])
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
@@ -147,20 +105,3 @@ model AuditLog {
|
|||||||
details String? // JSON string for additional details
|
details String? // JSON string for additional details
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuthIdentity {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
provider String
|
|
||||||
issuer String
|
|
||||||
subject String
|
|
||||||
emailAtLink String
|
|
||||||
lastLoginAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([issuer, subject])
|
|
||||||
@@unique([provider, userId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ const resolveDatabaseUrl = (rawUrl) => {
|
|||||||
|
|
||||||
const absolutePath = path.isAbsolute(filePath)
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
? filePath
|
? filePath
|
||||||
: path.resolve(
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||||
hasLeadingPrismaDir ? backendRoot : prismaDir,
|
|
||||||
normalizedRelative,
|
|
||||||
);
|
|
||||||
|
|
||||||
return `file:${absolutePath}`;
|
return `file:${absolutePath}`;
|
||||||
};
|
};
|
||||||
@@ -94,15 +91,7 @@ const backupDbIfPresent = () => {
|
|||||||
const isNonProd = nodeEnv !== "production";
|
const isNonProd = nodeEnv !== "production";
|
||||||
const isFileDb = databaseUrl.startsWith("file:");
|
const isFileDb = databaseUrl.startsWith("file:");
|
||||||
|
|
||||||
let deploy = runCapture("npx prisma migrate deploy");
|
const deploy = runCapture("npx prisma migrate deploy");
|
||||||
|
|
||||||
if (!deploy.ok) {
|
|
||||||
console.warn(
|
|
||||||
`[predev] Prisma migrate deploy failed. Attempting pnpm exec...`,
|
|
||||||
);
|
|
||||||
deploy = runCapture("pnpm exec prisma migrate deploy");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deploy.ok) {
|
if (deploy.ok) {
|
||||||
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
||||||
} else {
|
} else {
|
||||||
@@ -122,16 +111,7 @@ if (deploy.ok) {
|
|||||||
` If you need to preserve local data, restore the backup and baseline manually.`,
|
` If you need to preserve local data, restore the backup and baseline manually.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// check for npx, if not present try pnpm exec
|
run("npx prisma migrate reset --force --skip-seed");
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
`[predev] Running: npx prisma migrate reset --force --skip-seed`,
|
|
||||||
);
|
|
||||||
run("npx prisma migrate reset --force --skip-seed");
|
|
||||||
} catch {
|
|
||||||
console.warn(`[predev] npx not found, trying pnpm exec...`);
|
|
||||||
run("pnpm exec prisma migrate reset --force --skip-seed");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw deploy.error;
|
throw deploy.error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
require("dotenv").config();
|
|
||||||
|
|
||||||
const path = require("path");
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
const { PrismaClient } = require("../src/generated/client");
|
|
||||||
|
|
||||||
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
|
||||||
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
|
||||||
const backendRoot = path.resolve(__dirname, "..");
|
|
||||||
|
|
||||||
const resolveDatabaseUrl = (rawUrl) => {
|
|
||||||
const backendRoot = path.resolve(__dirname, "..");
|
|
||||||
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
|
||||||
|
|
||||||
if (!rawUrl || String(rawUrl).trim().length === 0) {
|
|
||||||
return `file:${defaultDbPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!String(rawUrl).startsWith("file:")) {
|
|
||||||
return String(rawUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = String(rawUrl).replace(/^file:/, "");
|
|
||||||
const prismaDir = path.resolve(backendRoot, "prisma");
|
|
||||||
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
|
||||||
const hasLeadingPrismaDir =
|
|
||||||
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
|
||||||
|
|
||||||
const absolutePath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
|
||||||
|
|
||||||
return `file:${absolutePath}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
|
||||||
|
|
||||||
const parseArgs = (argv) => {
|
|
||||||
const parsed = {
|
|
||||||
scenario: "",
|
|
||||||
dryRun: false,
|
|
||||||
allowProd: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i];
|
|
||||||
if (token === "--scenario") {
|
|
||||||
parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase();
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--dry-run") {
|
|
||||||
parsed.dryRun = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--allow-production") {
|
|
||||||
parsed.allowProd = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token === "--help" || token === "-h") {
|
|
||||||
parsed.help = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const usage = () => {
|
|
||||||
console.log(`Usage:
|
|
||||||
node scripts/simulate-auth-onboarding.cjs --scenario fresh
|
|
||||||
node scripts/simulate-auth-onboarding.cjs --scenario migration
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--dry-run Show what would change without modifying data
|
|
||||||
--allow-production Override production safety check (not recommended)
|
|
||||||
--help, -h Show this help
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertScenario = (scenario) => {
|
|
||||||
if (scenario !== "fresh" && scenario !== "migration") {
|
|
||||||
throw new Error("Invalid --scenario. Use 'fresh' or 'migration'.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nowIso = () => new Date().toISOString();
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
if (args.help) {
|
|
||||||
usage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assertScenario(args.scenario);
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || "development";
|
|
||||||
if (nodeEnv === "production" && !args.allowProd) {
|
|
||||||
throw new Error(
|
|
||||||
"Refusing to run in production. Pass --allow-production only if you explicitly intend this."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep migration history authoritative to avoid drift between db push and deploy.
|
|
||||||
// Includes a self-heal path for the known duplicate-column failure on
|
|
||||||
// 20260210153000_add_auth_onboarding_completed in local dev databases.
|
|
||||||
if (nodeEnv !== "production") {
|
|
||||||
const runDeploy = () =>
|
|
||||||
execSync("npx prisma migrate deploy", {
|
|
||||||
cwd: backendRoot,
|
|
||||||
stdio: "pipe",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
runDeploy();
|
|
||||||
} catch (error) {
|
|
||||||
const stdout =
|
|
||||||
error && error.stdout
|
|
||||||
? Buffer.isBuffer(error.stdout)
|
|
||||||
? error.stdout.toString("utf8")
|
|
||||||
: String(error.stdout)
|
|
||||||
: "";
|
|
||||||
const stderr =
|
|
||||||
error && error.stderr
|
|
||||||
? Buffer.isBuffer(error.stderr)
|
|
||||||
? error.stderr.toString("utf8")
|
|
||||||
: String(error.stderr)
|
|
||||||
: "";
|
|
||||||
const combined = `${stdout}\n${stderr}`;
|
|
||||||
|
|
||||||
const canAutoResolve =
|
|
||||||
combined.includes("Error: P3009") &&
|
|
||||||
combined.includes("20260210153000_add_auth_onboarding_completed") &&
|
|
||||||
combined.includes("duplicate column name: authOnboardingCompleted");
|
|
||||||
|
|
||||||
if (!canAutoResolve) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
execSync(
|
|
||||||
"npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed",
|
|
||||||
{
|
|
||||||
cwd: backendRoot,
|
|
||||||
stdio: "pipe",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
runDeploy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const before = {
|
|
||||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
|
||||||
users: await prisma.user.count(),
|
|
||||||
drawings: await prisma.drawing.count(),
|
|
||||||
collections: await prisma.collection.count(),
|
|
||||||
auth: await prisma.systemConfig.findUnique({
|
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
||||||
select: {
|
|
||||||
authEnabled: true,
|
|
||||||
authOnboardingCompleted: true,
|
|
||||||
registrationEnabled: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`);
|
|
||||||
console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`);
|
|
||||||
console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`);
|
|
||||||
console.log("[simulate-auth-onboarding] before:", before);
|
|
||||||
|
|
||||||
if (args.dryRun) {
|
|
||||||
console.log("[simulate-auth-onboarding] dry-run only. No data changed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.systemConfig.upsert({
|
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
||||||
update: {
|
|
||||||
authEnabled: false,
|
|
||||||
authOnboardingCompleted: false,
|
|
||||||
registrationEnabled: false,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
|
||||||
authEnabled: false,
|
|
||||||
authOnboardingCompleted: false,
|
|
||||||
registrationEnabled: false,
|
|
||||||
authLoginRateLimitEnabled: true,
|
|
||||||
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
|
||||||
authLoginRateLimitMax: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.user.updateMany({
|
|
||||||
data: {
|
|
||||||
isActive: false,
|
|
||||||
mustResetPassword: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.user.upsert({
|
|
||||||
where: { id: BOOTSTRAP_USER_ID },
|
|
||||||
update: {
|
|
||||||
email: "bootstrap@excalidash.local",
|
|
||||||
username: null,
|
|
||||||
passwordHash: "",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
role: "ADMIN",
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: BOOTSTRAP_USER_ID,
|
|
||||||
email: "bootstrap@excalidash.local",
|
|
||||||
username: null,
|
|
||||||
passwordHash: "",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
role: "ADMIN",
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (args.scenario === "fresh") {
|
|
||||||
await tx.drawing.deleteMany({});
|
|
||||||
await tx.collection.deleteMany({});
|
|
||||||
await tx.library.deleteMany({});
|
|
||||||
await tx.user.deleteMany({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
not: BOOTSTRAP_USER_ID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migration simulation:
|
|
||||||
// 1) Reassign existing data ownership to bootstrap user
|
|
||||||
// 2) Ensure at least one drawing+collection exists so UI shows migration messaging
|
|
||||||
await tx.collection.updateMany({
|
|
||||||
data: { userId: BOOTSTRAP_USER_ID },
|
|
||||||
});
|
|
||||||
await tx.drawing.updateMany({
|
|
||||||
data: { userId: BOOTSTRAP_USER_ID },
|
|
||||||
});
|
|
||||||
|
|
||||||
const collectionCount = await tx.collection.count();
|
|
||||||
let targetCollectionId = null;
|
|
||||||
|
|
||||||
if (collectionCount === 0) {
|
|
||||||
targetCollectionId = `sim-migration-col-${Date.now()}`;
|
|
||||||
await tx.collection.create({
|
|
||||||
data: {
|
|
||||||
id: targetCollectionId,
|
|
||||||
name: "Migrated Collection",
|
|
||||||
userId: BOOTSTRAP_USER_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const existing = await tx.collection.findFirst({
|
|
||||||
where: { userId: BOOTSTRAP_USER_ID },
|
|
||||||
select: { id: true },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
targetCollectionId = existing ? existing.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawingCount = await tx.drawing.count();
|
|
||||||
if (drawingCount === 0) {
|
|
||||||
await tx.drawing.create({
|
|
||||||
data: {
|
|
||||||
id: `sim-migration-draw-${Date.now()}`,
|
|
||||||
name: "Migrated Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
files: "{}",
|
|
||||||
preview: null,
|
|
||||||
version: 1,
|
|
||||||
userId: BOOTSTRAP_USER_ID,
|
|
||||||
collectionId: targetCollectionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const after = {
|
|
||||||
activeUsers: await prisma.user.count({ where: { isActive: true } }),
|
|
||||||
users: await prisma.user.count(),
|
|
||||||
drawings: await prisma.drawing.count(),
|
|
||||||
collections: await prisma.collection.count(),
|
|
||||||
auth: await prisma.systemConfig.findUnique({
|
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
|
||||||
select: {
|
|
||||||
authEnabled: true,
|
|
||||||
authOnboardingCompleted: true,
|
|
||||||
registrationEnabled: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("[simulate-auth-onboarding] after:", after);
|
|
||||||
console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`);
|
|
||||||
console.log(
|
|
||||||
"[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error("simulate-auth-onboarding failed:", error.message || error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import request from "supertest";
|
|
||||||
import bcrypt from "bcrypt";
|
|
||||||
import jwt, { SignOptions } from "jsonwebtoken";
|
|
||||||
import { StringValue } from "ms";
|
|
||||||
import { PrismaClient } from "../generated/client";
|
|
||||||
import { config } from "../config";
|
|
||||||
import { getTestPrisma, setupTestDb } from "./testUtils";
|
|
||||||
|
|
||||||
describe("Auth Enabled Toggle Authorization", () => {
|
|
||||||
const userAgent = "vitest-auth-enabled";
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let app: any;
|
|
||||||
let agent: any;
|
|
||||||
let csrfHeaderName: string;
|
|
||||||
let csrfToken: string;
|
|
||||||
let regularUserToken: string;
|
|
||||||
let adminUserToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
setupTestDb();
|
|
||||||
prisma = getTestPrisma();
|
|
||||||
|
|
||||||
({ app } = await import("../index"));
|
|
||||||
|
|
||||||
await prisma.systemConfig.upsert({
|
|
||||||
where: { id: "default" },
|
|
||||||
update: {
|
|
||||||
authEnabled: true,
|
|
||||||
registrationEnabled: false,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: "default",
|
|
||||||
authEnabled: true,
|
|
||||||
registrationEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash("password123", 10);
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: "regular-user@test.local",
|
|
||||||
passwordHash,
|
|
||||||
name: "Regular User",
|
|
||||||
role: "USER",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const signOptions: SignOptions = {
|
|
||||||
expiresIn: config.jwtAccessExpiresIn as StringValue,
|
|
||||||
};
|
|
||||||
regularUserToken = jwt.sign(
|
|
||||||
{ userId: user.id, email: user.email, type: "access" },
|
|
||||||
config.jwtSecret,
|
|
||||||
signOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
const admin = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: "admin-user@test.local",
|
|
||||||
passwordHash,
|
|
||||||
name: "Admin User",
|
|
||||||
role: "ADMIN",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
adminUserToken = jwt.sign(
|
|
||||||
{ userId: admin.id, email: admin.email, type: "access" },
|
|
||||||
config.jwtSecret,
|
|
||||||
signOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
agent = request.agent(app);
|
|
||||||
const csrfRes = await agent
|
|
||||||
.get("/csrf-token")
|
|
||||||
.set("User-Agent", userAgent);
|
|
||||||
csrfHeaderName = csrfRes.body.header;
|
|
||||||
csrfToken = csrfRes.body.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
|
|
||||||
const response = await agent
|
|
||||||
.post("/auth/auth-enabled")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({ enabled: false });
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-admin auth-enabled toggle", async () => {
|
|
||||||
const response = await agent
|
|
||||||
.post("/auth/auth-enabled")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set("Authorization", `Bearer ${regularUserToken}`)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({ enabled: false });
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(response.body?.message).toContain("Admin access required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies auth mode change immediately for subsequent requests", async () => {
|
|
||||||
const warmStatusResponse = await request(app)
|
|
||||||
.get("/auth/status")
|
|
||||||
.set("User-Agent", userAgent);
|
|
||||||
expect(warmStatusResponse.status).toBe(200);
|
|
||||||
expect(warmStatusResponse.body?.authEnabled).toBe(true);
|
|
||||||
|
|
||||||
const toggleResponse = await agent
|
|
||||||
.post("/auth/auth-enabled")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set("Authorization", `Bearer ${adminUserToken}`)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({ enabled: false });
|
|
||||||
expect(toggleResponse.status).toBe(200);
|
|
||||||
expect(toggleResponse.body?.authEnabled).toBe(false);
|
|
||||||
|
|
||||||
const drawingsResponse = await request(app)
|
|
||||||
.get("/drawings")
|
|
||||||
.set("User-Agent", userAgent);
|
|
||||||
expect(drawingsResponse.status).toBe(200);
|
|
||||||
expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import request from "supertest";
|
|
||||||
import { PrismaClient } from "../generated/client";
|
|
||||||
import { getTestPrisma, setupTestDb } from "./testUtils";
|
|
||||||
import { BOOTSTRAP_USER_ID } from "../auth/authMode";
|
|
||||||
|
|
||||||
describe("Auth onboarding decision", () => {
|
|
||||||
const userAgent = "vitest-auth-onboarding";
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let app: any;
|
|
||||||
let agent: any;
|
|
||||||
let csrfHeaderName: string;
|
|
||||||
let csrfToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
setupTestDb();
|
|
||||||
prisma = getTestPrisma();
|
|
||||||
|
|
||||||
({ app } = await import("../index"));
|
|
||||||
|
|
||||||
agent = request.agent(app);
|
|
||||||
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
|
|
||||||
csrfHeaderName = csrfRes.body.header;
|
|
||||||
csrfToken = csrfRes.body.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports migration onboarding mode when no active users and legacy data exists", async () => {
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { id: BOOTSTRAP_USER_ID },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: BOOTSTRAP_USER_ID,
|
|
||||||
email: "bootstrap@excalidash.local",
|
|
||||||
username: null,
|
|
||||||
passwordHash: "",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
role: "ADMIN",
|
|
||||||
mustResetPassword: true,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.systemConfig.upsert({
|
|
||||||
where: { id: "default" },
|
|
||||||
update: { authEnabled: false, authOnboardingCompleted: false },
|
|
||||||
create: {
|
|
||||||
id: "default",
|
|
||||||
authEnabled: false,
|
|
||||||
authOnboardingCompleted: false,
|
|
||||||
registrationEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.collection.upsert({
|
|
||||||
where: { id: "legacy-collection" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: "legacy-collection",
|
|
||||||
name: "Legacy",
|
|
||||||
userId: BOOTSTRAP_USER_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.drawing.upsert({
|
|
||||||
where: { id: "legacy-drawing" },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: "legacy-drawing",
|
|
||||||
name: "Legacy Drawing",
|
|
||||||
elements: "[]",
|
|
||||||
appState: "{}",
|
|
||||||
files: "{}",
|
|
||||||
userId: BOOTSTRAP_USER_ID,
|
|
||||||
collectionId: "legacy-collection",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body?.authEnabled).toBe(false);
|
|
||||||
expect(response.body?.authOnboardingRequired).toBe(true);
|
|
||||||
expect(response.body?.authOnboardingMode).toBe("migration");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists a single-user onboarding choice", async () => {
|
|
||||||
await prisma.systemConfig.update({
|
|
||||||
where: { id: "default" },
|
|
||||||
data: { authEnabled: false, authOnboardingCompleted: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const choiceResponse = await agent
|
|
||||||
.post("/auth/onboarding-choice")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({ enableAuth: false });
|
|
||||||
|
|
||||||
expect(choiceResponse.status).toBe(200);
|
|
||||||
expect(choiceResponse.body?.authEnabled).toBe(false);
|
|
||||||
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
|
|
||||||
|
|
||||||
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
|
||||||
expect(statusResponse.status).toBe(200);
|
|
||||||
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enables auth and bootstrap flow from onboarding choice", async () => {
|
|
||||||
await prisma.drawing.deleteMany({});
|
|
||||||
await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } });
|
|
||||||
await prisma.systemConfig.update({
|
|
||||||
where: { id: "default" },
|
|
||||||
data: { authEnabled: false, authOnboardingCompleted: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const choiceResponse = await agent
|
|
||||||
.post("/auth/onboarding-choice")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({ enableAuth: true });
|
|
||||||
|
|
||||||
expect(choiceResponse.status).toBe(200);
|
|
||||||
expect(choiceResponse.body?.authEnabled).toBe(true);
|
|
||||||
expect(choiceResponse.body?.bootstrapRequired).toBe(true);
|
|
||||||
expect(choiceResponse.body?.authOnboardingCompleted).toBe(true);
|
|
||||||
|
|
||||||
const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent);
|
|
||||||
expect(statusResponse.status).toBe(200);
|
|
||||||
expect(statusResponse.body?.authEnabled).toBe(true);
|
|
||||||
expect(statusResponse.body?.bootstrapRequired).toBe(true);
|
|
||||||
expect(statusResponse.body?.authOnboardingRequired).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires CSRF token for bootstrap registration", async () => {
|
|
||||||
const noCsrfResponse = await agent
|
|
||||||
.post("/auth/register")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.send({
|
|
||||||
email: "bootstrap-admin@test.local",
|
|
||||||
password: "StrongPass1!",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(noCsrfResponse.status).toBe(403);
|
|
||||||
expect(noCsrfResponse.body?.error).toBe("CSRF token missing");
|
|
||||||
|
|
||||||
const bootstrapResponse = await agent
|
|
||||||
.post("/auth/register")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.send({
|
|
||||||
email: "bootstrap-admin@test.local",
|
|
||||||
password: "StrongPass1!",
|
|
||||||
name: "Bootstrap Admin",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(bootstrapResponse.status).toBe(201);
|
|
||||||
expect(bootstrapResponse.body?.bootstrapped).toBe(true);
|
|
||||||
expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createCsrfToken, validateCsrfToken } from "../security";
|
|
||||||
|
|
||||||
describe("CSRF client identity stability", () => {
|
|
||||||
it("keeps token validation stable when using cookie-based client IDs", () => {
|
|
||||||
const cookieClientId = "cookie:fixed-client-id";
|
|
||||||
const token = createCsrfToken(cookieClientId);
|
|
||||||
|
|
||||||
expect(validateCsrfToken(cookieClientId, token)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows why legacy IP-based IDs are unstable across proxy hops", () => {
|
|
||||||
const userAgent = "Mozilla/5.0 test";
|
|
||||||
const clientIdViaProxyA = `10.0.0.5:${userAgent}`;
|
|
||||||
const clientIdViaProxyB = `10.0.0.6:${userAgent}`;
|
|
||||||
const token = createCsrfToken(clientIdViaProxyA);
|
|
||||||
|
|
||||||
expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -267,90 +267,6 @@ describe("Security Sanitization - Image Data URLs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeDrawingData - preview svg handling", () => {
|
|
||||||
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
|
|
||||||
const preview = [
|
|
||||||
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
|
|
||||||
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
|
|
||||||
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
|
|
||||||
"</svg>",
|
|
||||||
].join("");
|
|
||||||
|
|
||||||
const result = sanitizeDrawingData({
|
|
||||||
elements: [],
|
|
||||||
appState: { viewBackgroundColor: "#ffffff" },
|
|
||||||
files: {},
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
|
|
||||||
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
|
|
||||||
expect(result.preview).toContain('stroke-linecap="round"');
|
|
||||||
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve safe embedded image previews", () => {
|
|
||||||
const preview = [
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
|
|
||||||
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
|
|
||||||
"</svg>",
|
|
||||||
].join("");
|
|
||||||
|
|
||||||
const result = sanitizeDrawingData({
|
|
||||||
elements: [],
|
|
||||||
appState: { viewBackgroundColor: "#ffffff" },
|
|
||||||
files: {},
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.preview).toContain("<image");
|
|
||||||
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove embedded images with unsafe href values", () => {
|
|
||||||
const preview = [
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
|
|
||||||
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
|
|
||||||
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
|
|
||||||
"</svg>",
|
|
||||||
].join("");
|
|
||||||
|
|
||||||
const result = sanitizeDrawingData({
|
|
||||||
elements: [],
|
|
||||||
appState: { viewBackgroundColor: "#ffffff" },
|
|
||||||
files: {},
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.preview).not.toContain("<image");
|
|
||||||
expect(result.preview).toContain("<rect");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
|
|
||||||
const preview = [
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
|
|
||||||
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
|
|
||||||
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
|
|
||||||
"</pattern></defs>",
|
|
||||||
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
|
|
||||||
"</svg>",
|
|
||||||
].join("");
|
|
||||||
|
|
||||||
const result = sanitizeDrawingData({
|
|
||||||
elements: [],
|
|
||||||
appState: { viewBackgroundColor: "#ffffff" },
|
|
||||||
files: {},
|
|
||||||
preview,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.preview).toContain("<defs>");
|
|
||||||
expect(result.preview).toContain("<pattern");
|
|
||||||
expect(result.preview).toContain('id="p1"');
|
|
||||||
expect(result.preview).toContain("<image");
|
|
||||||
expect(result.preview).toContain('fill="url(#p1)"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateImportedDrawing - with files", () => {
|
describe("validateImportedDrawing - with files", () => {
|
||||||
it("should validate drawing with embedded images", () => {
|
it("should validate drawing with embedded images", () => {
|
||||||
const files = createSampleFilesObject(2, "large");
|
const files = createSampleFilesObject(2, "large");
|
||||||
|
|||||||
@@ -1,448 +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 agent: 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"));
|
|
||||||
|
|
||||||
agent = request.agent(app);
|
|
||||||
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
|
|
||||||
csrfHeaderName = csrfRes.body.header;
|
|
||||||
csrfToken = csrfRes.body.token;
|
|
||||||
expect(typeof csrfHeaderName).toBe("string");
|
|
||||||
expect(typeof csrfToken).toBe("string");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cleanupTestDb(prisma);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("verifies a v0.1.x–v0.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 agent
|
|
||||||
.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 agent
|
|
||||||
.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:bootstrap-admin" },
|
|
||||||
});
|
|
||||||
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 agent
|
|
||||||
.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 agent
|
|
||||||
.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 agent
|
|
||||||
.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 agent
|
|
||||||
.post("/import/excalidash/verify")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.attach("archive", archive);
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
|
|
||||||
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
|
||||||
const res = await agent
|
|
||||||
.post("/import/excalidash")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.attach("archive", archive);
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
|
|
||||||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
|
||||||
const res = await agent
|
|
||||||
.post("/import/sqlite/legacy/verify")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.attach("db", legacyDb);
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
|
|
||||||
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
|
||||||
const res = await agent
|
|
||||||
.post("/import/sqlite/legacy")
|
|
||||||
.set("User-Agent", userAgent)
|
|
||||||
.set(csrfHeaderName, csrfToken)
|
|
||||||
.attach("db", legacyDb);
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user