Compare commits

...

74 Commits

Author SHA1 Message Date
Zimeng Xiong a366acfedc chore: pre-release v0.4.3-dev 2026-02-07 11:08:03 -08:00
Zimeng Xiong 154dcbb151 update resopnsiveness hamburger 2026-02-07 11:07:15 -08:00
Zimeng Xiong 2e74d2ad1a chore: pre-release v0.4.2-dev 2026-02-07 10:34:36 -08:00
Zimeng Xiong 173c050f58 fix HTTPS reuqirement when frontend URL is nto HTTPS 2026-02-07 10:31:08 -08:00
Zimeng Xiong 8161a563f0 chore: pre-release v0.4.1-dev 2026-02-07 10:08:27 -08:00
Zimeng Xiong 812f1cbf58 chore: pre-release v0.4.1-dev 2026-02-07 10:01:14 -08:00
Zimeng Xiong 26017fa5d2 fix JWT secret 2026-02-07 10:00:58 -08:00
Zimeng Xiong 06f4c0f537 remove dev dependencies from development containers 2026-02-07 09:27:39 -08:00
Zimeng Xiong bbb23ca661 chore: pre-release v0.4.0-dev 2026-02-07 08:58:51 -08:00
Zimeng Xiong f214e4f7b7 Ensure non multi-user flow stays 2026-02-06 23:05:23 -08:00
Zimeng Xiong 7aa33a1bdf graph QL 2026-02-06 22:49:21 -08:00
Zimeng Xiong ea06cd9175 fix graphQL 2026-02-06 22:35:17 -08:00
Zimeng Xiong 734f0a292d fix graphQL 2026-02-06 22:28:36 -08:00
Zimeng Xiong 08135ee36a fix test failures, new export/backup solutions 2026-02-06 22:21:19 -08:00
Zimeng Xiong f462b2e288 minor UI fixes 2026-02-06 21:18:10 -08:00
Zimeng Xiong 01fda32bcd test(import): add legacy import compatibility coverage 2026-02-06 14:54:02 -08:00
copilot-swe-agent[bot] 94694deb91 fix: address code review feedback - add error handling and fix import style
Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
2026-02-06 14:52:47 -08:00
copilot-swe-agent[bot] ef75f9ebdf test: add user data sandboxing security tests
Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
2026-02-06 14:52:47 -08:00
copilot-swe-agent[bot] 5e782e4044 fix: scope drawings cache by userId and add Socket.io authentication
Security fixes:
1. Drawings cache now includes userId in cache key to prevent data leakage
   between users making identical queries.
