diff --git a/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql b/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql new file mode 100644 index 0000000..0e459d8 --- /dev/null +++ b/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a1302a3..b719647 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 2087497..33b63bf 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { PrismaClient, Prisma } from "./generated/client"; import { config } from "./config"; import { requireAuth, optionalAuth } from "./middleware/auth"; -import { sanitizeText } from "./security"; +import { sanitizeText, getCsrfTokenHeader, validateCsrfToken } from "./security"; import rateLimit, { MemoryStore } from "express-rate-limit"; import { logAuditEvent } from "./utils/audit"; import crypto from "crypto"; @@ -281,6 +281,36 @@ const requireAdmin = ( return true; }; +const getClientId = (req: Request): string => { + const ip = req.ip || req.connection.remoteAddress || "unknown"; + const userAgent = req.headers["user-agent"] || "unknown"; + return `${ip}:${userAgent}`.slice(0, 256); +}; + +const requireCsrf = (req: Request, res: Response): boolean => { + const headerName = getCsrfTokenHeader(); + const tokenHeader = req.headers[headerName]; + const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + + if (!token) { + res.status(403).json({ + error: "CSRF token missing", + message: `Missing ${headerName} header`, + }); + return false; + } + + if (!validateCsrfToken(getClientId(req), token)) { + res.status(403).json({ + error: "CSRF token invalid", + message: "Invalid or expired CSRF token. Please refresh and try again.", + }); + return false; + } + + return true; +}; + const countActiveAdmins = async () => { return prisma.user.count({ where: { role: "ADMIN", isActive: true }, @@ -968,6 +998,8 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => { */ router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => { try { + if (!requireCsrf(req, res)) return; + const parsed = authEnabledToggleSchema.safeParse(req.body); if (!parsed.success) { return res @@ -1477,6 +1509,15 @@ router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => { res.json({ user: updated }); } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } console.error("Update user error:", error); res.status(500).json({ error: "Internal server error", @@ -1745,7 +1786,7 @@ router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Requ : `http://${baseUrlRaw}` : "http://localhost:6767"; const baseUrl = baseUrlWithProtocol.replace(/\/$/, ""); - console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`); + console.log(`[DEV] Reset URL: ${baseUrl}/reset-password-confirm?token=${resetToken}`); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index b0c9f88..5d3c532 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -78,6 +78,14 @@ const isAllowedOrigin = (origin?: string): boolean => { }; const uploadDir = path.resolve(__dirname, "../uploads"); +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; +const MAX_PAGE_SIZE = 200; +const MAX_IMPORT_ARCHIVE_ENTRIES = 6000; +const MAX_IMPORT_COLLECTIONS = 1000; +const MAX_IMPORT_DRAWINGS = 5000; +const MAX_IMPORT_MANIFEST_BYTES = 2 * 1024 * 1024; +const MAX_IMPORT_DRAWING_BYTES = 5 * 1024 * 1024; +const MAX_IMPORT_TOTAL_EXTRACTED_BYTES = 120 * 1024 * 1024; let cachedBackendVersion: string | null = null; const getBackendVersion = (): string => { @@ -123,16 +131,15 @@ const initializeUploadDir = async () => { const app = express(); -// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx -// Required for correct client IP detection when running behind a reverse proxy -// Fix for issue #38: Use 'true' to handle multiple proxy layers (e.g., Traefik, Synology NAS) -// This ensures Express extracts the real client IP from the leftmost X-Forwarded-For value -const trustProxyConfig = process.env.TRUST_PROXY || "true"; +// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx. +// Default to a single trusted proxy hop unless TRUST_PROXY is explicitly configured. +// Set TRUST_PROXY=true only when you fully trust all upstream proxy hops. +const trustProxyConfig = (process.env.TRUST_PROXY ?? "1").trim(); const trustProxyValue = trustProxyConfig === "true" ? true : trustProxyConfig === "false" ? false - : parseInt(trustProxyConfig, 10) || 1; + : Number.parseInt(trustProxyConfig, 10) || 1; app.set("trust proxy", trustProxyValue); if (trustProxyValue === true) { @@ -217,14 +224,17 @@ const invalidateDrawingsCache = () => { * This is needed because Prisma enforces foreign key constraints * The trash collection is shared - drawings are still filtered by userId */ -const ensureTrashCollection = async (userId: string): Promise => { - const trashCollection = await prisma.collection.findUnique({ +const ensureTrashCollection = async ( + db: Prisma.TransactionClient | PrismaClient, + userId: string +): Promise => { + const trashCollection = await db.collection.findUnique({ where: { id: "trash" }, }); if (!trashCollection) { // Create trash collection (use first user's ID, but it's shared) - await prisma.collection.create({ + await db.collection.create({ data: { id: "trash", name: "Trash", @@ -249,7 +259,7 @@ const PORT = config.port; const upload = multer({ dest: uploadDir, limits: { - fileSize: 100 * 1024 * 1024, + fileSize: MAX_UPLOAD_SIZE_BYTES, files: 1, }, fileFilter: (req, file, cb) => { @@ -571,9 +581,15 @@ const drawingUpdateSchema = drawingBaseSchema }) .refine( (data) => { + const needsSanitization = + data.elements !== undefined || + data.appState !== undefined || + data.files !== undefined || + data.preview !== undefined; + try { const sanitizedData = { ...data }; - if (data.elements !== undefined || data.appState !== undefined) { + if (needsSanitization) { const fullData = { elements: Array.isArray(data.elements) ? data.elements : [], appState: @@ -596,13 +612,7 @@ const drawingUpdateSchema = drawingBaseSchema return true; } catch (error) { console.error("Sanitization failed:", error); - if ( - data.elements === undefined && - data.appState === undefined && - (data.name !== undefined || - data.preview !== undefined || - data.collectionId !== undefined) - ) { + if (!needsSanitization) { return true; } return false; @@ -793,6 +803,7 @@ io.use(async (socket, next) => { io.on("connection", (socket) => { const authenticatedUserId = socketUserMap.get(socket.id); + const authorizedDrawingIds = new Set(); socket.on( "join-room", @@ -812,13 +823,14 @@ io.on("connection", (socket) => { }); if (!drawing) { - socket.emit("error", { message: "Drawing not found or access denied" }); + socket.emit("error", { message: "You do not have access to this drawing" }); return; } } const roomId = `drawing_${drawingId}`; socket.join(roomId); + authorizedDrawingIds.add(drawingId); const newUser: User = { ...user, socketId: socket.id, isActive: true }; @@ -836,18 +848,29 @@ io.on("connection", (socket) => { ); socket.on("cursor-move", (data) => { - const roomId = `drawing_${data.drawingId}`; + const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; + if (!drawingId || !authorizedDrawingIds.has(drawingId)) { + return; + } + const roomId = `drawing_${drawingId}`; socket.volatile.to(roomId).emit("cursor-move", data); }); socket.on("element-update", (data) => { - const roomId = `drawing_${data.drawingId}`; + const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; + if (!drawingId || !authorizedDrawingIds.has(drawingId)) { + return; + } + const roomId = `drawing_${drawingId}`; socket.to(roomId).emit("element-update", data); }); socket.on( "user-activity", ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { + if (!authorizedDrawingIds.has(drawingId)) { + return; + } const roomId = `drawing_${drawingId}`; const users = roomUsers.get(roomId); if (users) { @@ -884,7 +907,7 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { return res.status(401).json({ error: "Unauthorized" }); } - const { search, collectionId, includeData } = req.query; + const { search, collectionId, includeData, limit, offset } = req.query; const where: Prisma.DrawingWhereInput = { userId: req.user.id, // Filter by user }; @@ -930,12 +953,21 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { ? includeData.toLowerCase() === "true" || includeData === "1" : false; + const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined; + const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined; + const parsedLimit = + rawLimit !== undefined && Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE) + : undefined; + const parsedOffset = + rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined; + const cacheKey = buildDrawingsCacheKey({ userId: req.user.id, searchTerm: searchTerm ?? "", collectionFilter: collectionFilterKey, includeData: shouldIncludeData, - }); + }) + `:${parsedLimit}:${parsedOffset}`; const cachedBody = getCachedDrawingsBody(cacheKey); if (cachedBody) { @@ -959,11 +991,21 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { orderBy: { updatedAt: "desc" }, }; + if (parsedLimit !== undefined) { + queryOptions.take = parsedLimit; + } + if (parsedOffset !== undefined) { + queryOptions.skip = parsedOffset; + } + if (!shouldIncludeData) { queryOptions.select = summarySelect; } - const drawings = await prisma.drawing.findMany(queryOptions); + const [drawings, totalCount] = await Promise.all([ + prisma.drawing.findMany(queryOptions), + prisma.drawing.count({ where }) + ]); type DrawingResponse = Prisma.DrawingGetPayload; type DrawingWithParsedData = Omit & { @@ -972,10 +1014,10 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { files: Record; }; - let responsePayload: DrawingResponse[] | DrawingWithParsedData[] = drawings; + let responsePayload: any[] = drawings; if (shouldIncludeData) { - responsePayload = drawings.map((d): DrawingWithParsedData => ({ + responsePayload = drawings.map((d: any): DrawingWithParsedData => ({ ...d, elements: parseJsonField(d.elements, []), appState: parseJsonField(d.appState, {}), @@ -983,7 +1025,14 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { })); } - const body = cacheDrawingsResponse(cacheKey, responsePayload); + const finalResponse = { + drawings: responsePayload, + totalCount, + limit: parsedLimit, + offset: parsedOffset + }; + + const body = cacheDrawingsResponse(cacheKey, finalResponse); res.setHeader("X-Cache", "MISS"); res.setHeader("Content-Type", "application/json"); return res.send(body); @@ -996,16 +1045,29 @@ app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res, next) => { const { id } = req.params; console.log("[API] Fetching drawing", { id, userId: req.user.id }); - const drawing = await prisma.drawing.findFirst({ - where: { - id, - userId: req.user.id, // Ensure user owns the drawing - }, + const drawing = await prisma.drawing.findUnique({ + where: { id }, }); if (!drawing) { console.warn("[API] Drawing not found", { id, userId: req.user.id }); - return res.status(404).json({ error: "Drawing not found" }); + return res.status(404).json({ + error: "Drawing not found", + message: "Drawing does not exist", + }); + } + + if (drawing.userId !== req.user.id) { + console.warn("[API] Drawing access denied", { + id, + requestedBy: req.user.id, + ownerId: drawing.userId, + }); + return res.status(403).json({ + error: "Forbidden", + code: "DRAWING_ACCESS_DENIED", + message: "You do not have access to this drawing", + }); } res.json({ @@ -1054,7 +1116,7 @@ app.post("/drawings", requireAuth, asyncHandler(async (req, res, next) => { } } else if (targetCollectionId === "trash") { // Ensure trash collection exists for this user - await ensureTrashCollection(req.user.id); + await ensureTrashCollection(prisma, req.user.id); } const newDrawing = await prisma.drawing.create({ @@ -1124,7 +1186,7 @@ app.put("/drawings/:id", requireAuth, asyncHandler(async (req, res, next) => { if (payload.collectionId !== undefined) { // Special handling for trash collection - ensure it exists first if (payload.collectionId === "trash") { - await ensureTrashCollection(req.user.id); + await ensureTrashCollection(prisma, req.user.id); (data as Prisma.DrawingUncheckedUpdateInput).collectionId = "trash"; } else if (payload.collectionId) { // Verify collection belongs to user if provided @@ -1429,6 +1491,45 @@ const excalidashManifestSchemaV1 = z.object({ ), }); +class ImportValidationError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "ImportValidationError"; + this.status = status; + } +} + +const getZipEntries = (zip: JSZip) => Object.values(zip.files).filter((entry) => !entry.dir); + +const normalizeArchivePath = (filePath: string): string => { + return path.posix.normalize(filePath.replace(/\\/g, "/")); +}; + +const assertSafeArchivePath = (filePath: string) => { + const normalized = normalizeArchivePath(filePath); + if ( + normalized.length === 0 || + path.posix.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith("../") || + normalized.includes("\0") + ) { + throw new ImportValidationError(`Unsafe archive path: ${filePath}`); + } +}; + +const assertSafeZipArchive = (zip: JSZip) => { + const entries = getZipEntries(zip); + if (entries.length > MAX_IMPORT_ARCHIVE_ENTRIES) { + throw new ImportValidationError("Archive contains too many files"); + } + for (const entry of entries) { + assertSafeArchivePath(entry.name); + } +}; + app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res, next) => { if (!req.user) { return res.status(401).json({ error: "Unauthorized" }); @@ -1582,6 +1683,15 @@ app.post("/import/excalidash/verify", requireAuth, upload.single("archive"), asy try { const buffer = await fsPromises.readFile(stagedPath); const zip = await JSZip.loadAsync(buffer); + try { + assertSafeZipArchive(zip); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + const manifestFile = zip.file("excalidash.manifest.json"); if (!manifestFile) { return res.status(400).json({ @@ -1590,6 +1700,12 @@ app.post("/import/excalidash/verify", requireAuth, upload.single("archive"), asy }); } const rawManifest = await manifestFile.async("string"); + if (Buffer.byteLength(rawManifest, "utf8") > MAX_IMPORT_MANIFEST_BYTES) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is too large", + }); + } let manifestJson: unknown; try { manifestJson = JSON.parse(rawManifest); @@ -1608,6 +1724,28 @@ app.post("/import/excalidash/verify", requireAuth, upload.single("archive"), asy } const manifest = parsed.data; + if (manifest.collections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (manifest.drawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + for (const drawing of manifest.drawings) { + assertSafeArchivePath(drawing.filePath); + if (!zip.file(normalizeArchivePath(drawing.filePath))) { + return res.status(400).json({ + error: "Invalid backup", + message: `Missing drawing file: ${drawing.filePath}`, + }); + } + } + res.json({ valid: true, formatVersion: manifest.formatVersion, @@ -1634,6 +1772,15 @@ app.post("/import/excalidash", requireAuth, upload.single("archive"), asyncHandl try { const buffer = await fsPromises.readFile(stagedPath); const zip = await JSZip.loadAsync(buffer); + try { + assertSafeZipArchive(zip); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + const manifestFile = zip.file("excalidash.manifest.json"); if (!manifestFile) { return res.status(400).json({ @@ -1643,6 +1790,12 @@ app.post("/import/excalidash", requireAuth, upload.single("archive"), asyncHandl } const rawManifest = await manifestFile.async("string"); + if (Buffer.byteLength(rawManifest, "utf8") > MAX_IMPORT_MANIFEST_BYTES) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is too large", + }); + } let manifestJson: unknown; try { manifestJson = JSON.parse(rawManifest); @@ -1661,172 +1814,233 @@ app.post("/import/excalidash", requireAuth, upload.single("archive"), asyncHandl } const manifest = parsed.data; + if (manifest.collections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (manifest.drawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } - const collectionIdMap = new Map(); - let collectionsCreated = 0; - let collectionsUpdated = 0; - let collectionIdConflicts = 0; + type PreparedImportDrawing = { + id: string; + name: string; + version: number | undefined; + collectionId: string | null; + sanitized: ReturnType; + }; - for (const c of manifest.collections) { - if (c.id === "trash") { - collectionIdMap.set("trash", "trash"); - continue; + const preparedDrawings: PreparedImportDrawing[] = []; + let extractedBytes = Buffer.byteLength(rawManifest, "utf8"); + try { + for (const d of manifest.drawings) { + assertSafeArchivePath(d.filePath); + const entry = zip.file(normalizeArchivePath(d.filePath)); + if (!entry) { + throw new ImportValidationError(`Missing drawing file: ${d.filePath}`); + } + + const raw = await entry.async("string"); + const rawSize = Buffer.byteLength(raw, "utf8"); + if (rawSize > MAX_IMPORT_DRAWING_BYTES) { + throw new ImportValidationError(`Drawing is too large: ${d.filePath}`); + } + extractedBytes += rawSize; + if (extractedBytes > MAX_IMPORT_TOTAL_EXTRACTED_BYTES) { + throw new ImportValidationError("Backup contents exceed maximum import size"); + } + + let parsedJson: any; + try { + parsedJson = JSON.parse(raw) as any; + } catch { + throw new ImportValidationError(`Drawing JSON is invalid: ${d.filePath}`); + } + + const elements = Array.isArray(parsedJson?.elements) ? parsedJson.elements : []; + const appState = + typeof parsedJson?.appState === "object" && parsedJson.appState !== null + ? parsedJson.appState + : {}; + const files = + typeof parsedJson?.files === "object" && parsedJson.files !== null + ? parsedJson.files + : {}; + + const imported = { + name: d.name, + elements, + appState, + files, + preview: null as string | null, + collectionId: d.collectionId, + }; + + if (!validateImportedDrawing(imported)) { + throw new ImportValidationError(`Drawing failed validation: ${d.filePath}`); + } + + preparedDrawings.push({ + id: d.id, + name: sanitizeText(imported.name, 255) || "Untitled Drawing", + version: typeof d.version === "number" ? d.version : undefined, + collectionId: d.collectionId, + sanitized: sanitizeDrawingData(imported), + }); + } + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + + const result = await prisma.$transaction(async (tx) => { + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + const needsTrash = + manifest.collections.some((c) => c.id === "trash") || + preparedDrawings.some((d) => d.collectionId === "trash"); + if (needsTrash) { + await ensureTrashCollection(tx, req.user.id); } - const existing = await prisma.collection.findUnique({ where: { id: c.id } }); - if (!existing) { - await prisma.collection.create({ + for (const c of manifest.collections) { + if (c.id === "trash") { + collectionIdMap.set("trash", "trash"); + continue; + } + + const existing = await tx.collection.findUnique({ where: { id: c.id } }); + if (!existing) { + await tx.collection.create({ + data: { + id: c.id, + name: sanitizeText(c.name, 100) || "Collection", + userId: req.user.id, + }, + }); + collectionIdMap.set(c.id, c.id); + collectionsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await tx.collection.update({ + where: { id: c.id }, + data: { name: sanitizeText(c.name, 100) || "Collection" }, + }); + collectionIdMap.set(c.id, c.id); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.collection.create({ data: { - id: c.id, - name: c.name, + id: newId, + name: sanitizeText(c.name, 100) || "Collection", userId: req.user.id, }, }); - collectionIdMap.set(c.id, c.id); + collectionIdMap.set(c.id, newId); collectionsCreated += 1; - continue; + collectionIdConflicts += 1; } - if (existing.userId === req.user.id) { - await prisma.collection.update({ - where: { id: c.id }, - data: { name: c.name }, - }); - collectionIdMap.set(c.id, c.id); - collectionsUpdated += 1; - continue; - } - - const newId = uuidv4(); - await prisma.collection.create({ - data: { - id: newId, - name: c.name, - userId: req.user.id, - }, - }); - collectionIdMap.set(c.id, newId); - collectionsCreated += 1; - collectionIdConflicts += 1; - } - - const resolveCollectionId = async (collectionId: string | null): Promise => { - if (!collectionId) return null; - if (collectionId === "trash") { - await ensureTrashCollection(req.user!.id); - return "trash"; - } - return collectionIdMap.get(collectionId) || null; - }; - - let drawingsCreated = 0; - let drawingsUpdated = 0; - let drawingIdConflicts = 0; - - for (const d of manifest.drawings) { - const entry = zip.file(d.filePath); - if (!entry) { - return res.status(400).json({ - error: "Invalid backup", - message: `Missing drawing file: ${d.filePath}`, - }); - } - - const raw = await entry.async("string"); - const parsedJson = JSON.parse(raw) as any; - - const elements = Array.isArray(parsedJson?.elements) ? parsedJson.elements : []; - const appState = typeof parsedJson?.appState === "object" && parsedJson.appState !== null ? parsedJson.appState : {}; - const files = typeof parsedJson?.files === "object" && parsedJson.files !== null ? parsedJson.files : {}; - - const imported = { - name: d.name, - elements, - appState, - files, - preview: null as string | null, - collectionId: await resolveCollectionId(d.collectionId), + const resolveCollectionId = (collectionId: string | null): string | null => { + if (!collectionId) return null; + if (collectionId === "trash") return "trash"; + return collectionIdMap.get(collectionId) || null; }; - if (!validateImportedDrawing(imported)) { - return res.status(400).json({ - error: "Invalid imported drawing", - message: `Drawing failed validation: ${d.filePath}`, - }); - } + for (const prepared of preparedDrawings) { + const targetCollectionId = resolveCollectionId(prepared.collectionId); + const existing = await tx.drawing.findUnique({ where: { id: prepared.id } }); + if (!existing) { + await tx.drawing.create({ + data: { + id: prepared.id, + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? 1, + userId: req.user.id, + collectionId: targetCollectionId, + }, + }); + drawingsCreated += 1; + continue; + } - const sanitized = sanitizeDrawingData(imported); - const targetCollectionId = imported.collectionId; + if (existing.userId === req.user.id) { + await tx.drawing.update({ + where: { id: prepared.id }, + data: { + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? existing.version, + collectionId: targetCollectionId, + }, + }); + drawingsUpdated += 1; + continue; + } - const existing = await prisma.drawing.findUnique({ where: { id: d.id } }); - if (!existing) { - await prisma.drawing.create({ + const newId = uuidv4(); + await tx.drawing.create({ data: { - id: d.id, - name: sanitizeText(imported.name, 255) || "Untitled Drawing", - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: typeof d.version === "number" ? d.version : 1, + id: newId, + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? 1, userId: req.user.id, collectionId: targetCollectionId, }, }); drawingsCreated += 1; - continue; + drawingIdConflicts += 1; } - if (existing.userId === req.user.id) { - await prisma.drawing.update({ - where: { id: d.id }, - data: { - name: sanitizeText(imported.name, 255) || "Untitled Drawing", - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: typeof d.version === "number" ? d.version : existing.version, - collectionId: targetCollectionId, - }, - }); - drawingsUpdated += 1; - continue; - } - - const newId = uuidv4(); - await prisma.drawing.create({ - data: { - id: newId, - name: sanitizeText(imported.name, 255) || "Untitled Drawing", - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: typeof d.version === "number" ? d.version : 1, - userId: req.user.id, - collectionId: targetCollectionId, + return { + collections: { + created: collectionsCreated, + updated: collectionsUpdated, + idConflicts: collectionIdConflicts, }, - }); - drawingsCreated += 1; - drawingIdConflicts += 1; - } + drawings: { + created: drawingsCreated, + updated: drawingsUpdated, + idConflicts: drawingIdConflicts, + }, + }; + }); invalidateDrawingsCache(); res.json({ success: true, message: "Backup imported successfully", - collections: { - created: collectionsCreated, - updated: collectionsUpdated, - idConflicts: collectionIdConflicts, - }, - drawings: { - created: drawingsCreated, - updated: drawingsUpdated, - idConflicts: drawingIdConflicts, - }, + ...result, }); } finally { await removeFileIfExists(stagedPath); @@ -1930,6 +2144,18 @@ app.post("/import/sqlite/legacy/verify", requireAuth, upload.single("db"), async const collectionsCount = collectionTable ? Number(db.prepare(`SELECT COUNT(1) as c FROM "${collectionTable}"`).get()?.c ?? 0) : 0; + if (drawingsCount > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + if (collectionsCount > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } let latestMigration: string | null = null; const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]); @@ -2005,93 +2231,42 @@ app.post("/import/sqlite/legacy", requireAuth, upload.single("db"), asyncHandler : []; const importedDrawings: any[] = legacyDb.prepare(`SELECT * FROM "${drawingTable}"`).all(); - const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash"); - if (hasTrash) { - await ensureTrashCollection(req.user.id); - } - - const collectionIdMap = new Map(); - let collectionsCreated = 0; - let collectionsUpdated = 0; - let collectionIdConflicts = 0; - - for (const c of importedCollections) { - const importedId = typeof c.id === "string" ? c.id : null; - const name = typeof c.name === "string" ? c.name : "Collection"; - - if (importedId === "trash" || name === "Trash") { - collectionIdMap.set(importedId || "trash", "trash"); - continue; - } - - if (!importedId) { - const newId = uuidv4(); - await prisma.collection.create({ - data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, - }); - collectionIdMap.set(`__name:${name}`, newId); - collectionsCreated += 1; - continue; - } - - const existing = await prisma.collection.findUnique({ where: { id: importedId } }); - if (!existing) { - await prisma.collection.create({ - data: { id: importedId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, - }); - collectionIdMap.set(importedId, importedId); - collectionsCreated += 1; - continue; - } - - if (existing.userId === req.user.id) { - await prisma.collection.update({ - where: { id: importedId }, - data: { name: sanitizeText(name, 100) || "Collection" }, - }); - collectionIdMap.set(importedId, importedId); - collectionsUpdated += 1; - continue; - } - - const newId = uuidv4(); - await prisma.collection.create({ - data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + if (importedCollections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (importedDrawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, }); - collectionIdMap.set(importedId, newId); - collectionsCreated += 1; - collectionIdConflicts += 1; } - const resolveImportedCollectionId = (rawCollectionId: unknown, rawCollectionName: unknown): string | null => { - const id = typeof rawCollectionId === "string" ? rawCollectionId : null; - const name = typeof rawCollectionName === "string" ? rawCollectionName : null; - - if (id === "trash" || name === "Trash") return "trash"; - if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!; - if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!; - return null; + type PreparedLegacyDrawing = { + importedId: string | null; + name: string; + sanitized: ReturnType; + collectionIdRaw: unknown; + collectionNameRaw: unknown; + versionRaw: unknown; }; - let drawingsCreated = 0; - let drawingsUpdated = 0; - let drawingIdConflicts = 0; - + const preparedDrawings: PreparedLegacyDrawing[] = []; for (const d of importedDrawings) { - const importedId = typeof d.id === "string" ? d.id : null; - const elements = parseOptionalJson(d.elements, []); const appState = parseOptionalJson>(d.appState, {}); const files = parseOptionalJson>(d.files, {}); const preview = typeof d.preview === "string" ? d.preview : null; - + const name = typeof d.name === "string" ? d.name : "Untitled Drawing"; const importPayload = { - name: typeof d.name === "string" ? d.name : "Untitled Drawing", + name, elements, appState, files, preview, - collectionId: resolveImportedCollectionId(d.collectionId, d.collectionName), + collectionId: null as string | null, }; if (!validateImportedDrawing(importPayload)) { @@ -2101,71 +2276,160 @@ app.post("/import/sqlite/legacy", requireAuth, upload.single("db"), asyncHandler }); } - const sanitized = sanitizeDrawingData(importPayload); - const drawingName = sanitizeText(importPayload.name, 255) || "Untitled Drawing"; + preparedDrawings.push({ + importedId: typeof d.id === "string" ? d.id : null, + name: sanitizeText(name, 255) || "Untitled Drawing", + sanitized: sanitizeDrawingData(importPayload), + collectionIdRaw: d.collectionId, + collectionNameRaw: d.collectionName, + versionRaw: d.version, + }); + } - const existing = importedId ? await prisma.drawing.findUnique({ where: { id: importedId } }) : null; + const result = await prisma.$transaction(async (tx) => { + const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash"); + if (hasTrash) { + await ensureTrashCollection(tx, req.user.id); + } - if (!existing) { - const idToUse = importedId || uuidv4(); - await prisma.drawing.create({ + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + for (const c of importedCollections) { + const importedId = typeof c.id === "string" ? c.id : null; + const name = typeof c.name === "string" ? c.name : "Collection"; + + if (importedId === "trash" || name === "Trash") { + collectionIdMap.set(importedId || "trash", "trash"); + continue; + } + + if (!importedId) { + const newId = uuidv4(); + await tx.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(`__name:${name}`, newId); + collectionsCreated += 1; + continue; + } + + const existing = await tx.collection.findUnique({ where: { id: importedId } }); + if (!existing) { + await tx.collection.create({ + data: { id: importedId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(importedId, importedId); + collectionsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await tx.collection.update({ + where: { id: importedId }, + data: { name: sanitizeText(name, 100) || "Collection" }, + }); + collectionIdMap.set(importedId, importedId); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(importedId, newId); + collectionsCreated += 1; + collectionIdConflicts += 1; + } + + const resolveImportedCollectionId = ( + rawCollectionId: unknown, + rawCollectionName: unknown + ): string | null => { + const id = typeof rawCollectionId === "string" ? rawCollectionId : null; + const name = typeof rawCollectionName === "string" ? rawCollectionName : null; + + if (id === "trash" || name === "Trash") return "trash"; + if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!; + if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!; + return null; + }; + + for (const d of preparedDrawings) { + const resolvedCollectionId = resolveImportedCollectionId(d.collectionIdRaw, d.collectionNameRaw); + const existing = d.importedId ? await tx.drawing.findUnique({ where: { id: d.importedId } }) : null; + + if (!existing) { + const idToUse = d.importedId || uuidv4(); + await tx.drawing.create({ + data: { + id: idToUse, + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1, + userId: req.user.id, + collectionId: resolvedCollectionId ?? null, + }, + }); + drawingsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await tx.drawing.update({ + where: { id: existing.id }, + data: { + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : existing.version, + collectionId: resolvedCollectionId ?? null, + }, + }); + drawingsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.drawing.create({ data: { - id: idToUse, - name: drawingName, - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: Number.isFinite(Number(d.version)) ? Number(d.version) : 1, + id: newId, + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1, userId: req.user.id, - collectionId: importPayload.collectionId ?? null, + collectionId: resolvedCollectionId ?? null, }, }); drawingsCreated += 1; - continue; + drawingIdConflicts += 1; } - if (existing.userId === req.user.id) { - await prisma.drawing.update({ - where: { id: existing.id }, - data: { - name: drawingName, - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: Number.isFinite(Number(d.version)) ? Number(d.version) : existing.version, - collectionId: importPayload.collectionId ?? null, - }, - }); - drawingsUpdated += 1; - continue; - } - - const newId = uuidv4(); - await prisma.drawing.create({ - data: { - id: newId, - name: drawingName, - elements: JSON.stringify(sanitized.elements), - appState: JSON.stringify(sanitized.appState), - files: JSON.stringify(sanitized.files || {}), - preview: sanitized.preview ?? null, - version: Number.isFinite(Number(d.version)) ? Number(d.version) : 1, - userId: req.user.id, - collectionId: importPayload.collectionId ?? null, - }, - }); - drawingsCreated += 1; - drawingIdConflicts += 1; - } + return { + collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts }, + drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts }, + }; + }); invalidateDrawingsCache(); res.json({ success: true, - collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts }, - drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts }, + ...result, }); } catch (_error) { return res.status(500).json({ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2121f33..f4dddaa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 466cfcf..20d06a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d51aab1..61552ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,26 @@ +import { Suspense, lazy } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { Dashboard } from './pages/Dashboard'; -import { Editor } from './pages/Editor'; -import { Settings } from './pages/Settings'; -import { Profile } from './pages/Profile'; -import { Admin } from './pages/Admin'; -import { Login } from './pages/Login'; -import { Register } from './pages/Register'; -import { PasswordResetRequest } from './pages/PasswordResetRequest'; -import { PasswordResetConfirm } from './pages/PasswordResetConfirm'; import { ThemeProvider } from './context/ThemeContext'; import { UploadProvider } from './context/UploadContext'; import { AuthProvider } from './context/AuthContext'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { Loader2 } from 'lucide-react'; + +const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard }))); +const Editor = lazy(() => import('./pages/Editor').then(m => ({ default: m.Editor }))); +const Settings = lazy(() => import('./pages/Settings').then(m => ({ default: m.Settings }))); +const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))); +const Admin = lazy(() => import('./pages/Admin').then(m => ({ default: m.Admin }))); +const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login }))); +const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register }))); +const PasswordResetRequest = lazy(() => import('./pages/PasswordResetRequest').then(m => ({ default: m.PasswordResetRequest }))); +const PasswordResetConfirm = lazy(() => import('./pages/PasswordResetConfirm').then(m => ({ default: m.PasswordResetConfirm }))); + +const PageLoader = () => ( +
+ +
+); function App() { return ( @@ -19,61 +28,63 @@ function App() { - - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - + }> + + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a2936b6..6a022ae 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -218,33 +218,50 @@ const deserializeDrawing = (drawing: unknown): Drawing => { return deserializeTimestamps(drawing as HasTimestamps & Drawing); }; +export interface PaginatedDrawings { + drawings: T[]; + totalCount: number; + limit?: number; + offset?: number; +} + export function getDrawings( search?: string, - collectionId?: string | null -): Promise; + collectionId?: string | null, + options?: { limit?: number; offset?: number } +): Promise>; export function getDrawings( search: string | undefined, collectionId: string | null | undefined, - options: { includeData: true } -): Promise; + options: { includeData: true; limit?: number; offset?: number } +): Promise>; export async function getDrawings( search?: string, collectionId?: string | null, - options?: { includeData?: boolean } + options?: { includeData?: boolean; limit?: number; offset?: number } ) { - const params: Record = {}; + const params: Record = {}; if (search) params.search = search; if (collectionId !== undefined) params.collectionId = collectionId === null ? "null" : collectionId; + if (options?.limit !== undefined) params.limit = options.limit; + if (options?.offset !== undefined) params.offset = options.offset; + if (options?.includeData) { params.includeData = "true"; - const response = await api.get("/drawings", { params }); - return response.data.map(deserializeDrawing); + const response = await api.get>("/drawings", { params }); + return { + ...response.data, + drawings: response.data.drawings.map(deserializeDrawing) + }; } - const response = await api.get("/drawings", { params }); - return response.data.map(deserializeDrawingSummary); + const response = await api.get>("/drawings", { params }); + return { + ...response.data, + drawings: response.data.drawings.map(deserializeDrawingSummary) + }; } export const getDrawing = async (id: string) => { diff --git a/frontend/src/components/DrawingCard.tsx b/frontend/src/components/DrawingCard.tsx index fe0f641..6ed873e 100644 --- a/frontend/src/components/DrawingCard.tsx +++ b/frontend/src/components/DrawingCard.tsx @@ -5,7 +5,7 @@ import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, import type { DrawingSummary, Collection, Drawing } from '../types'; import { formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; -import { exportToSvg } from "@excalidraw/excalidraw"; +// import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead import { exportDrawingToFile } from '../utils/exportUtils'; import * as api from '../api'; @@ -112,6 +112,11 @@ export const DrawingCard: React.FC = ({ if (cancelled) return; if (!data?.elements || !data?.appState) return; + // Lazy load exportToSvg to keep the main bundle small + const { exportToSvg } = await import("@excalidraw/excalidraw"); + + if (cancelled) return; + const svg = await exportToSvg({ elements: data.elements, appState: { @@ -243,18 +248,18 @@ export const DrawingCard: React.FC = ({ {previewSvg ? (
) : ( -
- +
+
)}
{/* Footer */} -
+
{isRenaming ? (
= ({ onBlur={() => setIsRenaming(false)} onDragStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} - className="w-full px-2 py-1 -ml-2 text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800" + className="w-full px-2 py-1 -ml-2 text-sm sm:text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800" />
) : (

{ e.stopPropagation(); @@ -285,9 +290,9 @@ export const DrawingCard: React.FC = ({ {drawing.name}

)} -
-

- +

+

+ {formatDistanceToNow(drawing.updatedAt)} ago

@@ -451,4 +456,3 @@ export const DrawingCard: React.FC = ({ ); }; - diff --git a/frontend/src/components/FingerprintAvatar.tsx b/frontend/src/components/FingerprintAvatar.tsx index 92ea1cb..2e5ee33 100644 --- a/frontend/src/components/FingerprintAvatar.tsx +++ b/frontend/src/components/FingerprintAvatar.tsx @@ -1,20 +1,5 @@ import React, { useMemo, useState } from 'react'; - -const DEVICE_ID_KEY = 'excalidash-device-id'; - -const getOrCreateDeviceId = (): string => { - if (typeof window === 'undefined') return 'server'; - const existing = localStorage.getItem(DEVICE_ID_KEY); - if (existing) return existing; - - const generated = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - - localStorage.setItem(DEVICE_ID_KEY, generated); - return generated; -}; +import { getOrCreateBrowserFingerprint, getFingerprintInitials } from '../utils/identity'; const fnv1a = (input: string): number => { let hash = 0x811c9dc5; @@ -27,99 +12,38 @@ const fnv1a = (input: string): number => { const toHsl = (n: number) => { const hue = n % 360; - const sat = 60 + (n % 20); - const light = 45 + (n % 10); + const sat = 55 + (n % 20); + const light = 42 + (n % 12); return `hsl(${hue} ${sat}% ${light}%)`; }; -const buildPattern = (seed: string) => { - let x = fnv1a(seed); - const nextBit = () => { - // xorshift32 - x ^= x << 13; - x ^= x >>> 17; - x ^= x << 5; - return (x >>> 0) & 1; - }; - - const cells: boolean[][] = Array.from({ length: 5 }, () => Array.from({ length: 5 }, () => false)); - - // Generate left 3 columns, mirror to 5. - for (let row = 0; row < 5; row += 1) { - for (let col = 0; col < 3; col += 1) { - const on = nextBit() === 1; - cells[row][col] = on; - cells[row][4 - col] = on; - } - } - - const foreground = toHsl(x); - const background = 'hsl(0 0% 98%)'; - const backgroundDark = 'hsl(0 0% 12%)'; - - return { cells, foreground, background, backgroundDark }; -}; - export const FingerprintAvatar: React.FC<{ size?: number; seed?: string; title?: string; className?: string; -}> = ({ size = 32, seed, title = 'Device fingerprint', className }) => { - const [deviceId] = useState(() => getOrCreateDeviceId()); +}> = ({ size = 32, seed, title = 'Browser fingerprint avatar', className }) => { + const [deviceId] = useState(() => getOrCreateBrowserFingerprint()); const effectiveSeed = seed || deviceId; - const { cells, foreground, background, backgroundDark } = useMemo( - () => buildPattern(effectiveSeed), - [effectiveSeed] - ); - - const padding = 0.5; - const viewBox = `${-padding} ${-padding} ${5 + padding * 2} ${5 + padding * 2}`; + const initials = useMemo(() => getFingerprintInitials(effectiveSeed), [effectiveSeed]); + const background = useMemo(() => toHsl(fnv1a(effectiveSeed)), [effectiveSeed]); return ( - - {title} - - - {cells.map((row, r) => - row.map((on, c) => - on ? : null - ) - )} - - +
+ {initials} +
+
); }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 889625d..76c43ae 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,7 +6,6 @@ import clsx from 'clsx'; import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; import { useAuth } from '../context/AuthContext'; -import { FingerprintAvatar } from './FingerprintAvatar'; import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation'; interface SidebarProps { @@ -112,6 +111,16 @@ 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 = ({ @@ -374,14 +383,16 @@ export const Sidebar: React.FC = ({
)} {user && ( -
-
- - -
+
+
+
+ {getInitialsFromName(user.name)} +
+
{user.name}
{user.email}
+
)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 84c4f52..a9ecd18 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -41,12 +41,16 @@ const DragOverlayPortal: React.FC<{ children: React.ReactNode }> = ({ children } return createPortal(children, document.body); }; +const PAGE_SIZE = 24; + export const Dashboard: React.FC = () => { const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const [drawings, setDrawings] = useState([]); const [collections, setCollections] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isFetchingMore, setIsFetchingMore] = useState(false); const selectedCollectionId = React.useMemo(() => { if (location.pathname === '/') return undefined; @@ -85,6 +89,7 @@ export const Dashboard: React.FC = () => { const [dragCurrent, setDragCurrent] = useState(null); const [potentialDragId, setPotentialDragId] = useState(null); const containerRef = useRef(null); + const loaderRef = useRef(null); type SortField = 'name' | 'createdAt' | 'updatedAt'; type SortDirection = 'asc' | 'desc'; @@ -101,14 +106,17 @@ export const Dashboard: React.FC = () => { const { uploadFiles } = useUpload(); + const hasMore = drawings.length < totalCount; + const refreshData = useCallback(async () => { setIsLoading(true); try { - const [drawingsData, collectionsData] = await Promise.all([ - api.getDrawings(debouncedSearch, selectedCollectionId), + const [drawingsRes, collectionsData] = await Promise.all([ + api.getDrawings(debouncedSearch, selectedCollectionId, { limit: PAGE_SIZE, offset: 0 }), api.getCollections() ]); - setDrawings(drawingsData); + setDrawings(drawingsRes.drawings); + setTotalCount(drawingsRes.totalCount); setCollections(collectionsData); setSelectedIds(new Set()); } catch (err) { @@ -118,10 +126,45 @@ export const Dashboard: React.FC = () => { } }, [debouncedSearch, selectedCollectionId]); + const fetchMore = useCallback(async () => { + if (isFetchingMore || !hasMore || isLoading) return; + setIsFetchingMore(true); + try { + const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, { + limit: PAGE_SIZE, + offset: drawings.length + }); + setDrawings(prev => [...prev, ...drawingsRes.drawings]); + setTotalCount(drawingsRes.totalCount); + } catch (err) { + console.error('Failed to fetch more data:', err); + } finally { + setIsFetchingMore(false); + } + }, [isFetchingMore, hasMore, isLoading, debouncedSearch, selectedCollectionId, drawings.length]); + useEffect(() => { refreshData(); }, [refreshData]); + // Infinite scroll observer + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore) { + fetchMore(); + } + }, + { threshold: 0.1 } + ); + + if (loaderRef.current) { + observer.observe(loaderRef.current); + } + + return () => observer.disconnect(); + }, [fetchMore, hasMore]); + const [isDraggingFile, setIsDraggingFile] = useState(false); const dragCounter = useRef(0); @@ -324,7 +367,13 @@ export const Dashboard: React.FC = () => { const trashId = 'trash'; // Optimistic Remove from current view - setDrawings(prev => prev.filter(d => d.id !== id)); + setDrawings(prev => { + const next = prev.filter(d => d.id !== id); + if (next.length !== prev.length) { + setTotalCount(t => t - 1); + } + return next; + }); setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); try { @@ -337,7 +386,13 @@ export const Dashboard: React.FC = () => { }; const executePermanentDelete = async (id: string) => { - setDrawings(prev => prev.filter(d => d.id !== id)); + setDrawings(prev => { + const next = prev.filter(d => d.id !== id); + if (next.length !== prev.length) { + setTotalCount(t => t - 1); + } + return next; + }); setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); setDrawingToDelete(null); // Close modal immediately @@ -393,7 +448,11 @@ export const Dashboard: React.FC = () => { const trashId = 'trash'; const ids = Array.from(selectedIds); - setDrawings(prev => prev.filter(d => !selectedIds.has(d.id))); + setDrawings(prev => { + const next = prev.filter(d => !selectedIds.has(d.id)); + setTotalCount(t => t - (prev.length - next.length)); + return next; + }); setSelectedIds(new Set()); try { @@ -406,7 +465,11 @@ export const Dashboard: React.FC = () => { const executeBulkPermanentDelete = async () => { const ids = Array.from(selectedIds); - setDrawings(prev => prev.filter(d => !selectedIds.has(d.id))); + setDrawings(prev => { + const next = prev.filter(d => !selectedIds.has(d.id)); + setTotalCount(t => t - (prev.length - next.length)); + return next; + }); setSelectedIds(new Set()); setShowBulkDeleteConfirm(false); @@ -427,10 +490,12 @@ export const Dashboard: React.FC = () => { setDrawings(prev => { const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d); if (selectedCollectionId === undefined) return updated; - return updated.filter(d => { + const next = updated.filter(d => { if (selectedCollectionId === null) return d.collectionId === null; return d.collectionId === selectedCollectionId; }); + setTotalCount(t => t - (prev.length - next.length)); + return next; }); setSelectedIds(new Set()); // Clear selection after move setShowBulkMoveMenu(false); @@ -467,12 +532,16 @@ export const Dashboard: React.FC = () => { const handleMoveToCollection = async (id: string, collectionId: string | null) => { setDrawings(prev => { - return prev.map(d => d.id === id ? { ...d, collectionId } : d) - .filter(d => { - if (selectedCollectionId === undefined) return true; - if (selectedCollectionId === null) return d.collectionId === null; - return d.collectionId === selectedCollectionId; - }); + const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d); + const next = updated.filter(d => { + if (selectedCollectionId === undefined) return true; + if (selectedCollectionId === null) return d.collectionId === null; + return d.collectionId === selectedCollectionId; + }); + if (next.length !== prev.length) { + setTotalCount(t => t - 1); + } + return next; }); try { await api.updateDrawing(id, { collectionId }); @@ -567,10 +636,12 @@ export const Dashboard: React.FC = () => { setDrawings(prev => { const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d); if (selectedCollectionId === undefined) return updated; - return updated.filter(d => { + const next = updated.filter(d => { if (selectedCollectionId === null) return d.collectionId === null; return d.collectionId === selectedCollectionId; }); + setTotalCount(t => t - (prev.length - next.length)); + return next; }); // Clear selection if we moved selected items @@ -678,8 +749,8 @@ export const Dashboard: React.FC = () => { {viewTitle} -
-
+
+
{
-
+
- {collections.filter(c => c.name !== 'Trash').map(c => ( + {collections.filter(c => c.id !== 'trash').map(c => (
) : ( -
+
{sortedDrawings.length === 0 ? (
@@ -983,6 +1057,16 @@ export const Dashboard: React.FC = () => { )}
)} + + {/* Infinite Scroll Trigger */} +
+ {isFetchingMore && ( +
+ + Loading more... +
+ )} +
{ - const parts = name.trim().split(/\s+/); + 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 `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); } - return name.substring(0, 2).toUpperCase().padEnd(2, name[0] || 'U'); + return trimmed.slice(0, 2).toUpperCase(); }; // Helper function to generate a color from a string (consistent hash) @@ -118,6 +119,7 @@ export const Editor: React.FC = () => { const [newName, setNewName] = useState(''); const [initialData, setInitialData] = useState(null); const [isSceneLoading, setIsSceneLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [isSavingOnLeave, setIsSavingOnLeave] = useState(false); const [isHeaderVisible, setIsHeaderVisible] = useState(true); const [autoHideEnabled, setAutoHideEnabled] = useState(true); @@ -326,7 +328,6 @@ export const Editor: React.FC = () => { button: data.button || 'up', selectedElementIds: data.selectedElementIds || {}, username: data.username, - avatarUrl: data.avatarUrl, color: { background: data.color, stroke: data.color }, id: data.userId, }); @@ -664,6 +665,7 @@ export const Editor: React.FC = () => { excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); + setLoadError(null); setInitialData(null); const loadData = async () => { @@ -712,11 +714,26 @@ export const Editor: React.FC = () => { }); } catch (err) { console.error('Failed to load drawing', err); - toast.error("Failed to load drawing"); + let message = "Failed to load drawing"; + if (api.isAxiosError(err)) { + const responseMessage = + typeof err.response?.data?.message === "string" + ? err.response.data.message + : null; + if (responseMessage) { + message = responseMessage; + } else if (err.response?.status === 403) { + message = "You do not have access to this drawing"; + } else if (err.response?.status === 404) { + message = "Drawing not found"; + } + } + toast.error(message); latestElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; - setInitialData(buildEmptyScene()); + setLoadError(message); + setInitialData(null); } finally { setIsSceneLoading(false); } @@ -1014,7 +1031,24 @@ export const Editor: React.FC = () => { marginTop: isHeaderVisible ? '3.5rem' : '0' }} > - {initialData ? ( + {loadError ? ( +
+
+

+ Unable to open drawing +

+

+ {loadError} +

+
+ +
+ ) : initialData ? ( { Exports an `.excalidash` archive (zip) organized by collections

-
+