diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index c12c755..b49f5fd 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -334,7 +334,20 @@ export const registerDashboardRoutes = ( } } - const updatedDrawing = await prisma.drawing.update({ where: { id }, data }); + const updateResult = await prisma.drawing.updateMany({ + where: { id, userId: req.user.id }, + data, + }); + if (updateResult.count === 0) { + return res.status(404).json({ error: "Drawing not found" }); + } + + const updatedDrawing = await prisma.drawing.findFirst({ + where: { id, userId: req.user.id }, + }); + if (!updatedDrawing) { + return res.status(404).json({ error: "Drawing not found" }); + } invalidateDrawingsCache(); return res.json({ @@ -352,7 +365,12 @@ export const registerDashboardRoutes = ( const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); if (!drawing) return res.status(404).json({ error: "Drawing not found" }); - await prisma.drawing.delete({ where: { id } }); + const deleteResult = await prisma.drawing.deleteMany({ + where: { id, userId: req.user.id }, + }); + if (deleteResult.count === 0) { + return res.status(404).json({ error: "Drawing not found" }); + } invalidateDrawingsCache(); if (config.enableAuditLogging) { @@ -375,6 +393,9 @@ export const registerDashboardRoutes = ( const { id } = req.params; const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); if (!original) return res.status(404).json({ error: "Original drawing not found" }); + if (original.collectionId === "trash") { + await ensureTrashCollection(prisma, req.user.id); + } const newDrawing = await prisma.drawing.create({ data: { @@ -443,10 +464,19 @@ export const registerDashboardRoutes = ( } const sanitizedName = sanitizeText(parsed.data, 100); - const updatedCollection = await prisma.collection.update({ - where: { id }, + const updateResult = await prisma.collection.updateMany({ + where: { id, userId: req.user.id }, data: { name: sanitizedName }, }); + if (updateResult.count === 0) { + return res.status(404).json({ error: "Collection not found" }); + } + const updatedCollection = await prisma.collection.findFirst({ + where: { id, userId: req.user.id }, + }); + if (!updatedCollection) { + return res.status(404).json({ error: "Collection not found" }); + } return res.json(updatedCollection); })); @@ -464,7 +494,7 @@ export const registerDashboardRoutes = ( where: { collectionId: id, userId: req.user.id }, data: { collectionId: null }, }), - prisma.collection.delete({ where: { id } }), + prisma.collection.deleteMany({ where: { id, userId: req.user.id } }), ]); invalidateDrawingsCache(); diff --git a/backend/src/routes/importExport.ts b/backend/src/routes/importExport.ts index 63a2645..33a8fcc 100644 --- a/backend/src/routes/importExport.ts +++ b/backend/src/routes/importExport.ts @@ -131,6 +131,21 @@ const makeUniqueName = (base: string, used: Set): string => { return candidate; }; +const findFirstDuplicate = (values: string[]): string | null => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return null; +}; + +const normalizeNonEmptyId = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + const findSqliteTable = (tables: string[], candidates: string[]): string | null => { const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); for (const candidate of candidates) { @@ -439,6 +454,28 @@ Drawings: ${drawings.length} message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, }); } + + const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id)); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate collection id in manifest: ${duplicateCollectionId}`, + }); + } + const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id)); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`, + }); + } + const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath)); + if (duplicateDrawingPath) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`, + }); + } for (const drawing of manifest.drawings) { if (!getSafeZipEntry(zip, drawing.filePath)) { return res.status(400).json({ @@ -532,6 +569,28 @@ Drawings: ${drawings.length} }); } + const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id)); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate collection id in manifest: ${duplicateCollectionId}`, + }); + } + const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id)); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`, + }); + } + const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath)); + if (duplicateDrawingPath) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`, + }); + } + type PreparedImportDrawing = { id: string; name: string; @@ -772,6 +831,31 @@ Drawings: ${drawings.length} }); } + const duplicateDrawingIdRow = db + .prepare( + `SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1` + ) + .get(); + if (duplicateDrawingIdRow?.id) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`, + }); + } + if (collectionTable) { + const duplicateCollectionIdRow = db + .prepare( + `SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1` + ) + .get(); + if (duplicateCollectionIdRow?.id) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`, + }); + } + } + let latestMigration: string | null = null; const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]); if (migrationsTable) { @@ -862,6 +946,28 @@ Drawings: ${drawings.length} }); } + const importedCollectionIds = importedCollections + .map((c) => normalizeNonEmptyId(c?.id)) + .filter((id): id is string => id !== null); + const duplicateCollectionId = findFirstDuplicate(importedCollectionIds); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`, + }); + } + + const importedDrawingIds = importedDrawings + .map((d) => normalizeNonEmptyId(d?.id)) + .filter((id): id is string => id !== null); + const duplicateDrawingId = findFirstDuplicate(importedDrawingIds); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`, + }); + } + type PreparedLegacyDrawing = { importedId: string | null; name: string; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bd92dc3..a590972 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -36,6 +36,8 @@ export const Layout: React.FC = ({ const sidebarRef = useRef(null); const startXRef = useRef(0); const startWidthRef = useRef(0); + const resizeMouseMoveHandlerRef = useRef<((e: MouseEvent) => void) | null>(null); + const resizeMouseUpHandlerRef = useRef<(() => void) | null>(null); // Handle mouse down on resize handle const handleMouseDown = (e: React.MouseEvent) => { @@ -44,6 +46,13 @@ export const Layout: React.FC = ({ startXRef.current = e.clientX; startWidthRef.current = sidebarWidth; + if (resizeMouseMoveHandlerRef.current) { + document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current); + } + if (resizeMouseUpHandlerRef.current) { + document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current); + } + const handleMouseMove = (e: MouseEvent) => { const diff = e.clientX - startXRef.current; const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff)); @@ -54,8 +63,12 @@ export const Layout: React.FC = ({ setIsResizing(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); + resizeMouseMoveHandlerRef.current = null; + resizeMouseUpHandlerRef.current = null; }; + resizeMouseMoveHandlerRef.current = handleMouseMove; + resizeMouseUpHandlerRef.current = handleMouseUp; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; @@ -63,8 +76,14 @@ export const Layout: React.FC = ({ // Cleanup event listeners on unmount useEffect(() => { return () => { - document.removeEventListener('mousemove', () => {}); - document.removeEventListener('mouseup', () => {}); + if (resizeMouseMoveHandlerRef.current) { + document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current); + resizeMouseMoveHandlerRef.current = null; + } + if (resizeMouseUpHandlerRef.current) { + document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current); + resizeMouseUpHandlerRef.current = null; + } }; }, []); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 0b973ad..1d8ab8a 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -60,12 +60,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => return; } } catch { - // If status fails (backend down / schema mismatch), avoid locking the UI - // behind login. Backend still enforces auth when enabled. - setAuthEnabled(false); + // If status fails, default to auth-enabled mode to avoid exposing + // single-user UI paths accidentally. Backend remains the source of truth. + setAuthEnabled(true); setBootstrapRequired(false); - setUser(null); - return; } const storedUser = localStorage.getItem(USER_KEY); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index bf79ccb..f5cd6a7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -73,12 +73,14 @@ export const Dashboard: React.FC = () => { }); const [isLoading, setIsLoading] = useState(false); + const listRequestVersionRef = useRef(0); const { uploadFiles } = useUpload(); const hasMore = drawings.length < totalCount; const refreshData = useCallback(async () => { + const requestVersion = ++listRequestVersionRef.current; setIsLoading(true); try { const [drawingsRes, collectionsData] = await Promise.all([ @@ -90,6 +92,7 @@ export const Dashboard: React.FC = () => { }), api.getCollections() ]); + if (requestVersion !== listRequestVersionRef.current) return; setDrawings(drawingsRes.drawings); setTotalCount(drawingsRes.totalCount); setCollections(collectionsData); @@ -97,12 +100,15 @@ export const Dashboard: React.FC = () => { } catch (err) { console.error('Failed to fetch data:', err); } finally { - setIsLoading(false); + if (requestVersion === listRequestVersionRef.current) { + setIsLoading(false); + } } }, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]); const fetchMore = useCallback(async () => { if (isFetchingMore || !hasMore || isLoading) return; + const requestVersion = listRequestVersionRef.current; setIsFetchingMore(true); try { const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, { @@ -111,7 +117,12 @@ export const Dashboard: React.FC = () => { sortField: sortConfig.field, sortDirection: sortConfig.direction, }); - setDrawings(prev => [...prev, ...drawingsRes.drawings]); + if (requestVersion !== listRequestVersionRef.current) return; + setDrawings(prev => { + const seen = new Set(prev.map((d) => d.id)); + const nextPage = drawingsRes.drawings.filter((d) => !seen.has(d.id)); + return [...prev, ...nextPage]; + }); setTotalCount(drawingsRes.totalCount); } catch (err) { console.error('Failed to fetch more data:', err);