diff --git a/backend/package-lock.json b/backend/package-lock.json index 0cb3700..66160ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1164,6 +1164,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2654,6 +2655,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.0.tgz", "integrity": "sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4088,6 +4090,7 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5100,6 +5103,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5258,6 +5262,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5348,6 +5353,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5441,6 +5447,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 5e90fa8..b5c7e0c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { getCsrfTokenHeader, getOriginFromReferer, } from "./security"; +import jwt from "jsonwebtoken"; import { config } from "./config"; import { requireAuth } from "./middleware/auth"; import { errorHandler, asyncHandler } from "./middleware/errorHandler"; @@ -176,11 +177,13 @@ type DrawingsCacheEntry = { body: Buffer; expiresAt: number }; const drawingsCache = new Map(); const buildDrawingsCacheKey = (keyParts: { + userId: string; searchTerm: string; collectionFilter: string; includeData: boolean; }) => JSON.stringify([ + keyParts.userId, keyParts.searchTerm, keyParts.collectionFilter, keyParts.includeData ? "full" : "summary", @@ -721,16 +724,92 @@ interface User { const roomUsers = new Map(); +// Track which authenticated user owns each socket for authorization checks +const socketUserMap = new Map(); + +/** + * Verify JWT from Socket.io auth and check if auth is required. + * When auth is disabled (single-user mode), all connections are allowed. + */ +const getSocketAuthUserId = async (token?: string): Promise => { + // Check if auth is enabled + const systemConfig = await prisma.systemConfig.findUnique({ + where: { id: "default" }, + select: { authEnabled: true }, + }); + + if (!systemConfig || !systemConfig.authEnabled) { + // Auth disabled: allow all connections (single-user / bootstrap mode) + return "bootstrap-admin"; + } + + // Auth enabled: require valid JWT + if (!token) return null; + + try { + const decoded = jwt.verify(token, config.jwtSecret) as Record; + if ( + typeof decoded.userId !== "string" || + typeof decoded.email !== "string" || + decoded.type !== "access" + ) { + return null; + } + + // Verify user is still active + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, isActive: true }, + }); + + if (!user || !user.isActive) return null; + return user.id; + } catch { + return null; + } +}; + +io.use(async (socket, next) => { + try { + const token = socket.handshake.auth?.token as string | undefined; + const userId = await getSocketAuthUserId(token); + + if (!userId) { + return next(new Error("Authentication required")); + } + + socketUserMap.set(socket.id, userId); + next(); + } catch { + next(new Error("Authentication failed")); + } +}); + io.on("connection", (socket) => { + const authenticatedUserId = socketUserMap.get(socket.id); + socket.on( "join-room", - ({ + async ({ drawingId, user, }: { drawingId: string; user: Omit; }) => { + // Verify the authenticated user owns this drawing + if (authenticatedUserId) { + const drawing = await prisma.drawing.findFirst({ + where: { id: drawingId, userId: authenticatedUserId }, + select: { id: true }, + }); + + if (!drawing) { + socket.emit("error", { message: "Drawing not found or access denied" }); + return; + } + } + const roomId = `drawing_${drawingId}`; socket.join(roomId); @@ -771,6 +850,7 @@ io.on("connection", (socket) => { ); socket.on("disconnect", () => { + socketUserMap.delete(socket.id); roomUsers.forEach((users, roomId) => { const index = users.findIndex((u) => u.socketId === socket.id); if (index !== -1) { @@ -840,6 +920,7 @@ app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { : false; const cacheKey = buildDrawingsCacheKey({ + userId: req.user.id, searchTerm: searchTerm ?? "", collectionFilter: collectionFilterKey, includeData: shouldIncludeData, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6260a95..85dd990 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -162,6 +162,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -516,6 +517,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -559,6 +561,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2609,8 +2612,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2775,6 +2777,7 @@ "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2786,6 +2789,7 @@ "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -2856,6 +2860,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3219,6 +3224,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3269,7 +3275,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3499,6 +3504,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3834,6 +3840,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4207,6 +4214,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4446,8 +4454,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.1.6", @@ -4666,6 +4673,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5499,6 +5507,7 @@ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, @@ -5846,7 +5855,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6876,6 +6884,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -7041,7 +7050,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7057,7 +7065,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7113,6 +7120,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7125,6 +7133,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7138,8 +7147,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7854,6 +7862,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7992,6 +8001,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8181,6 +8191,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8274,6 +8285,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8600,6 +8612,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 7ead0e3..795d03d 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -267,9 +267,11 @@ export const Editor: React.FC = () => { ? window.location.origin : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); + const authToken = localStorage.getItem('excalidash-access-token'); const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], + auth: authToken ? { token: authToken } : {}, }); socketRef.current = socket;