Compare commits

..

11 Commits

Author SHA1 Message Date
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
45 changed files with 5089 additions and 4141 deletions
+2 -2
View File
@@ -108,7 +108,7 @@ jobs:
run: |
# Start backend server in background
cd backend
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev &
DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:6767" npm run dev &
BACKEND_PID=$!
cd ..
@@ -132,7 +132,7 @@ jobs:
# Wait for frontend to be ready
echo "Waiting for frontend server..."
for i in {1..30}; do
if curl -s http://localhost:5173 > /dev/null; then
if curl -s http://localhost:6767 > /dev/null; then
echo "Frontend is ready!"
break
fi
+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
- Added new backend/src/security.ts module for security utilities
- Frontend API layer now handles CSRF tokens automatically
- Added integration tests for CSRF validation
Multi-user support for excalidash
- Admin dashboard
- Password reset, force user password reset (admin only), account lockout recovery
- 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.2
0.4.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "0.3.2",
"version": "0.4.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -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");
+5
View File
@@ -49,6 +49,8 @@ model Collection {
drawings Drawing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, updatedAt])
}
model Drawing {
@@ -65,6 +67,9 @@ model Drawing {
collection Collection? @relation(fields: [collectionId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, updatedAt])
@@index([userId, collectionId, updatedAt])
}
model Library {
@@ -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");
});
});
@@ -11,9 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import bcrypt from "bcrypt";
import {
getTestPrisma,
cleanupTestDb,
setupTestDb,
createTestDrawingPayload,
} from "./testUtils";
import { PrismaClient } from "../generated/client";
+110 -2063
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
import { z } from "zod";
export const registerSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(100),
name: z.string().trim().min(1).max(100),
});
export const loginSchema = z
.object({
identifier: z.string().trim().min(1).max(255).optional(),
email: z.string().email().toLowerCase().trim().optional(),
username: z.string().trim().min(1).max(255).optional(),
password: z.string(),
})
.refine((data) => Boolean(data.identifier || data.email || data.username), {
message: "identifier/email/username is required",
});
export const registrationToggleSchema = z.object({
enabled: z.boolean(),
});
export const adminRoleUpdateSchema = z.object({
identifier: z.string().trim().min(1).max(255),
role: z.enum(["ADMIN", "USER"]),
});
export const authEnabledToggleSchema = z.object({
enabled: z.boolean(),
});
export const adminCreateUserSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(100),
name: z.string().trim().min(1).max(100),
role: z.enum(["ADMIN", "USER"]).optional(),
mustResetPassword: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export const adminUpdateUserSchema = z.object({
username: z.string().trim().min(3).max(50).nullable().optional(),
name: z.string().trim().min(1).max(100).optional(),
role: z.enum(["ADMIN", "USER"]).optional(),
mustResetPassword: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export const impersonateSchema = z
.object({
userId: z.string().trim().min(1).optional(),
identifier: z.string().trim().min(1).optional(),
})
.refine((data) => Boolean(data.userId || data.identifier), {
message: "userId/identifier is required",
});
export const loginRateLimitUpdateSchema = z.object({
enabled: z.boolean(),
windowMs: z.number().int().min(10_000).max(24 * 60 * 60 * 1000),
max: z.number().int().min(1).max(10_000),
});
export const loginRateLimitResetSchema = z.object({
identifier: z.string().trim().min(1).max(255),
});
export const passwordResetRequestSchema = z.object({
email: z.string().email().toLowerCase().trim(),
});
export const passwordResetConfirmSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(100),
});
export const updateProfileSchema = z.object({
name: z.string().trim().min(1).max(100),
});
export const updateEmailSchema = z.object({
email: z.string().email().toLowerCase().trim(),
currentPassword: z.string().min(1).max(100),
});
export const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8).max(100),
});
export const mustResetPasswordSchema = z.object({
newPassword: z.string().min(8).max(100),
});
-8
View File
@@ -24,14 +24,6 @@ interface Config {
enableAuditLogging: boolean;
}
const getRequiredEnv = (key: string): string => {
const value = process.env[key];
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
};
+113 -1349
View File
File diff suppressed because it is too large Load Diff
+20 -24
View File
@@ -1,6 +1,3 @@
/**
* Authentication middleware for protecting routes
*/
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config";
@@ -16,7 +13,7 @@ type AuthEnabledCache = {
};
let authEnabledCache: AuthEnabledCache | null = null;
const AUTH_ENABLED_TTL_MS = 0;
const AUTH_ENABLED_TTL_MS = 5000;
const getAuthEnabled = async (): Promise<boolean> => {
const now = Date.now();
@@ -24,16 +21,32 @@ const getAuthEnabled = async (): Promise<boolean> => {
return authEnabledCache.value;
}
const systemConfig = await prisma.systemConfig.upsert({
let systemConfig = await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {},
create: {
select: { authEnabled: true },
});
if (!systemConfig) {
try {
systemConfig = await prisma.systemConfig.create({
data: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
registrationEnabled: false,
},
select: { authEnabled: true },
});
} catch {
// Handle race from concurrent initialization.
systemConfig = await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: { authEnabled: true },
});
if (!systemConfig) {
throw new Error("Failed to initialize system config");
}
}
}
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
return systemConfig.authEnabled;
@@ -102,9 +115,6 @@ interface JwtPayload {
impersonatorId?: string;
}
/**
* Type guard to check if decoded JWT is our expected payload structure
*/
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
if (typeof decoded !== "object" || decoded === null) {
return false;
@@ -120,9 +130,6 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
);
};
/**
* Extract JWT token from Authorization header
*/
const extractToken = (req: Request): string | null => {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") return null;
@@ -135,9 +142,6 @@ const extractToken = (req: Request): string | null => {
return parts[1];
};
/**
* Verify and decode JWT token
*/
const verifyToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
@@ -170,10 +174,6 @@ const isAllowedWhileMustResetPassword = (req: Request): boolean => {
return false;
};
/**
* Require authentication middleware
* Protects routes that require a valid JWT token
*/
export const requireAuth = async (
req: Request,
res: Response,
@@ -276,10 +276,6 @@ export const requireAuth = async (
}
};
/**
* Optional authentication middleware
* Attaches user to request if token is present, but doesn't require it
*/
export const optionalAuth = async (
req: Request,
res: Response,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -31
View File
@@ -552,21 +552,6 @@ const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew toleran
const CSRF_NONCE_BYTES = 16;
const CSRF_TOKEN_MAX_LENGTH = 2048; // sanity limit against abuse
/**
* IMPORTANT (Horizontal Scaling / K8s)
* -----------------------------------
* CSRF tokens must validate across multiple stateless instances.
*
* The prior in-memory Map-based token store breaks under horizontal scaling
* because each pod has its own memory. This implementation is stateless:
*
* - Token payload: { ts, nonce }
* - Signature: HMAC_SHA256(secret, `${clientId}|${ts}|${nonce}`)
*
* As long as all pods share the same `CSRF_SECRET`, any pod can validate
* any token without shared state (works on Kubernetes).
*/
let cachedCsrfSecret: Buffer | null = null;
const getCsrfSecret = (): Buffer => {
if (cachedCsrfSecret) return cachedCsrfSecret;
@@ -577,9 +562,7 @@ const getCsrfSecret = (): Buffer => {
return cachedCsrfSecret;
}
// If not configured, generate an ephemeral secret for this process.
// This keeps single-instance deployments working out of the box, but:
// - Horizontal scaling will BREAK unless CSRF_SECRET is set and shared.
// Fallback for local/single-instance setups.
cachedCsrfSecret = crypto.randomBytes(32);
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
console.warn(
@@ -609,9 +592,7 @@ const base64UrlDecode = (input: string): Buffer => {
};
type CsrfTokenPayload = {
/** Issued-at timestamp (ms since epoch) */
ts: number;
/** Random nonce (base64url) */
nonce: string;
};
@@ -621,10 +602,6 @@ const signCsrfToken = (clientId: string, payload: CsrfTokenPayload): Buffer => {
return crypto.createHmac("sha256", secret).update(data, "utf8").digest();
};
/**
* Create a new CSRF token for a client
* Returns the token to be sent to the client
*/
export const createCsrfToken = (clientId: string): string => {
const payload: CsrfTokenPayload = {
ts: Date.now(),
@@ -638,10 +615,6 @@ export const createCsrfToken = (clientId: string): string => {
return `${payloadB64}.${sigB64}`;
};
/**
* Validate a CSRF token for a client
* Uses timing-safe comparison to prevent timing attacks
*/
export const validateCsrfToken = (clientId: string, token: string): boolean => {
if (!token || typeof token !== "string") {
return false;
@@ -688,9 +661,6 @@ export const validateCsrfToken = (clientId: string, token: string): boolean => {
}
};
/**
* Revoke a CSRF token (e.g., on logout or token refresh)
*/
export const revokeCsrfToken = (clientId: string): void => {
// Stateless CSRF tokens cannot be selectively revoked without shared state.
// If revocation is required, implement token blacklisting in a shared store
+17 -2
View File
@@ -1,11 +1,26 @@
const { parentPort, workerData } = require('worker_threads');
const Database = require('better-sqlite3');
if (!parentPort) throw new Error("Must be run in a worker thread");
const openReadonlyDb = (filePath) => {
try {
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(filePath, {
readOnly: true,
enableForeignKeyConstraints: false,
});
return { kind: "node:sqlite", db };
} catch (_err) {
// Fall back to better-sqlite3 on Node versions that don't have node:sqlite.
const Database = require("better-sqlite3");
const db = new Database(filePath, { readonly: true, fileMustExist: true });
return { kind: "better-sqlite3", db };
}
};
try {
const { filePath } = workerData;
const db = new Database(filePath, { readonly: true, fileMustExist: true });
const { db } = openReadonlyDb(filePath);
// This is the CPU-heavy operation
const result = db.prepare("PRAGMA integrity_check;").get();
+2
View File
@@ -6,6 +6,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
# Required for authentication: must be explicitly set to a strong secret (min 32 chars)
- JWT_SECRET=${JWT_SECRET}
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes:
+2 -2
View File
@@ -8,8 +8,8 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
# Required for authentication: set a strong secret (min 32 chars)
- JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production-min-32-chars}
# Required for authentication: must be explicitly set to a strong secret (min 32 chars)
- JWT_SECRET=${JWT_SECRET}
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
# - CSRF_SECRET=${CSRF_SECRET}
volumes:
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
// Centralized test environment URLs
const FRONTEND_PORT = 5173;
const FRONTEND_PORT = 6767;
const BACKEND_PORT = 8000;
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
@@ -10,7 +10,7 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
* Playwright configuration for E2E browser testing
*
* Environment variables:
* - BASE_URL: Frontend URL (default: http://localhost:5173)
* - BASE_URL: Frontend URL (default: http://localhost:6767)
* - API_URL: Backend API URL (default: http://localhost:8000)
* - HEADED: Run in headed mode (default: false)
* - NO_SERVER: Skip starting servers (default: false)
+24 -7
View File
@@ -145,9 +145,10 @@ test.describe("Dashboard Workflows", () => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await applyDashboardSearch(page, prefix);
await expect(page.locator("[id^='drawing-card-']")).toHaveCount(2);
await ensureCardSelected(page, first.id);
await ensureCardSelected(page, second.id);
// Select all filtered cards (2) for a deterministic bulk action.
await page.getByTitle("Select All").click();
await page.getByTitle("Duplicate Selected").click();
@@ -156,16 +157,32 @@ test.describe("Dashboard Workflows", () => {
return results.length;
}).toBe(4);
const allPrefixDrawings = await listDrawings(request, { search: prefix });
for (const drawing of allPrefixDrawings) {
await ensureCardSelected(page, drawing.id);
}
await applyDashboardSearch(page, prefix);
await expect(page.locator("[id^='drawing-card-']")).toHaveCount(4);
const bulkMoveToTrash = async () => {
await page.getByTitle("Select All").click();
await expect(page.getByTitle("Move to Trash")).toBeEnabled();
await page.getByTitle("Move to Trash").click();
};
// Move all 4. If one is missed due transient selection flake, recover with extra passes.
await bulkMoveToTrash();
for (let i = 0; i < 2; i++) {
const remaining = await listDrawings(request, { search: prefix });
if (remaining.length === 0) break;
await applyDashboardSearch(page, prefix);
await page.waitForTimeout(400);
const visibleCount = await page.locator("[id^='drawing-card-']").count();
if (visibleCount === 0) continue;
await bulkMoveToTrash();
}
await expect.poll(async () => {
const trashed = await listDrawings(request, { search: prefix, collectionId: "trash" });
return trashed.length;
}).toBe(4);
}, { timeout: 15000 }).toBe(4);
const trashDrawings = await listDrawings(request, { search: prefix, collectionId: "trash" });
for (const drawing of trashDrawings) {
+26 -66
View File
@@ -1,6 +1,4 @@
import { test, expect } from "@playwright/test";
import * as path from "path";
import * as fs from "fs";
import {
createDrawing,
deleteDrawing,
@@ -201,85 +199,47 @@ test.describe("Drag and Drop - File Import", () => {
});
test("should show drop zone overlay when dragging files", async ({ page }) => {
// Note: Simulating drag events with files is unreliable in Playwright
// because the DataTransfer API has security restrictions.
// This test verifies the drop zone UI exists and can be triggered.
await page.goto("/");
await page.waitForLoadState("networkidle");
// Verify the dashboard is loaded
await expect(page.getByPlaceholder("Search drawings...")).toBeVisible();
// Try to trigger drag event - this may not work in all browsers
// due to security restrictions on DataTransfer
const triggered = await page.evaluate(() => {
try {
const dt = new DataTransfer();
dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' }));
const event = new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer: dt,
});
// Find the main content area and dispatch the event
const main = document.querySelector('main');
if (main) {
main.dispatchEvent(event);
return true;
}
return false;
} catch (e) {
console.error('Failed to simulate drag event:', e);
return false;
}
});
if (triggered) {
// Check that the drop zone overlay is shown
const dropZone = page.getByText("Drop files to import");
const isVisible = await dropZone.isVisible().catch(() => false);
if (isVisible) {
await expect(dropZone).toBeVisible();
} else {
// If drag simulation doesn't work, verify the import button exists as fallback
// Drag-and-drop simulation is flaky in headless browsers.
// Assert the import affordances that back DnD/import are present.
await expect(page.getByRole("button", { name: /Import/i })).toBeVisible();
await expect(page.locator("#dashboard-import")).toBeAttached();
}
} else {
// If drag simulation doesn't work, verify the import button exists as fallback
await expect(page.locator("#dashboard-import")).toBeAttached();
}
});
test("should import excalidraw file via file input", async ({ page }, testInfo) => {
test("should import excalidraw file via file input", async ({ page, request }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Resolve fixture relative to project test directory to avoid env differences
const fixturePath = path.join(testInfo.project.testDir, "..", "fixtures", "small-image.excalidraw");
// Fail fast if the fixture is missing instead of skipping the test
expect(fs.existsSync(fixturePath)).toBeTruthy();
// Click import button to open file dialog
const importButton = page.getByRole("button", { name: /Import/i });
await importButton.click();
const fileBase = `ImportedDnD_${Date.now()}`;
const excalidrawContent = JSON.stringify({
type: "excalidraw",
version: 2,
source: "e2e-test",
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
});
// Find the hidden file input and upload the file
const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(fixturePath);
await fileInput.setInputFiles({
name: `${fileBase}.excalidraw`,
mimeType: "application/json",
buffer: Buffer.from(excalidrawContent),
});
// Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Wait until backend contains imported drawing
await expect.poll(async () => {
const drawings = await listDrawings(request, { search: fileBase });
return drawings.length;
}, { timeout: 15000 }).toBeGreaterThan(0);
// Search for the imported drawing (it uses the filename as name)
await page.getByPlaceholder("Search drawings...").fill("small-image");
await page.waitForTimeout(500);
// Verify imported drawing is visible in dashboard
await page.getByPlaceholder("Search drawings...").fill(fileBase);
await page.waitForTimeout(700);
// Verify at least one drawing was imported
const importedCards = page.locator("[id^='drawing-card-']");
await expect(importedCards.first()).toBeVisible();
});

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