2. Socket.io connections now require JWT authentication when auth is enabled.
3. Socket.io join-room verifies drawing ownership before allowing access.
4. Frontend passes auth token when connecting to Socket.io.

Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
2026-02-06 14:52:47 -08:00
Zimeng Xiong 0253ebb6b8 admin dashboard 2026-02-06 14:27:24 -08:00
Zimeng Xiong 1e617025df Add admin password reset flow 2026-02-06 14:11:13 -08:00
Zimeng Xiong e4941ad77f fix(dev): avoid native deps in predev migrate 2026-02-06 09:56:45 -08:00
Zimeng Xiong 2e370f9821 fix(dev): reset legacy dev.db and apply migrations 2026-02-06 09:54:13 -08:00
Zimeng Xiong b075a0cf9e fix(dev): avoid auth redirect when backend/schema missing 2026-02-06 09:50:27 -08:00
Zimeng Xiong 7977a3eb09 feat(auth): default to single-user mode with enable toggle 2026-02-06 09:45:38 -08:00
Zimeng Xiong 40a645b823 chore(deps): apply dependabot updates 2026-02-06 09:22:23 -08:00
Zimeng Xiong dd966f6d01 merge(pr): record PR #51 on pre-release 2026-02-06 09:20:35 -08:00
Zimeng Xiong d832e55dfd merge(pr): record PR #52 on pre-release 2026-02-06 09:20:35 -08:00
Zimeng Xiong 887818c9b4 merge(pr): record PR #47 on pre-release 2026-02-06 09:20:35 -08:00
Zimeng Xiong bc13cc3483 merge(pr): record PR #46 on pre-release 2026-02-06 09:20:35 -08:00
Zimeng Xiong da299d00d5 merge(pr): record PR #44 on pre-release 2026-02-06 09:20:35 -08:00
Zimeng Xiong 302d9bd94b merge(pr): record PR #41 on pre-release 2026-02-06 09:17:30 -08:00
Zimeng Xiong d68fe6a2c0 fix(auth): stabilize refresh expiry and frontend URL handling 2026-02-06 09:17:24 -08:00
Zimeng Xiong 7a54123e93 fix(export): include excalidraw source/version metadata 2026-02-06 00:26:31 -08:00
Zimeng Xiong 75a1f11a96 feat(auth): consolidate multi-user auth and admin controls 2026-02-06 00:25:13 -08:00
Zimeng Xiong 700e153740 merge: pull PR48 auth and UX into pre-release 2026-02-05 23:25:56 -08:00
Zimeng Xiong fd3b97225f merge: bring main into pre-release 2026-02-05 23:20:06 -08:00
dependabot[bot] 0d1fe8e0e5 Bump lodash from 4.17.21 to 4.17.23 in /backend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 00:07:20 +00:00
Zimeng Xiong b6d0150d44 chore: release v0.3.2 2026-02-01 16:06:19 -08:00
Zimeng Xiong 55cd816cca fix: correct test assertions for trust proxy behavior in supertest
The demonstration tests had incorrect assumptions about how Express
trust proxy works in supertest (no real socket connection). Updated
assertions to match actual behavior while preserving the test's purpose
of showing that trust proxy: true extracts the correct client IP.
2026-02-01 16:05:58 -08:00
Zimeng Xiong d67bd1daf8 fix express proxy headers 2026-02-01 16:04:52 -08:00
Zimeng Xiong 4b56d3cfc6 repro issue 2026-02-01 16:04:52 -08:00
Zimeng Xiong 88ed4360c0 docs: document comma-separated FRONTEND_URL support
Clarifies that FRONTEND_URL accepts multiple comma-separated URLs
for accessing ExcaliDash from different addresses (e.g., localhost
and LAN IP simultaneously).
2026-02-01 16:01:02 -08:00
Zimeng Xiong 7dfa69de2a fix export source and verisoning 2026-01-30 14:57:27 -08:00
Matteo 4f53b899c9 chore: add dependencies for authentication features
- Add bcrypt for password hashing
- Add jsonwebtoken for JWT tokens
- Add zod for input validation
- Update package-lock.json
2026-01-24 17:13:07 +01:00
Matteo 9fe3a2193d chore: update tests and configuration for auth integration
- Update test utilities for user authentication
- Update Settings page for authenticated export
- Update docker-compose.yml if needed
- Update package-lock.json files
2026-01-24 17:12:39 +01:00
Matteo 804adb7347 docs: add FORK.md with feature summary
- Document all security features added
- Document UX improvements added
- Include migration strategy and backward compatibility notes
- Provide enable instructions for optional features
2026-01-24 17:12:36 +01:00
Matteo 9c6b7dd727 test: add tests for audit logging utility
- Add comprehensive tests for logAuditEvent
- Add tests for getAuditLogs with user filtering
- Test graceful degradation when feature disabled
- Test JSON details parsing
- Follow existing test patterns and style
2026-01-24 17:12:34 +01:00
Matteo f6e337aa98 feat(frontend): add auto-hide header to Editor
- Add mouse-based auto-hide functionality
- Add toggle button to enable/disable auto-hide
- Prevent auto-hide during drawing name editing
- Smooth transitions with translate-y animations
- Dynamic canvas height adjustment based on header visibility
2026-01-24 17:12:31 +01:00
Matteo cbe83efe1f feat(frontend): add select all button to Dashboard
- Add Select All button with CheckSquare/Square icons
- Toggle selection of all drawings in current view
- Match styling with other icon buttons
- Add tooltip for better UX
2026-01-24 17:12:27 +01:00
Matteo 112d58a92a feat(frontend): add profile page for user management
- Add Profile page for viewing/editing user info
- Add display name editing functionality
- Add change password functionality with validation
- Add Profile button to Sidebar navigation
- Handle authentication errors gracefully
2026-01-24 17:12:26 +01:00
Matteo b834f777b5 feat(frontend): add password reset pages
- Add PasswordResetRequest page for requesting reset
- Add PasswordResetConfirm page for confirming reset
- Handle feature disabled state gracefully
- Add routes to App.tsx
2026-01-24 17:12:24 +01:00
Matteo 5f476542e2 feat(frontend): add login and register pages
- Add Login page with email/password form
- Add Register page with email validation
- Add forgot password link to login page
- Update App.tsx with auth routes and AuthProvider
- Add email validation in registration form
2026-01-24 17:12:23 +01:00
Matteo f1a1ff3a8a feat(frontend): add authentication context and API client
- Add AuthContext for managing user authentication state
- Add ProtectedRoute component for route protection
- Update API client with JWT token injection
- Add refresh token rotation support
- Add CSRF token handling
2026-01-24 17:12:21 +01:00
Matteo 29af9fac62 feat(backend): integrate authentication and user isolation
- Add authentication middleware to protected routes
- Add user isolation to drawing and collection queries
- Add audit logging to delete operations
- Update CSRF token handling for authenticated users
2026-01-24 17:12:18 +01:00
Matteo 2998fad8e7 feat(security): add audit logging utility
- Add logAuditEvent function for security event logging
- Add getAuditLogs function for retrieving audit logs
- Gracefully handles disabled feature or missing table
- Feature disabled by default via config flag
2026-01-24 17:12:16 +01:00
Matteo b6e9514eb3 feat(auth): add authentication endpoints (login, register, refresh, me)
- Add POST /auth/register endpoint with email validation
- Add POST /auth/login endpoint with JWT token generation
- Add POST /auth/refresh endpoint for token refresh
- Add GET /auth/me endpoint for current user info
- Add rate limiting for auth endpoints
- Add bcrypt password hashing
- Add JWT access and refresh token generation
2026-01-24 17:12:06 +01:00
Matteo b175706da1 feat(auth): add authentication middleware and utilities
- Add requireAuth middleware for protecting routes
- Add errorHandler and asyncHandler middleware
- Add user isolation helpers for database queries
2026-01-24 17:11:52 +01:00
Matteo 381dd95543 feat(config): add feature flags for optional security features
- Add enablePasswordReset, enableRefreshTokenRotation, enableAuditLogging flags
- All flags default to false for backward compatibility
- Add getOptionalBoolean helper for parsing boolean env vars
- Update .env.example with feature flag documentation
2026-01-24 17:11:50 +01:00
Matteo 78ab52b762 feat(security): add database schema for security features
- Add PasswordResetToken model for password reset flow
- Add RefreshToken model for token rotation tracking
- Add AuditLog model for security event logging
- All features disabled by default via feature flags
2026-01-24 17:11:46 +01:00
Matteo d9013b8f7a feat(auth): add user authentication database schema
- Add User model with email, passwordHash, and name fields
- Add userId foreign key to Drawing and Collection models
- Create initial migration for user authentication
2026-01-24 17:11:40 +01:00
dependabot[bot] 9170930e8e Bump lodash-es from 4.17.21 to 4.17.23 in /frontend
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-22 01:37:02 +00:00
Adrian Acala f7c9a1ab80 chore(tests): enable server start during end-to-end tests 2026-01-20 20:38:11 -08:00
Adrian Acala af07a73a07 feat(auth): enhance authentication system with login attempt tracking and configuration options
- Added a new `LoginAttempt` model to track login attempts, including rate limiting and lockout functionality.
- Introduced environment variables for configuring login rate limits and maximum failures.
- Updated the authentication middleware to handle login attempts and enforce rate limits.
- Enhanced the user model with indexing for username and email for improved lookup performance.
- Modified the `.env.example` file to include new optional authentication settings.
- Updated integration tests to cover new login attempt features and authentication state management.
2026-01-20 19:55:32 -08:00
Adrian-Ryan Acala 865285fbb7 fix: sync pasted/uploaded images across collaborating tabs (#36)
* fix: sync pasted/uploaded images across collaborating tabs

- Implement file delta synchronization to broadcast image file data
- Add periodic file sync check to catch async file data arrival
- Wrap Excalidraw addFiles API to automatically emit file changes
- Enhance socket element-update to include file payloads
- Add comprehensive E2E test for image collaboration scenarios
- Improve CORS flexibility for development localhost ports

Fixes #25: New images not appearing when collaborating - collaborators
now see uploaded images immediately instead of placeholder until refresh.

* perf: increase file sync polling interval from 500ms to 1000ms

Reduces CPU overhead while still catching async file arrivals. Most
updates go through the addFiles wrapper anyway.

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
2026-01-20 13:49:00 -08:00
Sushil Kumar 77c22916a8 Fix: Save complete app state (#40)
* pass rest of appState in put request

* fix: support both legacy and current currentItemRoundness formats

Add union type to accept both the old object format {type, value} and
the new enum format for backwards compatibility with existing drawings.

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
2026-01-20 13:49:00 -08:00
dependabot[bot] 08d1479a01 Bump react-router and react-router-dom in /frontend
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.12.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.9.6 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

Updates `react-router-dom` from 7.9.6 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.12.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-20 13:49:00 -08:00
dependabot[bot] 7ea1c3ebf0 Bump qs from 6.14.0 to 6.14.1 in /backend
Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-20 13:49:00 -08:00
dependabot[bot] 5d819b0234 Bump diff from 5.2.0 to 5.2.2 in /frontend
Bumps [diff](https://github.com/kpdecker/jsdiff) from 5.2.0 to 5.2.2.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](https://github.com/kpdecker/jsdiff/compare/v5.2.0...v5.2.2)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 5.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-20 20:46:06 +00:00
Adrian Acala 260a898e3e test: stabilize e2e auth and rate limits 2026-01-19 00:07:27 -08:00
Adrian Acala 15ac634d15 feat(auth): add password reset functionality and user model update
- Introduced a `mustResetPassword` field in the User model to manage password reset requirements.
- Enhanced authentication flow to support password changes, including validation and error handling.
- Updated frontend components to handle password reset scenarios and integrate with the new API endpoints.
- Modified authentication context and hooks to accommodate the new password reset logic.
- Adjusted E2E tests to ensure proper coverage for the password reset functionality.
2026-01-18 13:02:18 -08:00
Adrian Acala 1a52fe80f3 feat(auth): enhance authentication system with multi-user support and admin role management
- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
2026-01-18 09:43:32 -08:00
Adrian Acala 20ef4ee295 feat: implement basic authentication system 2026-01-16 21:34:58 -08:00
Adrian Acala d1dbde95e4 chore(frontend): add eslint v9 config and fix lint issues 2026-01-16 21:34:58 -08:00
83 changed files with 12097 additions and 1549 deletions
+6
View File
@@ -7,3 +7,9 @@ dist
.env .env
.DS_Store .DS_Store
*.log *.log
backend
frontend/node_modules
frontend/dist
frontend/coverage
frontend/test-results
frontend/playwright-report
+2 -2
View File
@@ -108,7 +108,7 @@ jobs:
run: | 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:5173" npm run dev & DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:6767" 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:5173 > /dev/null; then if curl -s http://localhost:6767 > /dev/null; then
echo "Frontend is ready!" echo "Frontend is ready!"
break break
fi fi
+69
View File
@@ -0,0 +1,69 @@
# Fork Summary
This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags.
## Security Features Added
1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`)
2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use
3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance
## UX Improvements Added
1. **Profile Page** - View and edit personal information, change password (`/profile`)
2. **Select All Button** - Quick selection of all drawings in current view
3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle
4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle)
## Backward Compatibility
✅ All security features disabled by default
✅ No breaking changes to existing code
✅ Graceful degradation (missing tables don't cause errors)
✅ Optional database migration
## Enable Security Features
Set in `backend/.env`:
```bash
ENABLE_PASSWORD_RESET=true
ENABLE_REFRESH_TOKEN_ROTATION=true
ENABLE_AUDIT_LOGGING=true
```
Then run migration:
```bash
cd backend && npx prisma migrate deploy
```
## Migration Strategy
**For base project:** Keep features disabled (default) - no migration needed, zero risk.
**For this fork:** Enable features via environment variables when ready.
## Database Changes
Migration adds 3 optional tables (only used when features enabled):
- `PasswordResetToken` - For password reset flow
- `RefreshToken` - For token rotation tracking
- `AuditLog` - For security event logging
## Code Changes
### Backend
- Feature flags in `backend/src/config.ts`
- Conditional logic in auth endpoints
- Graceful error handling for missing tables
- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST)
- Audit logging utility (`backend/src/utils/audit.ts`)
### Frontend
- Password reset pages (`/reset-password`, `/reset-password-confirm`)
- Profile page (`/profile`)
- Select All button in Dashboard
- Sort dropdown with icons
- Auto-hide header in Editor with toggle
- Updated API client for token rotation
All changes are backward compatible and optional.
+11
View File
@@ -511,6 +511,17 @@ release-docker: ## Build and push release Docker images
pre-release-docker: ## Build and push pre-release Docker images pre-release-docker: ## Build and push pre-release Docker images
./publish-docker-prerelease.sh ./publish-docker-prerelease.sh
dev-release: ## Build and push custom dev release (usage: make dev-release NAME=issue38)
@if [ -z "$(NAME)" ]; then \
echo "$(RED)ERROR: NAME parameter is required!$(NC)"; \
echo "$(YELLOW)Usage: make dev-release NAME=<custom-name>$(NC)"; \
echo "$(YELLOW)Example: make dev-release NAME=issue38$(NC)"; \
echo "$(YELLOW) This will create tags like: 0.3.1-dev-issue38$(NC)"; \
exit 1; \
fi
@echo "$(BLUE)Building custom dev release: $(NAME)$(NC)"
@./publish-docker-dev.sh $(NAME)
#=============================================================================== #===============================================================================
# DATABASE # DATABASE
#=============================================================================== #===============================================================================
+8 -2
View File
@@ -99,6 +99,8 @@ 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.
## Docker Build ## Docker Build
[Install Docker](https://docs.docker.com/desktop/) [Install Docker](https://docs.docker.com/desktop/)
@@ -120,14 +122,17 @@ 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. - `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname. - `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
# docker-compose.yml example # docker-compose.yml example
backend: backend:
environment: environment:
# Single URL
- FRONTEND_URL=https://excalidash.example.com - FRONTEND_URL=https://excalidash.example.com
# Or multiple URLs (comma-separated) for local + network access
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
frontend: frontend:
environment: environment:
# For standard Docker Compose (default) # For standard Docker Compose (default)
@@ -138,7 +143,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 the `CSRF_SECRET` environment variable to the same value across all instances. When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set both `JWT_SECRET` and `CSRF_SECRET` to the same values across all instances.
```bash ```bash
# Generate a secure secret # Generate a secure secret
@@ -149,6 +154,7 @@ 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
``` ```
+6 -40
View File
@@ -1,43 +1,9 @@
CSRF Protection (8a78b2b) Multi user setup is opt-in, single user by default
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security Multi-user support for excalidash
- Added new backend/src/security.ts module for security utilities - Admin dashboard
- Frontend API layer now handles CSRF tokens automatically - Password reset, force user password reset (admin only), account lockout recovery
- Added integration tests for CSRF validation - Rate limits
Upload Progress Indicator (8f9b9b4) Deprecates .json and .sqlite database backups in favor of .excalidash archives (user scoped, prevents exporting of senstive information). Legacy import is maintained.
- Added a visual upload progress bar when users upload files
- New UploadContext for managing upload state across components
- New UploadStatus component displaying real-time upload progress
- Save status indicator when navigating back from the editor
- Improved error handling and recovery for failed uploads
Bug Fixes
- Fixed broken e2e tests (cae8f3c)
- Replaced deprecated substr() with substring()
- Fixed stale state issues in error handling
- Fixed missing useEffect dependencies
- Fixed CSS class conflicts in progress bar styling
- Added error recovery for save state in Editor
Infrastructure
- Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README
### Kubernetes
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
```bash
openssl rand -base64 32
Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
If not set, the backend will refuse to start.
```
+1 -1
View File
@@ -1 +1 @@
0.3.1 0.4.3
+4
View File
@@ -9,3 +9,7 @@ dist
*.log *.log
prisma/dev.db prisma/dev.db
prisma/dev.db-journal prisma/dev.db-journal
src/generated
coverage
*.test.ts
*.spec.ts
+7
View File
@@ -3,3 +3,10 @@ PORT=8000
NODE_ENV=production NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767 FRONTEND_URL=http://localhost:6767
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
# Set to "true" or "1" to enable:
# ENABLE_PASSWORD_RESET=false
# ENABLE_REFRESH_TOKEN_ROTATION=false
# ENABLE_AUDIT_LOGGING=false
+9 -6
View File
@@ -3,12 +3,15 @@ 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 RUN npm ci && npm cache clean --force
# Copy prisma schema # Copy prisma schema
COPY prisma ./prisma/ COPY prisma ./prisma/
@@ -25,7 +28,7 @@ RUN npx tsc
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
# Install OpenSSL for Prisma and su-exec, create non-root user # Install runtime packages and create non-root user
RUN apk add --no-cache openssl su-exec && \ 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
@@ -36,7 +39,10 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install production dependencies only # Install production dependencies only
RUN npm ci --only=production RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
npm ci --omit=dev && \
npm cache clean --force && \
apk del .build-deps
# Copy prisma schema and migrations for runtime and hydration template # Copy prisma schema and migrations for runtime and hydration template
COPY prisma ./prisma/ COPY prisma ./prisma/
@@ -48,9 +54,6 @@ COPY --from=builder /app/dist ./dist
# Copy the generated Prisma Client from builder to maintain the same structure # Copy 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
+25
View File
@@ -1,6 +1,30 @@
#!/bin/sh #!/bin/sh
set -e set -e
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
# Ensure JWT secret exists for production startup.
# Backward compatibility: older installs may not have JWT_SECRET configured.
if [ -z "${JWT_SECRET:-}" ]; then
echo "JWT_SECRET not provided, resolving persisted secret..."
if [ -f "${JWT_SECRET_FILE}" ]; then
JWT_SECRET="$(tr -d '\r\n' < "${JWT_SECRET_FILE}")"
fi
if [ -z "${JWT_SECRET}" ]; then
echo "No persisted JWT secret found. Generating a new secret..."
JWT_SECRET="$(openssl rand -hex 32)"
umask 077
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
fi
else
# Persist explicitly provided secret to support future restarts without env injection.
umask 077
printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}"
fi
export JWT_SECRET
# 1. Hydrate volume if empty (Running as root) # 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..."
@@ -18,6 +42,7 @@ 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}"
# 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
+330 -21
View File
File diff suppressed because it is too large Load Diff
+18 -5
View File
@@ -1,10 +1,12 @@
{ {
"name": "backend", "name": "backend",
"version": "0.3.1", "version": "0.4.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts", "dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -15,27 +17,38 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0",
"@types/jsdom": "^21.1.7",
"@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.4.6", "better-sqlite3": "^12.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"dompurify": "^3.3.0", "dompurify": "^3.3.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"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",
@@ -0,0 +1,96 @@
-- NOTE:
-- This migration assigns all pre-existing data to a bootstrap admin user so that
-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible.
-- The bootstrap admin user starts inactive and must be activated via the app's
-- initial registration flow.
-- Constants
-- Keep in sync with backend/src/auth.ts
-- (SQLite doesn't support variables; we inline the values instead.)
-- BOOTSTRAP_USER_ID = 'bootstrap-admin'
-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin'
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"mustResetPassword" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- Bootstrap state:
-- - Insert a singleton config row (registration disabled by default)
-- - Insert an inactive bootstrap admin user and assign all existing data to it
INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt")
VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt")
VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Collection" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Collection" ("createdAt", "id", "name", "userId", "updatedAt")
SELECT "createdAt", "id", "name", 'bootstrap-admin', "updatedAt" FROM "Collection";
DROP TABLE "Collection";
ALTER TABLE "new_Collection" RENAME TO "Collection";
CREATE TABLE "new_Drawing" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"elements" TEXT NOT NULL,
"appState" TEXT NOT NULL,
"files" TEXT NOT NULL DEFAULT '{}',
"preview" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"userId" TEXT NOT NULL,
"collectionId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "userId", "updatedAt", "version")
SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "updatedAt", "version" FROM "Drawing";
DROP TABLE "Drawing";
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
CREATE TABLE "new_Library" (
"id" TEXT NOT NULL PRIMARY KEY,
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- Migrate the singleton library to the bootstrap user's library key.
INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt")
SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default';
DROP TABLE "Library";
ALTER TABLE "new_Library" RENAME TO "Library";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
@@ -0,0 +1,5 @@
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
-- SQLite supports simple ADD COLUMN for non-null with default.
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20;
@@ -0,0 +1,9 @@
-- Improve dashboard query performance for user-scoped collection and drawing listings.
CREATE INDEX IF NOT EXISTS "Collection_userId_updatedAt_idx"
ON "Collection" ("userId", "updatedAt");
CREATE INDEX IF NOT EXISTS "Drawing_userId_updatedAt_idx"
ON "Drawing" ("userId", "updatedAt");
CREATE INDEX IF NOT EXISTS "Drawing_userId_collectionId_updatedAt_idx"
ON "Drawing" ("userId", "collectionId", "updatedAt");
+71 -1
View File
@@ -12,12 +12,45 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User {
id String @id @default(uuid())
username String? @unique
email String @unique
passwordHash String
name String
role String @default("USER")
mustResetPassword Boolean @default(false)
isActive Boolean @default(true)
drawings Drawing[]
collections Collection[]
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemConfig {
id String @id @default("default")
authEnabled Boolean @default(false)
registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
authLoginRateLimitMax Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Collection { model Collection {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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 {
@@ -28,15 +61,52 @@ model Drawing {
files String @default("{}") // Stored as JSON string files String @default("{}") // Stored as JSON string
preview String? // SVG string for thumbnail preview String? // SVG string for thumbnail
version Int @default(1) version Int @default(1)
userId String
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])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId, updatedAt])
@@index([userId, collectionId, updatedAt])
} }
model Library { model Library {
id String @id @default("default") // Singleton pattern - use "default" ID id String @id // User-specific library ID (e.g., "user_<userId>")
items String @default("[]") // Stored as JSON string array of library items items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model PasswordResetToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
revoked Boolean @default(false)
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted"
resource String? // e.g., "drawing:123", "collection:456"
ipAddress String?
userAgent String?
details String? // JSON string for additional details
createdAt DateTime @default(now())
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* CLI admin password recovery for ExcaliDash.
*
* Examples:
* node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!"
* node scripts/admin-recover.cjs --identifier admin@example.com --generate
*
* Notes:
* - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db).
* - Sets the password hash and clears mustResetPassword by default.
* - If there are no active admins, this script can promote the target user to ADMIN.
*/
require("dotenv").config();
const path = require("path");
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
`file:${path.resolve(__dirname, "../prisma/dev.db")}`;
const { PrismaClient } = require("../src/generated/client");
const bcrypt = require("bcrypt");
const parseArgs = (argv) => {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
};
const generatePassword = () => {
// 24 chars base64url-ish
const buf = require("crypto").randomBytes(18);
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const identifier = typeof args.identifier === "string" ? args.identifier.trim() : "";
const providedPassword = typeof args.password === "string" ? args.password : null;
const generate = Boolean(args.generate);
const setMustReset = Boolean(args["must-reset"]);
const activate = Boolean(args.activate);
const promote = Boolean(args.promote);
const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]);
if (!identifier) {
console.error("Missing --identifier (email or username).");
process.exitCode = 2;
return;
}
let newPassword = providedPassword;
if (!newPassword) {
if (!generate) {
console.error('Provide --password "<new password>" or pass --generate.');
process.exitCode = 2;
return;
}
newPassword = generatePassword();
}
if (newPassword.length < 8) {
console.error("Password must be at least 8 characters.");
process.exitCode = 2;
return;
}
const prisma = new PrismaClient();
try {
const activeAdminCount = await prisma.user.count({
where: { role: "ADMIN", isActive: true },
});
const trimmed = identifier.toLowerCase();
const user = await prisma.user.findFirst({
where: {
OR: [{ email: trimmed }, { username: identifier }],
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
if (!user) {
console.error("User not found:", identifier);
process.exitCode = 1;
return;
}
const shouldPromote = promote || activeAdminCount === 0;
if (user.role !== "ADMIN" && !shouldPromote) {
console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user.");
console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins.");
process.exitCode = 1;
return;
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
if (disableLoginRateLimit) {
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authLoginRateLimitEnabled: false },
create: {
id: "default",
authEnabled: true,
registrationEnabled: false,
authLoginRateLimitEnabled: false,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
}
const updated = await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
mustResetPassword: setMustReset ? true : false,
isActive: activate ? true : user.isActive,
role: shouldPromote ? "ADMIN" : user.role,
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
console.log("Updated admin account:");
console.log(`- id: ${updated.id}`);
console.log(`- email: ${updated.email}`);
console.log(`- username: ${updated.username || ""}`);
console.log(`- isActive: ${updated.isActive}`);
console.log(`- mustResetPassword: ${updated.mustResetPassword}`);
console.log(`- role: ${updated.role}`);
if (disableLoginRateLimit) {
console.log("");
console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false).");
console.log("Remember to re-enable it from the Admin dashboard after you regain access.");
}
if (generate || !providedPassword) {
console.log("");
console.log("New password:");
console.log(newPassword);
} else {
console.log("");
console.log("Password updated.");
}
} finally {
await prisma.$disconnect().catch(() => {});
}
};
main().catch((err) => {
console.error("Admin recovery failed:", err);
process.exitCode = 1;
});
+118
View File
@@ -0,0 +1,118 @@
/* eslint-disable no-console */
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const backendRoot = path.resolve(__dirname, "..");
const resolveDatabaseUrl = (rawUrl) => {
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
if (!rawUrl || String(rawUrl).trim().length === 0) {
return `file:${defaultDbPath}`;
}
if (!String(rawUrl).startsWith("file:")) {
return String(rawUrl);
}
const filePath = String(rawUrl).replace(/^file:/, "");
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
const databaseUrl = resolveDatabaseUrl(process.env.DATABASE_URL);
process.env.DATABASE_URL = databaseUrl;
const nodeEnv = process.env.NODE_ENV || "development";
const runCapture = (cmd) => {
try {
const stdout = execSync(cmd, {
cwd: backendRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, DATABASE_URL: databaseUrl },
});
return { ok: true, stdout: stdout || "", stderr: "" };
} catch (error) {
const err = error;
const stderr =
err && err.stderr
? Buffer.isBuffer(err.stderr)
? err.stderr.toString("utf8")
: String(err.stderr)
: "";
const stdout =
err && err.stdout
? Buffer.isBuffer(err.stdout)
? err.stdout.toString("utf8")
: String(err.stdout)
: "";
return { ok: false, stdout, stderr, error: err };
}
};
const run = (cmd) => {
execSync(cmd, {
cwd: backendRoot,
stdio: "inherit",
env: { ...process.env, DATABASE_URL: databaseUrl },
});
};
const getDbFilePath = () => {
if (!databaseUrl.startsWith("file:")) return null;
return databaseUrl.replace(/^file:/, "");
};
const backupDbIfPresent = () => {
const dbPath = getDbFilePath();
if (!dbPath) return null;
if (!fs.existsSync(dbPath)) return null;
const dir = path.dirname(dbPath);
const base = path.basename(dbPath, path.extname(dbPath));
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(dir, `${base}.${stamp}.backup`);
fs.copyFileSync(dbPath, backupPath);
return backupPath;
};
const isNonProd = nodeEnv !== "production";
const isFileDb = databaseUrl.startsWith("file:");
const deploy = runCapture("npx prisma migrate deploy");
if (deploy.ok) {
if (deploy.stdout) process.stdout.write(deploy.stdout);
} else {
if (deploy.stdout) process.stdout.write(deploy.stdout);
if (deploy.stderr) process.stderr.write(deploy.stderr);
const stderr = deploy.stderr || "";
const isP3005 = stderr.includes("P3005");
// Common when an older dev.db exists but migrations weren't used previously.
if (isNonProd && isFileDb && isP3005) {
const backupPath = backupDbIfPresent();
console.warn(
`[predev] Prisma migrate baseline required (P3005). Resetting local SQLite database.\n` +
` DATABASE_URL=${databaseUrl}\n` +
(backupPath ? ` Backup: ${backupPath}\n` : "") +
` If you need to preserve local data, restore the backup and baseline manually.`,
);
run("npx prisma migrate reset --force --skip-seed");
} else {
throw deploy.error;
}
}
@@ -0,0 +1,172 @@
/**
* Issue #38: CSRF fails with multiple reverse proxies
*
* This test demonstrates how trust proxy settings affect CSRF validation
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import request from "supertest";
import {
createCsrfToken,
validateCsrfToken,
getCsrfTokenHeader,
} from "../security";
// mock the getClientId function behavior
const getClientIdFromRequest = (req: express.Request): string => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const userAgent = req.headers["user-agent"] || "unknown";
return `${ip}:${userAgent}`.slice(0, 256);
};
describe("Issue #38: CSRF with trust proxy settings", () => {
let app: express.Application;
beforeEach(() => {
app = express();
app.use(express.json());
});
it("demonstrates the trust proxy issue with multiple proxies", async () => {
// ext proxy -> frontend nginx -> backend
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
// With trust proxy: 1 (current setting)
const app1 = express();
app1.set("trust proxy", 1);
app1.use(express.json());
app1.get("/test-ip", (req, res) => {
res.json({
ip: req.ip,
clientId: getClientIdFromRequest(req),
});
});
// Simulate request through multiple proxies
const response1 = await request(app1)
.get("/test-ip")
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
.set("User-Agent", "Mozilla/5.0 Test");
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
expect(response1.body.ip).toBe("172.17.0.3");
console.log(
"trust proxy: 1 → IP:",
response1.body.ip,
"(not the real client IP)",
);
// With trust proxy: true
const app2 = express();
app2.set("trust proxy", true);
app2.use(express.json());
app2.get("/test-ip", (req, res) => {
res.json({
ip: req.ip,
clientId: getClientIdFromRequest(req),
});
});
const response2 = await request(app2)
.get("/test-ip")
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
.set("User-Agent", "Mozilla/5.0 Test");
// With trust proxy: true, Express takes leftmost IP
expect(response2.body.ip).toBe("203.0.113.42");
console.log(
"trust proxy: true → IP:",
response2.body.ip,
"(real client IP - CORRECT)",
);
});
it("simulates CSRF failure scenario from issue #38", async () => {
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
// Request 1: Fetch CSRF token
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
const clientIp1 = "203.0.113.42";
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
// With trust proxy: 1, Express sees the external proxy IP
const clientId1 = `${externalProxyIp1}:${userAgent}`;
const token = createCsrfToken(clientId1);
console.log(
" X-Forwarded-For:",
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
);
console.log(" Express sees IP:", externalProxyIp1);
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
// Request 2: Try to create drawing with token
// External proxy IP might differ slightly
const externalProxyIp2 = "10.0.0.6";
const clientId2 = `${externalProxyIp2}:${userAgent}`;
console.log(
" X-Forwarded-For:",
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
);
console.log(" Express sees IP:", externalProxyIp2);
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
// CSRF validation fails because clientId changed
const isValid = validateCsrfToken(clientId2, token);
expect(isValid).toBe(false);
console.log(" Expected:", clientId1.slice(0, 50) + "...");
console.log(" Got:", clientId2.slice(0, 50) + "...");
});
it("shows the fix works with trust proxy: true", async () => {
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
const realClientIp = "203.0.113.42";
const clientId1 = `${realClientIp}:${userAgent}`;
const token = createCsrfToken(clientId1);
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
console.log(" Express sees IP:", realClientIp);
// Request 2: Use token (even if middle proxy IPs differ)
const clientId2 = `${realClientIp}:${userAgent}`;
console.log("Create drawing");
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
console.log("Express sees IP:", realClientIp, "(same!)");
const isValid = validateCsrfToken(clientId2, token);
expect(isValid).toBe(true);
console.log("\nCSRF Validation: SUCCESS");
});
it("demonstrates the Synology NAS scenario from issue #38", async () => {
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
let seenIp: string | undefined;
app.get("/test", (req, res) => {
seenIp = req.ip;
res.json({ ip: req.ip });
});
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
// In supertest without real socket, trust proxy: 1 returns last IP
// Key point: it's NOT the real client IP (192.168.0.100)
await request(app)
.get("/test")
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
console.log(" With trust proxy: 1, Express sees:", seenIp);
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
});
});
@@ -315,10 +315,11 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests // Database integration tests
describe("Drawing API - Database Round-Trip", () => { describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma(); const prisma = getTestPrisma();
let testUser: { id: string };
beforeAll(async () => { beforeAll(async () => {
setupTestDb(); setupTestDb();
await initTestDb(prisma); testUser = await initTestDb(prisma);
}); });
afterAll(async () => { afterAll(async () => {
@@ -343,6 +344,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]), elements: JSON.stringify([]),
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }), appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
files: JSON.stringify(files), files: JSON.stringify(files),
userId: testUser.id,
}, },
}); });
@@ -381,6 +383,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]), elements: JSON.stringify([]),
appState: JSON.stringify({}), appState: JSON.stringify({}),
files: JSON.stringify(files), files: JSON.stringify(files),
userId: testUser.id,
}, },
}); });
@@ -404,6 +407,7 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]), elements: JSON.stringify([]),
appState: JSON.stringify({}), appState: JSON.stringify({}),
files: JSON.stringify({}), files: JSON.stringify({}),
userId: testUser.id,
}, },
}); });
@@ -0,0 +1,290 @@
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 { 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;
};
describe("Import compatibility (legacy exports)", () => {
const uploadsDir = path.resolve(__dirname, "../../uploads");
const userAgent = "vitest-import-compat";
let prisma: ReturnType<typeof getTestPrisma>;
let app: any;
let csrfHeaderName: string;
let csrfToken: string;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
fs.mkdirSync(uploadsDir, { recursive: true });
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
({ app } = await import("../index"));
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
expect(typeof csrfHeaderName).toBe("string");
expect(typeof csrfToken).toBe("string");
});
beforeEach(async () => {
await cleanupTestDb(prisma);
});
afterAll(async () => {
await prisma.$disconnect();
});
it("verifies a v0.1.xv0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "prisma",
includeCollections: true,
includeMigrationsTable: true,
includeTrashDrawing: false,
});
const res = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.drawings).toBe(2);
expect(res.body.collections).toBe(1);
expect(res.body.latestMigration).toBe("20240104000000_initial");
expect(typeof res.body.currentLatestMigration === "string").toBe(true);
});
it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "prisma",
includeCollections: true,
includeMigrationsTable: false,
includeTrashDrawing: true,
});
const res = await request(app)
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.collections?.created).toBeGreaterThanOrEqual(1);
expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3);
const importedDrawings = await prisma.drawing.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, collectionId: true, userId: true },
});
// In single-user mode, imports land on the bootstrap acting user.
expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true);
expect(importedDrawings.map((d) => d.id)).toEqual(
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
);
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
expect(trash).toBeTruthy();
});
it("supports older exports with plural/lowercase table names (drawings/collections)", async () => {
const legacyDb = createLegacySqliteDb({
tableStyle: "plural-lower",
includeCollections: true,
includeMigrationsTable: false,
includeTrashDrawing: false,
});
const verify = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(verify.status).toBe(200);
expect(verify.body.drawings).toBe(2);
expect(verify.body.collections).toBe(1);
const res = await request(app)
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", legacyDb);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it("fails verification if the legacy DB is missing a Drawing table", async () => {
const dir = createTempDir();
const filePath = path.join(dir, "invalid.db");
const db = openWritableDb(filePath);
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
db.close();
const res = await request(app)
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
.attach("db", filePath);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Invalid legacy DB");
});
});
+79 -5
View File
@@ -2,11 +2,53 @@
* Test utilities for backend integration tests * Test utilities for backend integration tests
*/ */
import { PrismaClient } from "../generated/client"; import { PrismaClient } from "../generated/client";
import fs from "fs";
import path from "path"; import path from "path";
import { execSync } from "child_process"; import { execSync } from "child_process";
// Use a separate test database // Use a unique test database per test-file import to avoid cross-file contention
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db"); // when Vitest runs test files in parallel.
const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`;
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME);
const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock");
const sleepSync = (ms: number) => {
const shared = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(shared, 0, 0, ms);
};
const withDbPushLock = (fn: () => void) => {
const start = Date.now();
let fd: number | null = null;
while (fd === null) {
try {
fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx");
fs.writeFileSync(fd, String(process.pid));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "EEXIST") throw error;
if (Date.now() - start > 30_000) {
throw new Error("Timed out waiting for Prisma db push lock");
}
sleepSync(50);
}
}
try {
fn();
} finally {
try {
fs.closeSync(fd);
} catch {
// ignore
}
try {
fs.unlinkSync(DB_PUSH_LOCK_PATH);
} catch {
// ignore
}
}
};
/** /**
* Get a test Prisma client pointing to the test database * Get a test Prisma client pointing to the test database
@@ -32,11 +74,20 @@ export const setupTestDb = () => {
// Run Prisma migrations to create the test database // Run Prisma migrations to create the test database
try { try {
execSync("npx prisma db push --skip-generate", { withDbPushLock(() => {
execSync("npx prisma db push --skip-generate --force-reset", {
cwd: path.resolve(__dirname, "../../"), cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl }, env: {
...process.env,
DATABASE_URL: databaseUrl,
// Work around Prisma schema engine failures on this repo's schema
// (seen as a blank "Schema engine error:" from `prisma db push`).
// `RUST_LOG=info` reliably avoids the failure mode.
RUST_LOG: "info",
},
stdio: "pipe", stdio: "pipe",
}); });
});
} catch (error) { } catch (error) {
console.error("Failed to setup test database:", error); console.error("Failed to setup test database:", error);
throw error; throw error;
@@ -54,19 +105,42 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
}); });
}; };
/**
* Create a test user for testing
*/
export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => {
const bcrypt = require("bcrypt");
const passwordHash = await bcrypt.hash("testpassword", 10);
return await prisma.user.upsert({
where: { email },
update: {},
create: {
email,
passwordHash,
name: "Test User",
},
});
};
/** /**
* Initialize test database with required data * Initialize test database with required data
*/ */
export const initTestDb = async (prisma: PrismaClient) => { export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first
const testUser = await createTestUser(prisma);
// Ensure Trash collection exists // Ensure Trash collection exists
const trash = await prisma.collection.findUnique({ const trash = await prisma.collection.findUnique({
where: { id: "trash" }, where: { id: "trash" },
}); });
if (!trash) { if (!trash) {
await prisma.collection.create({ await prisma.collection.create({
data: { id: "trash", name: "Trash" }, data: { id: "trash", name: "Trash", userId: testUser.id },
}); });
} }
return testUser;
}; };
/** /**

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