From f214e4f7b7608b33590e6fdfe711a9f1571f3cc3 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Fri, 6 Feb 2026 23:05:23 -0800 Subject: [PATCH] Ensure non multi-user flow stays --- backend/src/__tests__/user-sandboxing.test.ts | 2 - backend/src/auth/coreRoutes.ts | 2 +- backend/src/config.ts | 8 ---- backend/src/index.ts | 1 - backend/src/middleware/auth.ts | 32 ++++++++++---- backend/src/routes/dashboard.ts | 14 +++--- backend/src/routes/importExport.ts | 2 - e2e/tests/image-collab.spec.ts | 13 +----- frontend/src/components/Sidebar.tsx | 13 +----- frontend/src/pages/editor/shared.ts | 10 +---- frontend/src/utils/identity.ts | 44 ++++++++++++++++--- frontend/src/utils/user.ts | 9 ++++ 12 files changed, 80 insertions(+), 70 deletions(-) create mode 100644 frontend/src/utils/user.ts diff --git a/backend/src/__tests__/user-sandboxing.test.ts b/backend/src/__tests__/user-sandboxing.test.ts index 790c111..f7a5cca 100644 --- a/backend/src/__tests__/user-sandboxing.test.ts +++ b/backend/src/__tests__/user-sandboxing.test.ts @@ -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"; diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index 92d3dbf..1916b30 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from "express"; import bcrypt from "bcrypt"; import jwt, { SignOptions } from "jsonwebtoken"; -import { PrismaClient, Prisma } from "../generated/client"; +import { PrismaClient } from "../generated/client"; import { StringValue } from "ms"; import { logAuditEvent } from "../utils/audit"; import { diff --git a/backend/src/config.ts b/backend/src/config.ts index a2749f8..2ba6602 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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; }; diff --git a/backend/src/index.ts b/backend/src/index.ts index 9de37b5..a3f7f5f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -908,7 +908,6 @@ registerImportExportRoutes({ asyncHandler, upload, uploadDir, - config, backendRoot, getBackendVersion, parseJsonField, diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index d4fd7d8..f0a9227 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -13,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 => { const now = Date.now(); @@ -21,17 +21,33 @@ const getAuthEnabled = async (): Promise => { return authEnabledCache.value; } - const systemConfig = await prisma.systemConfig.upsert({ + let systemConfig = await prisma.systemConfig.findUnique({ where: { id: DEFAULT_SYSTEM_CONFIG_ID }, - update: {}, - create: { - id: DEFAULT_SYSTEM_CONFIG_ID, - authEnabled: false, - registrationEnabled: false, - }, 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; }; diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 9064e35..c12c755 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -211,17 +211,15 @@ export const registerDashboardRoutes = ( if (!req.user) return res.status(401).json({ error: "Unauthorized" }); const { id } = req.params; - const drawing = await prisma.drawing.findUnique({ where: { id } }); + const drawing = await prisma.drawing.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); if (!drawing) { return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" }); } - if (drawing.userId !== req.user.id) { - return res.status(403).json({ - error: "Forbidden", - code: "DRAWING_ACCESS_DENIED", - message: "You do not have access to this drawing", - }); - } return res.json({ ...drawing, diff --git a/backend/src/routes/importExport.ts b/backend/src/routes/importExport.ts index 4a6eb00..63a2645 100644 --- a/backend/src/routes/importExport.ts +++ b/backend/src/routes/importExport.ts @@ -56,7 +56,6 @@ type RegisterImportExportDeps = { ) => express.RequestHandler; upload: any; uploadDir: string; - config: { nodeEnv: string }; backendRoot: string; getBackendVersion: () => string; parseJsonField: (rawValue: string | null | undefined, fallback: T) => T; @@ -231,7 +230,6 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { asyncHandler, upload, uploadDir, - config, backendRoot, getBackendVersion, parseJsonField, diff --git a/e2e/tests/image-collab.spec.ts b/e2e/tests/image-collab.spec.ts index 611fcd8..2b98ba4 100644 --- a/e2e/tests/image-collab.spec.ts +++ b/e2e/tests/image-collab.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import { test, expect, type BrowserContext } from "@playwright/test"; import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpers/api"; /** @@ -12,16 +12,6 @@ import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpe * file data later" behavior seen with paste/import. */ -const waitForEditorReady = async (page: Page) => { - await page.goto(page.url() || "/"); - // Excalidraw renders a canvas; this is our "loaded" signal. - await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); - await page.waitForFunction(() => { - // @ts-expect-error - injected in dev build - return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__; - }); -}; - const openEditorTab = async (context: BrowserContext, drawingId: string) => { const page = await context.newPage(); await page.goto(`/editor/${drawingId}`); @@ -249,4 +239,3 @@ test.describe("Issue #25 - image sync + deletion across tabs", () => { await context.close(); }); }); - diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 76c43ae..cd42f72 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; import { useAuth } from '../context/AuthContext'; import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation'; +import { getInitialsFromName } from '../utils/user'; interface SidebarProps { collections: Collection[]; @@ -111,18 +112,6 @@ const SidebarItem: React.FC = ({ ); }; -const getInitialsFromName = (name: string): string => { - const trimmed = name.trim(); - if (!trimmed) return 'U'; - const parts = trimmed.split(/\s+/).filter(Boolean); - if (parts.length >= 2) { - return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); - } - return trimmed.slice(0, 2).toUpperCase(); -}; - - - export const Sidebar: React.FC = ({ collections, selectedCollectionId, diff --git a/frontend/src/pages/editor/shared.ts b/frontend/src/pages/editor/shared.ts index 6549df9..a2786cd 100644 --- a/frontend/src/pages/editor/shared.ts +++ b/frontend/src/pages/editor/shared.ts @@ -62,15 +62,7 @@ export const UIOptions = { }, }; -export const getInitialsFromName = (name: string): string => { - const trimmed = name.trim(); - if (!trimmed) return 'U'; - const parts = trimmed.split(/\s+/).filter(Boolean); - if (parts.length >= 2) { - return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); - } - return trimmed.slice(0, 2).toUpperCase(); -}; +export { getInitialsFromName } from "../../utils/user"; export const getColorFromString = (str: string): string => { const COLORS = [ diff --git a/frontend/src/utils/identity.ts b/frontend/src/utils/identity.ts index 67da24f..c9f2573 100644 --- a/frontend/src/utils/identity.ts +++ b/frontend/src/utils/identity.ts @@ -106,7 +106,13 @@ const getSecureRandomInt = (maxExclusive: number): number => { cryptoObj.getRandomValues(buffer); return buffer[0] % maxExclusive; } - const seed = `${Date.now().toString(16)}:${performance.now().toString(16)}`; + const perfNow = + typeof globalThis !== "undefined" && + typeof globalThis.performance !== "undefined" && + typeof globalThis.performance.now === "function" + ? globalThis.performance.now() + : 0; + const seed = `${Date.now().toString(16)}:${perfNow.toString(16)}`; return hashString(seed) % maxExclusive; }; @@ -129,7 +135,13 @@ const generateClientId = (): string => { } // Final fallback for very old browsers; uniqueness window-scoped only. - const entropy = `${Date.now().toString(16)}-${performance.now().toString(16)}-${getSecureRandomInt(1_000_000_000).toString(16)}`; + const perfNow = + typeof globalThis !== "undefined" && + typeof globalThis.performance !== "undefined" && + typeof globalThis.performance.now === "function" + ? globalThis.performance.now() + : 0; + const entropy = `${Date.now().toString(16)}-${perfNow.toString(16)}-${getSecureRandomInt(1_000_000_000).toString(16)}`; return `id-${hashString(entropy).toString(16)}-${hashString(`${entropy}:2`).toString(16)}`; }; @@ -152,12 +164,30 @@ export const getFingerprintInitials = (seed?: string): string => { export const getUserIdentity = (): UserIdentity => { const stored = localStorage.getItem("excalidash-user-id"); if (stored) { - const parsed = JSON.parse(stored) as UserIdentity; - if (!parsed.initials || parsed.initials.length !== 2) { - parsed.initials = getFingerprintInitials(parsed.id); - localStorage.setItem("excalidash-user-id", JSON.stringify(parsed)); + try { + const parsed = JSON.parse(stored) as Partial; + if ( + parsed && + typeof parsed === "object" && + typeof parsed.id === "string" && + typeof parsed.name === "string" && + typeof parsed.color === "string" + ) { + const normalized: UserIdentity = { + id: parsed.id, + name: parsed.name, + color: parsed.color, + initials: + typeof parsed.initials === "string" && parsed.initials.length === 2 + ? parsed.initials + : getFingerprintInitials(parsed.id), + }; + localStorage.setItem("excalidash-user-id", JSON.stringify(normalized)); + return normalized; + } + } catch { + // Fall through to regenerate identity. } - return parsed; } const deviceId = getOrCreateBrowserFingerprint(); diff --git a/frontend/src/utils/user.ts b/frontend/src/utils/user.ts new file mode 100644 index 0000000..625784a --- /dev/null +++ b/frontend/src/utils/user.ts @@ -0,0 +1,9 @@ +export const getInitialsFromName = (name: string): string => { + const trimmed = name.trim(); + if (!trimmed) return "U"; + const parts = trimmed.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + } + return trimmed.slice(0, 2).toUpperCase(); +};