Compare commits

..

74 Commits

Author SHA1 Message Date
Zimeng Xiong e9d349bb0e fix express proxy headers 2026-01-30 14:28:38 -08:00
Zimeng Xiong 6a84cc4ab7 repro issue 2026-01-30 14:19:24 -08:00
Zimeng Xiong 81918b00cd chore: release v0.3.1 2026-01-20 13:41:22 -08:00
Zimeng Xiong 3b384dc5fb CSRF token validation failing behind nginx proxy (#38)
Express was not configured to trust proxy headers, causing req.ip to return nginx's internal container IP instead of the actual client IP. In Docker environments, nginx can appear with different internal IPs between requests, causing the CSRF clientId to change and token validation to fail.
2026-01-20 13:39:33 -08:00
Zimeng Xiong 7c238701b7 Update RELEASE.md with CSRF_SECRET instructions (#33)
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:25 -08:00
Zimeng Xiong c5c8b15e75 Update README header to remove version number
Removed version number from README header.
2026-01-14 13:10:43 -08:00
Zimeng Xiong 9bc3c7c8fc chore: release v0.3.0 2026-01-14 11:26:20 -08:00
Zimeng Xiong 0476315322 0.2.1 Release (#32)
* feat(security): implement CSRF protection

* chore: clean up CSRF implementation

  - Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety

* add K8S note in README, fix broken e2e

* feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 11:25:27 -08:00
dependabot[bot] e75b727a5a Bump body-parser from 2.2.0 to 2.2.1 in /backend (#11)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 15:22:06 -08:00
dependabot[bot] c2aa742a79 Bump express from 5.1.0 to 5.2.0 in /backend (#16)
Bumps [express](https://github.com/expressjs/express) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 5.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 15:21:55 -08:00
Zimeng Xiong 49b413bf07 Testing infrastructure, fix truncating of dataURLs (#26)
* feat: implement comprehensive testing infrastructure

- Fix image dataURL truncation bug in security.ts with configurable size limits
- Add backend integration tests (22 tests) with Vitest for API validation
- Add frontend unit tests (11 tests) for JSON serialization
- Implement browser-based E2E tests (8 tests) with Playwright
- Create Docker setup for repeatable E2E testing environment
- Add GitHub Actions CI workflow for automated testing
- Update .gitignore for test artifacts and temporary files

Testing Infrastructure:
- Backend: Vitest + Supertest for API integration tests
- Frontend: Vitest + Testing Library for component tests
- E2E: Playwright with Chromium for full browser automation
- CI/CD: GitHub Actions with parallel test execution

Security Improvements:
- Make dataURL size limit configurable (default: 10MB)
- Enhanced validation for image dataURLs
- Block malicious content (javascript:, script tags)

All tests pass: 41 total (22 backend + 11 frontend + 8 E2E)

* feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence
chore(env): update environment variables for consistent API URL usage
fix(api): centralize API request helpers for drawing and collection management
style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing

* cleanup/revise documentation

* cleanup/revise documentation

* Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities

- Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`.
- Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`.
- Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`.
- Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`.

* fix: exclude test files from production build to fix Docker build

* feat: implement comprehensive testing infrastructure (#19)

* bump version 0.1.7

* feat: implement comprehensive testing infrastructure

- Fix image dataURL truncation bug in security.ts with configurable size limits
- Add backend integration tests (22 tests) with Vitest for API validation
- Add frontend unit tests (11 tests) for JSON serialization
- Implement browser-based E2E tests (8 tests) with Playwright
- Create Docker setup for repeatable E2E testing environment
- Add GitHub Actions CI workflow for automated testing
- Update .gitignore for test artifacts and temporary files

Testing Infrastructure:
- Backend: Vitest + Supertest for API integration tests
- Frontend: Vitest + Testing Library for component tests
- E2E: Playwright with Chromium for full browser automation
- CI/CD: GitHub Actions with parallel test execution

Security Improvements:
- Make dataURL size limit configurable (default: 10MB)
- Enhanced validation for image dataURLs
- Block malicious content (javascript:, script tags)

All tests pass: 41 total (22 backend + 11 frontend + 8 E2E)

* feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence
chore(env): update environment variables for consistent API URL usage
fix(api): centralize API request helpers for drawing and collection management
style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing

* Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities

- Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`.
- Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`.
- Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`.
- Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`.

* Update backend/src/__tests__/testUtils.ts

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
* version bump 0.1.8

* fix(ci): consolidate E2E server startup to prevent shell isolation issues

Background processes started with & in separate GitHub Actions run steps
can terminate when those steps complete because each step creates a new
shell. This caused the backend and frontend servers to die before the
E2E tests could run.

Fixed by consolidating server startup and test execution into a single
shell step with:
- Proper PID tracking for cleanup
- Health check loops instead of fixed sleep times
- All processes run in the same shell session

* fix(ci): use absolute database path for E2E tests

* fix(backend): use resolved DATABASE_URL path for export/import endpoints

---------

Co-authored-by: Adrian Acala <adrianacala017@gmail.com>
2025-12-19 15:09:15 -08:00
Zimeng Xiong 18c8595c2e bump version 0.1.7 2025-12-01 14:09:37 -08:00
Zimeng Xiong 2e6b94644f bump version 0.1.7 2025-12-01 14:02:32 -08:00
Zimeng Xiong b0bdc05071 Merge pull request #15 from AdrianAcala/perf/drawings-optim
perf: optimize drawings endpoint with caching and lazy loading
2025-12-01 13:59:08 -08:00
Zimeng Xiong 2520d7e7a2 fix(drawings): stabilize lazy loading, improve export error handling, and tidy cache invalidation 2025-12-01 13:58:24 -08:00
Zimeng Xiong 32985ea6fe Merge pull request #13 from AdrianAcala/12-backend-url-config-fix
Add backend URL configuration for frontend and update nginx setup
2025-12-01 13:28:44 -08:00
Zimeng Xiong f8830a8b0f add example in docker-compose, clarify README, add clearer validation, longer timeouts for websocket connections 2025-12-01 13:27:31 -08:00
Adrian Acala c4352185d6 refactor: optimize drawing data handling and cache management
- Improve cache key generation using JSON.stringify for consistent formatting
- Add promise deduplication in DrawingCard to prevent redundant API calls for full drawing data
- Clear full data state when drawing ID changes to ensure fresh data loading
- Fix async cache invalidation in drawing update and collection delete endpoints
- Move cache invalidation after database operations in SQLite import endpoint
- Add HydratedDrawingData type for better type safety in drawing data management
2025-11-29 11:48:47 -08:00
Adrian Acala f9986513f8 Refactor nginx configuration and update README
- Moved BACKEND_URL configuration to the frontend service in the README.
- Added validation for the generated nginx configuration in the entrypoint script.
- Removed fallback nginx configuration copy from the Dockerfile.
- Adjusted nginx template to ensure proper header formatting.

This improves the deployment process and clarifies configuration instructions.
2025-11-29 11:29:43 -08:00
Adrian Acala 6f050aec7d perf: optimize drawings endpoint with caching and lazy loading
- Add 5s in-memory cache for /drawings responses with automatic cleanup
- Split Drawing/DrawingSummary types for efficient data fetching
- Implement lazy loading of drawing data in DrawingCard component
- Add configurable DRAWINGS_CACHE_TTL_MS and RATE_LIMIT_MAX_REQUESTS env vars
- Prevent memory leaks with periodic cleanup of cache and rate limit maps
- Add loading states and better UX for export operations
- Improve JSON parsing with error handling for malformed stored data

Benchmark results (100 drawings, cached):
- Avg latency: 6.94ms (p50: 4ms, p97.5: 8ms)
- Avg throughput: 668 req/s (peak: 1,023)
- 3k requests in 5s with 0 errors

Update .gitignore to exclude generated files, env files, and build artifacts
2025-11-29 04:30:28 +00:00
Adrian Acala 05b787bc27 Add backend URL configuration for frontend and update nginx setup
- Added BACKEND_URL environment variable to docker-compose for frontend service.
- Introduced a new entrypoint script to configure nginx with the BACKEND_URL at runtime.
- Created a template for nginx configuration to handle API and WebSocket requests dynamically.
- Updated README with instructions for configuring reverse proxy setups.

Fixes #12
2025-11-28 17:56:19 -08:00
Zimeng Xiong 971046d568 Update README 2025-11-24 15:04:52 -08:00
Zimeng Xiong 602350d2e6 Merge pull request #9 from ZimengXiong/pre-release
v0.1.6 Add export button, store library in database
2025-11-24 15:01:02 -08:00
Zimeng Xiong f20d48fea2 fix migration issues 2025-11-24 14:53:17 -08:00
Zimeng Xiong c53dc010de Merge branch '8-export-drawing' into pre-release 2025-11-24 14:43:58 -08:00
Zimeng Xiong 03e778a06f add export functionality via exportUtils 2025-11-24 14:39:38 -08:00
Zimeng Xiong fa73708d97 allow importing of libraries via URL, update db schema 2025-11-24 14:32:48 -08:00
Zimeng Xiong ee8204532d Update README.md 2025-11-23 10:23:24 -08:00
Zimeng Xiong a347403a26 Fix caution message formatting in README 2025-11-23 10:15:51 -08:00
Zimeng Xiong 8becfd87bb Merge pull request #6 from ZimengXiong/pre-release
v0.1.5 Fix security issues.
2025-11-23 10:08:42 -08:00
Zimeng Xiong 1b78597649 Merge branch 'main' into pre-release 2025-11-23 10:06:08 -08:00
Zimeng Xiong d93b6493c1 fix database import in docker 2025-11-23 09:40:00 -08:00
Zimeng Xiong d581eb3e88 fix database import, allow sqlite and db format 2025-11-23 09:22:01 -08:00
Zimeng Xiong 4728ef151c release notes 2025-11-23 09:12:36 -08:00
Zimeng Xiong eb5f54a6d0 unify version numbering 2025-11-23 08:53:36 -08:00
Zimeng Xiong c502f1c0bd add version card to settings, branch push protection 2025-11-23 08:35:36 -08:00
Zimeng Xiong 8f9ac1f9c0 add dev tag to pre release dockerhub images 2025-11-23 08:03:48 -08:00
Zimeng Xiong 0787989496 add version managment script 2025-11-23 08:02:00 -08:00
Zimeng Xiong 9bc25a3dc2 update README, release notes 2025-11-23 07:43:14 -08:00
Zimeng Xiong 3cc3fd18f4 add prerelease docker script 2025-11-23 07:30:20 -08:00
Zimeng Xiong 997fa4af03 add prisma cli to dependencies, make zod checks more permissive 2025-11-23 07:08:41 -08:00
Zimeng Xiong b864e82318 Merge branch '1-413-request-entity-too-large' into pre-release 2025-11-22 22:50:40 -08:00
Zimeng Xiong 2f22be2bd7 Merge branch 'fix-CPU-blocking' into pre-release 2025-11-22 22:48:51 -08:00
Zimeng Xiong fcfb850168 Merge branch 'fix-DoS-event-blocking' into pre-release 2025-11-22 22:44:27 -08:00
Zimeng Xiong 4a224c1f92 Merge branch 'fix-rce-via-upload' into pre-release 2025-11-22 22:43:47 -08:00
Zimeng Xiong d1d17e1288 Merge branch 'fix-xss-root-execution' into pre-release 2025-11-22 22:43:31 -08:00
Zimeng Xiong 9055661b51 make async database integrity check 2025-11-22 21:59:18 -08:00
Zimeng Xiong d25a32cdd3 Fix license badge URL in README.md 2025-11-22 21:56:14 -08:00
Zimeng Xiong 8d65404514 Fix license badge URL in README.md 2025-11-22 21:56:14 -08:00
Zimeng Xiong 1b6c32d773 Merge pull request #3 from ZimengXiong/ZimengXiong-patch-1
Create LICENSE
2025-11-22 21:54:46 -08:00
Zimeng Xiong 352bcfca29 Merge pull request #3 from ZimengXiong/ZimengXiong-patch-1
Create LICENSE
2025-11-22 21:54:46 -08:00
Zimeng Xiong 448c678ecc Merge pull request #4 from ZimengXiong/ZimengXiong-readme-license
Update license badge in README.md
2025-11-22 21:53:55 -08:00
Zimeng Xiong e980b96091 Merge pull request #4 from ZimengXiong/ZimengXiong-readme-license
Update license badge in README.md
2025-11-22 21:53:55 -08:00
Zimeng Xiong fabe0fcd54 Update license badge in README.md 2025-11-22 21:53:38 -08:00
Zimeng Xiong ef27256879 Update license badge in README.md 2025-11-22 21:53:38 -08:00
Zimeng Xiong c1da41474f Create LICENSE 2025-11-22 21:51:20 -08:00
Zimeng Xiong 815dcd5c80 Create LICENSE 2025-11-22 21:51:20 -08:00
Zimeng Xiong 29936417fc convert all sync op to async, implemented streaming 2025-11-22 21:36:02 -08:00
Zimeng Xiong 49e32f7d96 validate SQlite magic header 2025-11-22 21:27:34 -08:00
Zimeng Xiong cd9c242983 filter with dompurify 2025-11-22 21:21:28 -08:00
Zimeng Xiong 3835557e67 update nginx config 2025-11-22 21:06:01 -08:00
Zimeng Xiong 69bffab745 fix XSS and Root execution of NPM in docker 2025-11-22 20:38:40 -08:00
Zimeng Xiong ef412a3887 Merge pull request #2 from ZimengXiong/fix-bind-mount-prisma
fix bind mount prisma, auto hydrate empty folder
2025-11-22 20:25:44 -08:00
Zimeng Xiong 888834c8f0 Merge pull request #2 from ZimengXiong/fix-bind-mount-prisma
fix bind mount prisma, auto hydrate empty folder
2025-11-22 20:25:44 -08:00
Zimeng Xiong 2e2b4ca455 fix bind mount prisma, auto hydrate empty folder 2025-11-22 20:25:07 -08:00
Zimeng Xiong ae8f6d696e fix bind mount prisma, auto hydrate empty folder 2025-11-22 20:25:07 -08:00
Zimeng Xiong fb5fe1235c add fallback for browsers that do not have crypto.randomUUID 2025-11-22 19:18:05 -08:00
Zimeng Xiong 77c1824b00 add fallback for browsers that do not have crypto.randomUUID 2025-11-22 19:18:05 -08:00
Zimeng Xiong e21cdbe6a8 add CORS fallback 2025-11-22 19:14:55 -08:00
Zimeng Xiong c54a2ae5e7 add CORS fallback 2025-11-22 19:14:55 -08:00
Zimeng Xiong 94f33f0a56 fix: add linux-musl-openssl-3.0.x 2025-11-22 19:07:28 -08:00
Zimeng Xiong 55162c0b93 fix: add linux-musl-openssl-3.0.x 2025-11-22 19:07:28 -08:00
Zimeng Xiong 5d5e22c8a1 fix: pinning CORS to FRONTEND_URL, validate drawing payloads with Zod, staging SQLite imports with integrity checks and atomic swaps in index.ts 2025-11-22 17:17:50 -08:00
Zimeng Xiong b3dbcc2376 Update caution note in README
Added cautionary note about security and production use.
2025-11-22 16:33:23 -08:00
111 changed files with 12943 additions and 13673 deletions
+199
View File
@@ -0,0 +1,199 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
name: Backend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend dependencies
run: |
cd backend
npm ci
- name: Generate Prisma client
run: |
cd backend
npx prisma generate
- name: Run backend tests
run: |
cd backend
npm test
frontend-unit-tests:
name: Frontend Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm ci
- name: Run frontend tests
run: |
cd frontend
npm test
e2e-tests:
name: E2E Browser Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install backend dependencies
run: |
cd backend
npm ci
- name: Generate Prisma client
run: |
cd backend
npx prisma generate
- name: Setup backend database
run: |
cd backend
npx prisma db push
env:
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
- name: Install frontend dependencies
run: |
cd frontend
npm ci
- name: Install E2E test dependencies
run: |
cd e2e
npm ci
- name: Install Playwright browsers
run: |
cd e2e
npx playwright install chromium --with-deps
- name: Start servers and run E2E tests
run: |
# Start backend server in background
cd backend
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
BACKEND_PID=$!
cd ..
# Wait for backend to be ready
echo "Waiting for backend server..."
for i in {1..30}; do
if curl -s http://localhost:8000/health > /dev/null; then
echo "Backend is ready!"
break
fi
echo "Attempt $i: Backend not ready yet..."
sleep 2
done
# Start frontend server in background
cd frontend
npm run dev -- --host &
FRONTEND_PID=$!
cd ..
# Wait for frontend to be ready
echo "Waiting for frontend server..."
for i in {1..30}; do
if curl -s http://localhost:5173 > /dev/null; then
echo "Frontend is ready!"
break
fi
echo "Attempt $i: Frontend not ready yet..."
sleep 2
done
# Run E2E tests
cd e2e
NO_SERVER=true CI=true npx playwright test
TEST_EXIT_CODE=$?
# Cleanup
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
exit $TEST_EXIT_CODE
env:
DATABASE_URL: file:${{ github.workspace }}/backend/prisma/e2e-test.db
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: e2e/test-results/
retention-days: 7
# Security tests for data sanitization
security-tests:
name: Security Sanitization Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend dependencies
run: |
cd backend
npm ci
- name: Generate Prisma client
run: |
cd backend
npx prisma generate
- name: Run security tests
run: |
cd backend
npx ts-node src/securityTest.ts
+110 -1
View File
@@ -1,3 +1,112 @@
# Dependencies
frontend/node_modules frontend/node_modules
.DS_Store backend/node_modules
# Database
backend/prisma/*.db backend/prisma/*.db
backend/prisma/**/*.db
backend/prisma/*.db-journal
backend/prisma/**/*.db-journal
backend/prisma/dev.db
backend/prisma/e2e-test.db
backend/prisma/*.backup
# Uploads
backend/uploads/
# Generated files
backend/src/generated/
# Environment variables
.env
.env.local
.env.production
.env.staging
# Build outputs
frontend/dist/
frontend/build/
backend/dist/
# E2E Testing
e2e/node_modules/
e2e/test-results/
e2e/test-results-user/
e2e/playwright-report/
e2e/playwright-report-user/
e2e/.playwright/
# Temporary files
*.tmp
*.temp
*.bak
# Test artifacts (in case they appear in other locations)
**/playwright-report/
**/test-results/
**/playwright/.cache/
# Docker volumes (if any temporary ones are created)
docker-volumes/
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# Vitest cache
.vitest/
# Playwright screenshots/videos on failure
**/screenshots/
**/videos/
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
+1 -1
View File
@@ -148,7 +148,7 @@ ExcaliDash/
**Backend (.env):** **Backend (.env):**
```bash ```bash
DATABASE_URL="file:./prisma/dev.db" DATABASE_URL="file:./dev.db"
PORT=8000 PORT=8000
NODE_ENV=development NODE_ENV=development
``` ```
+661
View File
File diff suppressed because it is too large Load Diff
+561
View File
File diff suppressed because it is too large Load Diff
+47 -3
View File
@@ -1,8 +1,9 @@
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88"> <img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
# ExcaliDash v0.1.0 # ExcaliDash
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com)
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.
@@ -21,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
- [Installation](#installation) - [Installation](#installation)
- [Docker Hub (Recommended)](#dockerhub-recommended) - [Docker Hub (Recommended)](#dockerhub-recommended)
- [Docker Build](#docker-build) - [Docker Build](#docker-build)
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
- [Development](#development) - [Development](#development)
- [Clone the Repository](#clone-the-repository) - [Clone the Repository](#clone-the-repository)
- [Frontend](#frontend) - [Frontend](#frontend)
@@ -74,7 +77,10 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
# Installation # Installation
> [!CAUTION] > [!CAUTION]
> NOT for production use. This is just a side project (and also the first release), and it likely contains some bugs. DO NOT open ports to the internet (e.g. CORS is set to allow all) > NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
> [!CAUTION]
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
## Docker Hub (Recommended) ## Docker Hub (Recommended)
@@ -110,6 +116,44 @@ docker compose up -d
# Access the frontend at localhost:6767 # Access the frontend at localhost:6767
``` ```
### Reverse Proxy / Traefik Setups (Docker)
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
```yaml
# docker-compose.yml example
backend:
environment:
- FRONTEND_URL=https://excalidash.example.com
frontend:
environment:
# For standard Docker Compose (default)
# - BACKEND_URL=backend:8000
# For Kubernetes, use the service DNS name:
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
```
### Multi-Container / Kubernetes Deployments
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
```bash
# Generate a secure secret
openssl rand -base64 32
```
```yaml
# docker-compose.yml or k8s deployment
backend:
environment:
- CSRF_SECRET=your-generated-secret-here
```
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
# Development # Development
## Clone the Repository ## Clone the Repository
+43
View File
@@ -0,0 +1,43 @@
CSRF Protection (8a78b2b)
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
- Added new backend/src/security.ts module for security utilities
- Frontend API layer now handles CSRF tokens automatically
- Added integration tests for CSRF validation
Upload Progress Indicator (8f9b9b4)
- Added a visual upload progress bar when users upload files
- New UploadContext for managing upload state across components
- New UploadStatus component displaying real-time upload progress
- Save status indicator when navigating back from the editor
- Improved error handling and recovery for failed uploads
Bug Fixes
- Fixed broken e2e tests (cae8f3c)
- Replaced deprecated substr() with substring()
- Fixed stale state issues in error handling
- Fixed missing useEffect dependencies
- Fixed CSS class conflicts in progress bar styling
- Added error recovery for save state in Editor
Infrastructure
- Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README
### Kubernetes
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
```bash
openssl rand -base64 32
Add it to your deployment:
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
If not set, the backend will refuse to start.
```
+1
View File
@@ -0,0 +1 @@
0.3.1
+1
View File
@@ -2,3 +2,4 @@
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=http://localhost:6767
+12 -4
View File
@@ -25,8 +25,10 @@ RUN npx tsc
# Production stage # Production stage
FROM node:20-alpine FROM node:20-alpine
# Install OpenSSL for Prisma # Install OpenSSL for Prisma and su-exec, create non-root user
RUN apk add --no-cache openssl RUN apk add --no-cache openssl su-exec && \
addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app WORKDIR /app
@@ -36,8 +38,9 @@ COPY package*.json ./
# Install production dependencies only # Install production dependencies only
RUN npm ci --only=production RUN npm ci --only=production
# Copy prisma schema and migrations # Copy prisma schema and migrations for runtime and hydration template
COPY prisma ./prisma/ COPY prisma ./prisma/
COPY prisma ./prisma_template/
# Copy built application from builder # Copy built application from builder
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
@@ -48,10 +51,15 @@ COPY --from=builder /app/src/generated ./dist/generated
# Generate Prisma Client in production (updates node_modules) # Generate Prisma Client in production (updates node_modules)
RUN npx prisma generate RUN npx prisma generate
# Run migrations and start server # Create necessary directories (ownership will be set in entrypoint)
RUN mkdir -p /app/uploads /app/prisma
# Copy and set permissions for entrypoint script
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]
+30 -4
View File
@@ -1,8 +1,34 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Run migrations # 1. Hydrate volume if empty (Running as root)
npx prisma migrate deploy if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..."
cp -R /app/prisma_template/. /app/prisma/
else
# Volume exists but may be missing new migrations from an upgrade
# Always sync schema and migrations from template to ensure upgrades work
echo "Syncing schema and migrations from template..."
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
fi
# Start the application # 2. Fix permissions unconditionally (Running as root)
node dist/index.js echo "Fixing filesystem permissions..."
chown -R nodejs:nodejs /app/uploads
chown -R nodejs:nodejs /app/prisma
chmod 755 /app/uploads
# Ensure database file has proper permissions
if [ -f "/app/prisma/dev.db" ]; then
echo "Database file found, ensuring write permissions..."
chmod 666 /app/prisma/dev.db
fi
# 3. Run Migrations (Drop privileges to nodejs)
echo "Running database migrations..."
su-exec nodejs npx prisma migrate deploy
# 4. Start Application (Drop privileges to nodejs)
echo "Starting application as nodejs..."
exec su-exec nodejs node dist/index.js
+2162 -28
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -1,11 +1,13 @@
{ {
"name": "backend", "name": "backend",
"version": "1.0.0", "version": "0.3.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nodemon src/index.ts", "dev": "nodemon src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1" "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -14,14 +16,18 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/jsdom": "^21.1.7",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/socket.io": "^3.0.1", "@types/socket.io": "^3.0.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-sqlite3": "^12.4.6", "better-sqlite3": "^12.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"dompurify": "^3.3.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"jsdom": "^22.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@@ -29,9 +35,11 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/supertest": "^6.0.3",
"nodemon": "^3.1.11", "nodemon": "^3.1.11",
"prisma": "^5.22.0", "supertest": "^7.1.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.0.15"
} }
} }
Binary file not shown.
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Library" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
Binary file not shown.
+8 -1
View File
@@ -4,7 +4,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../src/generated/client" output = "../src/generated/client"
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -33,3 +33,10 @@ model Drawing {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -0,0 +1,169 @@
/**
* 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, Express takes second-to-last IP (the external proxy)
expect(response1.body.ip).toBe("10.0.0.5");
console.log(
"trust proxy: 1 → IP:",
response1.body.ip,
"(external proxy IP - WRONG)",
);
// 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
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.1.4"); // Proxy IP, not client IP
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* CSRF Tests - Horizontal Scaling (K8s) Validation
*
* PR #20 review concern:
* "Worried that in memory token store might not work on horizontal scaling"
*
* Fix:
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
* - Any pod can validate any token as long as all pods share the same secret.
*
* These tests prove:
* - Tokens validate correctly for the issuing client id
* - Tokens do NOT validate for a different client id
* - Tokens expire after 24 hours
* - Tokens validate across separate module instances (simulated pods)
*/
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
const SHARED_SECRET = "test-shared-csrf-secret";
beforeAll(() => {
// Must be shared across instances/pods for horizontal scaling.
process.env.CSRF_SECRET = SHARED_SECRET;
});
afterEach(() => {
vi.useRealTimers();
});
describe("CSRF - stateless HMAC tokens", () => {
it("creates a token in payload.signature format and validates for same client id", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "test-client-1";
const token = createCsrfToken(clientId);
expect(typeof token).toBe("string");
// base64url(payload).base64url(signature)
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("rejects validation for a different client id (token binding)", async () => {
const { createCsrfToken, validateCsrfToken } = await import("../security");
const token = createCsrfToken("client-a");
expect(validateCsrfToken("client-b", token)).toBe(false);
});
it("rejects malformed tokens", async () => {
const { validateCsrfToken } = await import("../security");
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
expect(validateCsrfToken("client", "")).toBe(false);
});
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
"../security"
);
const clientId = "client-revoke";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
revokeCsrfToken(clientId);
// Stateless token remains valid until expiry
expect(validateCsrfToken(clientId, token)).toBe(true);
});
it("expires tokens after 24 hours", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const { createCsrfToken, validateCsrfToken } = await import("../security");
const clientId = "client-expiry";
const token = createCsrfToken(clientId);
expect(validateCsrfToken(clientId, token)).toBe(true);
// 24h + 1ms later
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
expect(validateCsrfToken(clientId, token)).toBe(false);
});
});
describe("CSRF - horizontal scaling (simulated pods)", () => {
it("validates across module instances (pod A issues, pod B validates)", async () => {
const clientId = "user-123";
vi.resetModules();
const podA = await import("../security");
const token = podA.createCsrfToken(clientId);
// Simulate a different pod (new Node.js process / fresh module state)
vi.resetModules();
const podB = await import("../security");
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
});
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
const clientId = "user-round-robin";
const pods: Array<{
createCsrfToken: (clientId: string) => string;
validateCsrfToken: (clientId: string, token: string) => boolean;
}> = [];
for (let i = 0; i < 3; i++) {
vi.resetModules();
pods.push(await import("../security"));
}
// Token issued on one pod
const token = pods[0].createCsrfToken(clientId);
// Validate on alternating pods (simulates a non-sticky load balancer)
const attempts = 60;
let failures = 0;
for (let i = 0; i < attempts; i++) {
const pod = pods[i % pods.length];
if (!pod.validateCsrfToken(clientId, token)) failures++;
}
expect(failures).toBe(0);
});
});
describe("CSRF - referer origin parsing", () => {
it("extracts exact origin from a referer URL", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
"https://example.com"
);
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
"http://localhost:5173"
);
});
it("does not allow prefix tricks (origin must be parsed)", async () => {
const { getOriginFromReferer } = await import("../security");
expect(
getOriginFromReferer("https://example.com.evil.com/anything")
).toBe("https://example.com.evil.com");
// `startsWith("https://example.com")` would incorrectly allow this.
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
"https://evil.com"
);
});
it("returns null for invalid or non-http(s) referers", async () => {
const { getOriginFromReferer } = await import("../security");
expect(getOriginFromReferer("")).toBeNull();
expect(getOriginFromReferer("not a url")).toBeNull();
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
expect(getOriginFromReferer(null)).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
/**
* Test utilities for backend integration tests
*/
import { PrismaClient } from "../generated/client";
import path from "path";
import { execSync } from "child_process";
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
/**
* Get a test Prisma client pointing to the test database
*/
export const getTestPrisma = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
return new PrismaClient({
datasources: {
db: {
url: databaseUrl,
},
},
});
};
/**
* Setup the test database by running migrations
*/
export const setupTestDb = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
// Run Prisma migrations to create the test database
try {
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: "pipe",
});
} catch (error) {
console.error("Failed to setup test database:", error);
throw error;
}
};
/**
* Clean up the test database between tests
*/
export const cleanupTestDb = async (prisma: PrismaClient) => {
// Delete all drawings and collections (except Trash)
await prisma.drawing.deleteMany({});
await prisma.collection.deleteMany({
where: { id: { not: "trash" } },
});
};
/**
* Initialize test database with required data
*/
export const initTestDb = async (prisma: PrismaClient) => {
// Ensure Trash collection exists
const trash = await prisma.collection.findUnique({
where: { id: "trash" },
});
if (!trash) {
await prisma.collection.create({
data: { id: "trash", name: "Trash" },
});
}
};
/**
* Generate a sample base64 PNG image data URL
* This creates a small but valid PNG for testing
*/
export const generateSampleImageDataUrl = (size: "small" | "medium" | "large" = "small"): string => {
// Minimal 1x1 red PNG (smallest valid PNG possible)
const smallPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
if (size === "small") {
return `data:image/png;base64,${smallPng}`;
}
// For medium/large, repeat the pattern to create larger payloads
const repetitions = size === "medium" ? 1000 : 10000;
const paddedBase64 = smallPng.repeat(repetitions);
return `data:image/png;base64,${paddedBase64}`;
};
/**
* Generate a large image data URL that exceeds the 10000 char limit
* This is specifically designed to catch the truncation bug from issue #17
*/
export const generateLargeImageDataUrl = (): string => {
// Create a base64 string that's definitely larger than 10000 characters
// This simulates a real image that would get truncated by the old code
const baseImage = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
// Repeat to create a ~50KB payload
const largeBase64 = baseImage.repeat(500);
return `data:image/png;base64,${largeBase64}`;
};
/**
* Create a sample Excalidraw files object with embedded images
*/
export const createSampleFilesObject = (imageCount: number = 1, size: "small" | "large" = "small") => {
const files: Record<string, any> = {};
for (let i = 0; i < imageCount; i++) {
const fileId = `file-${i}-${Date.now()}`;
files[fileId] = {
id: fileId,
mimeType: "image/png",
dataURL: size === "large" ? generateLargeImageDataUrl() : generateSampleImageDataUrl("small"),
created: Date.now(),
lastRetrieved: Date.now(),
};
}
return files;
};
/**
* Create a minimal valid Excalidraw drawing payload
*/
export const createTestDrawingPayload = (options: {
name?: string;
files?: Record<string, any> | null;
elements?: any[];
appState?: any;
} = {}) => {
return {
name: options.name ?? "Test Drawing",
elements: options.elements ?? [
{
id: "element-1",
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: null,
seed: 12345,
version: 1,
versionNonce: 1,
isDeleted: false,
boundElements: null,
updated: Date.now(),
link: null,
locked: false,
},
],
appState: options.appState ?? {
viewBackgroundColor: "#ffffff",
gridSize: null,
},
files: options.files ?? null,
preview: null,
collectionId: null,
};
};
/**
* Compare two files objects to check if image data was preserved
*/
export const compareFilesObjects = (original: Record<string, any>, received: Record<string, any>): {
isEqual: boolean;
differences: string[];
} => {
const differences: string[] = [];
const originalKeys = Object.keys(original);
const receivedKeys = Object.keys(received);
if (originalKeys.length !== receivedKeys.length) {
differences.push(`Key count mismatch: original=${originalKeys.length}, received=${receivedKeys.length}`);
}
for (const key of originalKeys) {
if (!(key in received)) {
differences.push(`Missing key: ${key}`);
continue;
}
const origFile = original[key];
const recvFile = received[key];
// Check dataURL specifically - this is where truncation would occur
if (origFile.dataURL !== recvFile.dataURL) {
differences.push(
`DataURL mismatch for ${key}: ` +
`original length=${origFile.dataURL?.length ?? 0}, ` +
`received length=${recvFile.dataURL?.length ?? 0}`
);
// Check if it was truncated
if (recvFile.dataURL && origFile.dataURL?.startsWith(recvFile.dataURL.substring(0, 100))) {
differences.push(`TRUNCATION DETECTED: dataURL was cut short`);
}
}
}
return {
isEqual: differences.length === 0,
differences,
};
};
-29
View File
@@ -1,29 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }
export * as $Enums from './enums'
export * from './enums';
/**
* Model Collection
*
*/
export type Collection = Prisma.CollectionModel
/**
* Model Drawing
*
*/
export type Drawing = Prisma.DrawingModel
-49
View File
@@ -1,49 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from './enums'
export * from "./enums"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Collections
* const collections = await prisma.collection.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model Collection
*
*/
export type Collection = Prisma.CollectionModel
/**
* Model Drawing
*
*/
export type Drawing = Prisma.DrawingModel
@@ -1,272 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import type * as Prisma from "./internal/prismaNamespace"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
-1
View File
@@ -1 +0,0 @@
export * from "./index"

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