From 6f292756ed91b7d93a0222d5d21d66277062ca901f7fd3025a46094abfc83ca0 Mon Sep 17 00:00:00 2001 From: aslan Date: Tue, 13 Jan 2026 17:34:39 -0500 Subject: [PATCH] Added end to end encryption --- package-lock.json | 96 ++++++++++++- package.json | 3 +- .../20260113122144_sessions_1/migration.sql | 4 + .../20260113122254_sessions_2/migration.sql | 9 ++ .../20260113123852_sessions_3/migration.sql | 9 ++ .../20260113125244_sessions_4/migration.sql | 2 + .../20260113213019_encryption/migration.sql | 2 + prisma/schema.prisma | 15 ++- src/controllers/auth/auth.ts | 3 + src/controllers/auth/types.ts | 1 + src/controllers/channel/channel.ts | 1 + src/controllers/channel/types.ts | 1 + src/controllers/community/community.ts | 36 ++++- src/controllers/community/routes.ts | 1 + src/controllers/community/types.ts | 18 +++ src/controllers/message/message.ts | 2 + src/controllers/message/types.ts | 2 + src/controllers/session/session.ts | 3 + src/controllers/session/types.ts | 3 + src/controllers/user/types.ts | 4 + src/controllers/user/user.ts | 4 + src/index.ts | 4 + src/services/auth/auth.ts | 17 +++ src/services/auth/helpers.ts | 31 +++-- src/services/auth/types.ts | 1 + src/services/channel/channel.ts | 42 +++++- src/services/community/community.ts | 63 ++++++++- src/services/invite/invite.ts | 16 ++- src/services/message/message.ts | 72 +++++++--- src/services/message/types.ts | 1 + src/services/role/role.ts | 112 ++++++++++++++- src/services/user/user.ts | 127 +++++++++++++++++- src/services/websocket/types.ts | 36 +++-- src/services/websocket/websocket.ts | 10 +- 34 files changed, 682 insertions(+), 69 deletions(-) create mode 100644 prisma/migrations/20260113122144_sessions_1/migration.sql create mode 100644 prisma/migrations/20260113122254_sessions_2/migration.sql create mode 100644 prisma/migrations/20260113123852_sessions_3/migration.sql create mode 100644 prisma/migrations/20260113125244_sessions_4/migration.sql create mode 100644 prisma/migrations/20260113213019_encryption/migration.sql diff --git a/package-lock.json b/package-lock.json index 4a9d01a..a42f127 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tether", - "version": "0.4.0", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tether", - "version": "0.4.0", + "version": "0.5.2", "license": "GPL-3.0-only", "dependencies": { "@fastify/cookie": "^11.0.2", @@ -18,6 +18,7 @@ "fastify": "^5.6.2", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", + "ua-parser-js": "^2.0.8", "uuid": "^13.0.0", "ws": "^8.19.0" }, @@ -965,6 +966,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1324,6 +1345,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2381,6 +2422,57 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.8.tgz", + "integrity": "sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 0370c3a..08ab1bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tether", - "version": "0.4.0", + "version": "0.5.2", "description": "Communication server using the Nexlink protocol", "repository": { "type": "git", @@ -35,6 +35,7 @@ "fastify": "^5.6.2", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", + "ua-parser-js": "^2.0.8", "uuid": "^13.0.0", "ws": "^8.19.0" } diff --git a/prisma/migrations/20260113122144_sessions_1/migration.sql b/prisma/migrations/20260113122144_sessions_1/migration.sql new file mode 100644 index 0000000..6805bc0 --- /dev/null +++ b/prisma/migrations/20260113122144_sessions_1/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "name" TEXT, +ADD COLUMN "sessionStorageKey" TEXT, +ADD COLUMN "userAgent" TEXT; diff --git a/prisma/migrations/20260113122254_sessions_2/migration.sql b/prisma/migrations/20260113122254_sessions_2/migration.sql new file mode 100644 index 0000000..b05fb3f --- /dev/null +++ b/prisma/migrations/20260113122254_sessions_2/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `sessionStorageKey` on the `Session` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Session" DROP COLUMN "sessionStorageKey", +ADD COLUMN "storageKey" TEXT; diff --git a/prisma/migrations/20260113123852_sessions_3/migration.sql b/prisma/migrations/20260113123852_sessions_3/migration.sql new file mode 100644 index 0000000..5b745ad --- /dev/null +++ b/prisma/migrations/20260113123852_sessions_3/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `storageKey` on the `Session` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Session" DROP COLUMN "storageKey", +ADD COLUMN "storageSecret" TEXT; diff --git a/prisma/migrations/20260113125244_sessions_4/migration.sql b/prisma/migrations/20260113125244_sessions_4/migration.sql new file mode 100644 index 0000000..1b714ae --- /dev/null +++ b/prisma/migrations/20260113125244_sessions_4/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "refreshDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260113213019_encryption/migration.sql b/prisma/migrations/20260113213019_encryption/migration.sql new file mode 100644 index 0000000..371cad4 --- /dev/null +++ b/prisma/migrations/20260113213019_encryption/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Message" ADD COLUMN "iv" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c6fcf23..5ccd1db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,11 +60,15 @@ model User { } model Session { - id String @id @unique @default(uuid()) - owner User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String - cookie String - creationDate DateTime @default(now()) + id String @id @unique @default(uuid()) + name String? + userAgent String? + owner User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + cookie String + storageSecret String? + creationDate DateTime @default(now()) + refreshDate DateTime @default(now()) } model Invite { @@ -82,6 +86,7 @@ model Invite { model Message { id String @id @unique @default(uuid()) text String + iv String? editHistory String[] @default([]) edited Boolean @default(false) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) diff --git a/src/controllers/auth/auth.ts b/src/controllers/auth/auth.ts index f6a882d..d5ce9c0 100644 --- a/src/controllers/auth/auth.ts +++ b/src/controllers/auth/auth.ts @@ -41,10 +41,12 @@ const postRegister = async (request: FastifyRequest, reply: FastifyReply) => { const postLogin = async (request: FastifyRequest, reply: FastifyReply) => { const { username, password } = request.body as IPostLoginRequest; + const userAgent = request.headers["user-agent"]; const session = await loginUser({ username: username, password: password, + userAgent: userAgent ?? "", }); if (!session) { @@ -85,6 +87,7 @@ const getRefresh = async (request: FastifyRequest, reply: FastifyReply) => { id: refresh[0].id, ownerId: refresh[0].userId, token: refresh[1], + storageSecret: refresh[0].storageSecret, } as IGetRefreshResponseSuccess; }; diff --git a/src/controllers/auth/types.ts b/src/controllers/auth/types.ts index 2e18914..b844ecf 100644 --- a/src/controllers/auth/types.ts +++ b/src/controllers/auth/types.ts @@ -35,6 +35,7 @@ interface IGetRefreshResponseSuccess { id: string; ownerId: string; token: string; + storageSecret: string; } interface IGetRefreshResponseError { diff --git a/src/controllers/channel/channel.ts b/src/controllers/channel/channel.ts index c53aea3..d95e2cb 100644 --- a/src/controllers/channel/channel.ts +++ b/src/controllers/channel/channel.ts @@ -164,6 +164,7 @@ const getMessages = async (request: FastifyRequest, reply: FastifyReply) => { messages: messages.map((message) => ({ id: message.id, text: message.text, + iv: message.iv, edited: message.edited, ownerId: message.ownerId, creationDate: message.creationDate.getTime(), diff --git a/src/controllers/channel/types.ts b/src/controllers/channel/types.ts index 751a3f9..754eef9 100644 --- a/src/controllers/channel/types.ts +++ b/src/controllers/channel/types.ts @@ -79,6 +79,7 @@ interface IGetMessagesResponseSuccess { interface IGetMessagesResponseMessage { id: string; text: string; + iv: string; edited: boolean; ownerId: string; creationDate: number; diff --git a/src/controllers/community/community.ts b/src/controllers/community/community.ts index 08cb643..edb488d 100644 --- a/src/controllers/community/community.ts +++ b/src/controllers/community/community.ts @@ -29,17 +29,21 @@ import type { IPostCreateInviteRequest, IPostCreateInviteResponseError, IPostCreateInviteResponseSuccess, + IDeleteMemberParams, + IDeleteMemberResponseError, + IDeleteMemberResponseSuccess, } from "./types.js"; import { getCommunityById, createCommunityAuth, updateCommunityByIdAuth, + deleteCommunityByIdAuth, getCommunityChannelsByIdAuth, getCommunityMembersByIdAuth, getCommunityRolesByIdAuth, getCommunityInvitesByIdAuth, createInviteAuth, - deleteCommunityByIdAuth, + deleteMemberByIdAuth, } from "../../services/community/community.js"; import { API_ERROR } from "../errors.js"; import type { ICreateInvite } from "../../services/community/types.js"; @@ -318,6 +322,35 @@ const postCreateInvite = async ( } as IPostCreateInviteResponseSuccess; }; +const deleteCommunityMember = async ( + request: FastifyRequest, + reply: FastifyReply, +) => { + const { id, memberId } = request.params as IDeleteMemberParams; + const authHeader = request.headers["authorization"]; + + const community = await deleteMemberByIdAuth(id, memberId, authHeader); + if (!community) { + reply.status(404); + return { + id: id, + error: API_ERROR.NOT_FOUND, + } as IDeleteMemberResponseError; + } + if (community === API_ERROR.ACCESS_DENIED) { + reply.status(403); + return { + id: id, + error: API_ERROR.ACCESS_DENIED, + } as IDeleteMemberResponseError; + } + + return { + id: community.id, + userId: memberId, + } as IDeleteMemberResponseSuccess; +}; + export { getCommunity, postCreateCommunity, @@ -328,4 +361,5 @@ export { getRoles, getInvites, postCreateInvite, + deleteCommunityMember, }; diff --git a/src/controllers/community/routes.ts b/src/controllers/community/routes.ts index e35fe60..45fc083 100644 --- a/src/controllers/community/routes.ts +++ b/src/controllers/community/routes.ts @@ -11,6 +11,7 @@ const communityRoutes = async (fastify: FastifyInstance) => { fastify.get(`/:id/roles`, controller.getRoles); fastify.get(`/:id/invites`, controller.getInvites); fastify.post(`/:id/invite`, controller.postCreateInvite); + fastify.delete(`/:id/members/:memberId`, controller.deleteCommunityMember); }; export { communityRoutes }; diff --git a/src/controllers/community/types.ts b/src/controllers/community/types.ts index b1e4972..05dc778 100644 --- a/src/controllers/community/types.ts +++ b/src/controllers/community/types.ts @@ -158,6 +158,21 @@ interface IPostCreateInviteResponseSuccess { inviteId: string; } +interface IDeleteMemberParams { + id: string; + memberId: string; +} + +interface IDeleteMemberResponseError { + id: string; + error: API_ERROR; +} + +interface IDeleteMemberResponseSuccess { + id: string; + userId: string; +} + export { type ICommunity, type IGetCommunityParams, @@ -193,4 +208,7 @@ export { type IPostCreateInviteRequest, type IPostCreateInviteResponseError, type IPostCreateInviteResponseSuccess, + type IDeleteMemberParams, + type IDeleteMemberResponseError, + type IDeleteMemberResponseSuccess, }; diff --git a/src/controllers/message/message.ts b/src/controllers/message/message.ts index 03384f7..90d3bf4 100644 --- a/src/controllers/message/message.ts +++ b/src/controllers/message/message.ts @@ -45,6 +45,7 @@ const getMessage = async (request: FastifyRequest, reply: FastifyReply) => { return { id: message.id, text: message.text, + iv: message.iv, editHistory: message.editHistory, edited: message.edited, ownerId: message.ownerId, @@ -71,6 +72,7 @@ const postCreateMessage = async ( return { id: message.id, text: message.text, + iv: message.iv, editHistory: message.editHistory, edited: message.edited, ownerId: message.ownerId, diff --git a/src/controllers/message/types.ts b/src/controllers/message/types.ts index 0bcb57c..a607677 100644 --- a/src/controllers/message/types.ts +++ b/src/controllers/message/types.ts @@ -3,6 +3,7 @@ import type { API_ERROR } from "../errors.js"; interface IMessage { id: string; text: string; + iv: string; editHistory: string[]; edited: boolean; ownerId: string; @@ -23,6 +24,7 @@ interface IGetMessageResponseSuccess extends IMessage {} interface IPostCreateMessageRequest { text: string; + iv: string; channelId: string; } diff --git a/src/controllers/session/session.ts b/src/controllers/session/session.ts index 8cacd0e..3ebbf7f 100644 --- a/src/controllers/session/session.ts +++ b/src/controllers/session/session.ts @@ -36,7 +36,10 @@ const getSession = async (request: FastifyRequest, reply: FastifyReply) => { return { id: session.id, userId: session.userId, + name: session.name, + userAgent: session.userAgent, creationDate: session.creationDate.getTime(), + refreshDate: session.refreshDate.getTime(), } as IGetSessionResponseSuccess; }; diff --git a/src/controllers/session/types.ts b/src/controllers/session/types.ts index f2791a6..a8bcffa 100644 --- a/src/controllers/session/types.ts +++ b/src/controllers/session/types.ts @@ -3,7 +3,10 @@ import type { API_ERROR } from "../errors.js"; interface ISession { id: string; userId: string; + name: string; + userAgent: string; creationDate: number; + refreshDate: number; } interface IGetSessionParams { diff --git a/src/controllers/user/types.ts b/src/controllers/user/types.ts index d85a0fe..8243424 100644 --- a/src/controllers/user/types.ts +++ b/src/controllers/user/types.ts @@ -90,6 +90,10 @@ interface IGetSessionsResponseSuccess { interface IGetSessionsResponseSession { id: string; userId: string; + name: string; + userAgent: string; + creationDate: number; + refreshDate: number; } interface IGetCommunitiesParams { diff --git a/src/controllers/user/user.ts b/src/controllers/user/user.ts index beb44c6..84cb732 100644 --- a/src/controllers/user/user.ts +++ b/src/controllers/user/user.ts @@ -185,6 +185,10 @@ const getSessions = async (request: FastifyRequest, reply: FastifyReply) => { sessions: sessions.map((session) => ({ id: session.id, userId: session.userId, + name: session.name, + userAgent: session.userAgent, + creationDate: session.creationDate.getTime(), + refreshDate: session.refreshDate.getTime(), })), } as IGetSessionsResponseSuccess; }; diff --git a/src/index.ts b/src/index.ts index 98af533..7e9713a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { roleRoutes } from "./controllers/role/routes.js"; import { inviteRoutes } from "./controllers/invite/routes.js"; import { messageRoutes } from "./controllers/message/routes.js"; import { websocketRoutes } from "./controllers/websocket/routes.js"; +import { initializeCommunitiesWithReadableUsersCache } from "./services/user/user.js"; const app = Fastify({ logger: true, @@ -25,6 +26,7 @@ const app = Fastify({ app.register(cors, { origin: "http://localhost:3000", credentials: true, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], }); app.register(cookie, { secret: getCookieSecret() }); @@ -46,3 +48,5 @@ app.listen({ port: config.port }, (err, address) => { if (err) throw err; console.log(`Server is now listening on ${address}`); }); + +initializeCommunitiesWithReadableUsersCache(); diff --git a/src/services/auth/auth.ts b/src/services/auth/auth.ts index 1c3031c..f30995e 100644 --- a/src/services/auth/auth.ts +++ b/src/services/auth/auth.ts @@ -1,8 +1,10 @@ +import { UAParser } from "ua-parser-js"; import type { User, Session } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; import { createSessionCookie, createToken, + getRandomBytesHex, hashPassword, verifyPassword, } from "./helpers.js"; @@ -45,6 +47,9 @@ const registerUser = async ( }; const loginUser = async (login: IUserLogin): Promise => { + const uaParser = new UAParser(login.userAgent); + const sessionName = `${uaParser.getBrowser()} on ${uaParser.getOS()}`; + const user = await getDB().user.findUnique({ where: { username: login.username }, }); @@ -69,6 +74,9 @@ const loginUser = async (login: IUserLogin): Promise => { data: { cookie: createSessionCookie(), userId: user.id, + name: sessionName, + userAgent: login.userAgent, + storageSecret: `${getRandomBytesHex(32)};${getRandomBytesHex(12)}`, }, }); }; @@ -90,6 +98,15 @@ const refreshSession = async ( return null; } + await getDB().session.update({ + where: { + id: session.id, + }, + data: { + refreshDate: new Date(), + }, + }); + return [session, createToken(session.id)]; }; diff --git a/src/services/auth/helpers.ts b/src/services/auth/helpers.ts index 9fb3851..fac2177 100644 --- a/src/services/auth/helpers.ts +++ b/src/services/auth/helpers.ts @@ -14,8 +14,12 @@ const getCookieSecret = (): string => { return process.env.COOKIE_SECRET || ""; }; +const getRandomBytesHex = (amount: number) => { + return crypto.randomBytes(amount).toString("hex"); +}; + const createSessionCookie = () => { - return crypto.randomBytes(32).toString("hex"); + return getRandomBytesHex(32); }; const createToken = (sessionId: string) => { @@ -152,19 +156,15 @@ const isUserOwnerOrAdmin = async ( }; const getUserPermissions = async ( - user: User | null, - community: Community | null, + userId: string, + communityId: string, ): Promise => { - if (!user || !community) { - return []; - } - const roles = await getDB().role.findMany({ where: { - communityId: community.id, + communityId: communityId, users: { some: { - id: user.id, + id: userId, }, }, }, @@ -184,15 +184,15 @@ const getUserPermissions = async ( }; const userHasPermissions = async ( - user: User | null, - community: Community | null, + userId: string | undefined, + communityId: string | undefined, requiredPermissions: PERMISSION[], ): Promise => { - if (!user || !community) { + if (!userId || !communityId) { return false; } - const userPermissions = await getUserPermissions(user, community); + const userPermissions = await getUserPermissions(userId, communityId); return requiredPermissions.every((requiredPermission) => userPermissions.includes(requiredPermission), @@ -208,7 +208,9 @@ const isUserAllowed = async ( if (await isUserOwnerOrAdmin(user, ownerCheck)) { return true; } - if (await userHasPermissions(user, community, requiredPermissions)) { + if ( + await userHasPermissions(user?.id, community?.id, requiredPermissions) + ) { return true; } @@ -240,6 +242,7 @@ const isUserInCommunity = async ( export { getJwtSecret, getCookieSecret, + getRandomBytesHex, createSessionCookie, createToken, verifyToken, diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index c9878b4..a75394c 100644 --- a/src/services/auth/types.ts +++ b/src/services/auth/types.ts @@ -22,6 +22,7 @@ interface IUserRegistration { interface IUserLogin { username: string; password: string; + userAgent: string; } interface IOwnerCheck { diff --git a/src/services/channel/channel.ts b/src/services/channel/channel.ts index d94fcbc..b49ec8a 100644 --- a/src/services/channel/channel.ts +++ b/src/services/channel/channel.ts @@ -4,6 +4,9 @@ import { getDB } from "../../store/store.js"; import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; import { PERMISSION } from "../auth/permission.js"; import { getCommunityById } from "../community/community.js"; +import { getUserIdsInCommunity } from "../user/user.js"; +import { SocketMessageTypes } from "../websocket/types.js"; +import { sendMessageToUsersWS } from "../websocket/websocket.js"; import type { ICreateChannel, IUpdateChannel } from "./types.js"; const getChannelById = async (id: string): Promise => { @@ -37,11 +40,22 @@ const getChannelByIdAuth = async ( }; const createChannel = async (create: ICreateChannel): Promise => { - return await getDB().channel.create({ + const createdChannel = await getDB().channel.create({ data: { ...create, }, }); + + const userIds = await getUserIdsInCommunity(createdChannel.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_CHANNELS, + payload: { + communityId: createdChannel.communityId, + }, + }); + + return createdChannel; }; const createChannelAuth = async ( @@ -71,7 +85,7 @@ const updateChannelById = async ( id: string, update: IUpdateChannel, ): Promise => { - return await getDB().channel.update({ + const updatedChannel = await getDB().channel.update({ where: { id: id, }, @@ -79,6 +93,17 @@ const updateChannelById = async ( ...update, }, }); + + const userIds = await getUserIdsInCommunity(updatedChannel.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_CHANNELS, + payload: { + communityId: updatedChannel.communityId, + }, + }); + + return updatedChannel; }; const updateChannelByIdAuth = async ( @@ -107,9 +132,20 @@ const updateChannelByIdAuth = async ( }; const deleteChannelById = async (id: string): Promise => { - return await getDB().channel.delete({ + const deletedChannel = await getDB().channel.delete({ where: { id: id }, }); + + const userIds = await getUserIdsInCommunity(deletedChannel.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_CHANNELS, + payload: { + communityId: deletedChannel.communityId, + }, + }); + + return deletedChannel; }; const deleteChannelByIdAuth = async ( diff --git a/src/services/community/community.ts b/src/services/community/community.ts index 32a304e..de5ae1d 100644 --- a/src/services/community/community.ts +++ b/src/services/community/community.ts @@ -7,6 +7,9 @@ import { isUserOwnerOrAdmin, } from "../auth/helpers.js"; import { PERMISSION } from "../auth/permission.js"; +import { getUserIdsInCommunity } from "../user/user.js"; +import { SocketMessageTypes } from "../websocket/types.js"; +import { sendMessageToUsersWS } from "../websocket/websocket.js"; import type { ICreateCommunity, IUpdateCommunity, @@ -287,6 +290,7 @@ const createInviteAuth = async ( const community = await getCommunityById(id); if ( + !authUser || !(await isUserAllowed( authUser, { @@ -294,8 +298,7 @@ const createInviteAuth = async ( }, community, [PERMISSION.INVITES_CREATE], - )) || - !authUser + )) ) { return API_ERROR.ACCESS_DENIED; } @@ -303,6 +306,60 @@ const createInviteAuth = async ( return await createInvite(id, authUser.id, createInviteData); }; +const deleteMemberById = async ( + id: string, + memberId: string, +): Promise => { + const updatedCommunity = await getDB().community.update({ + where: { + id: id, + }, + data: { + members: { + disconnect: { + id: memberId, + }, + }, + }, + }); + + const userIds = await getUserIdsInCommunity(updatedCommunity.id); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_MEMBERS, + payload: { + communityId: updatedCommunity.id, + }, + }); + + return updatedCommunity; +}; + +const deleteMemberByIdAuth = async ( + id: string, + memberId: string, + authHeader: string | undefined, +): Promise => { + const authUser = await getUserFromAuth(authHeader); + const community = await getCommunityById(id); + + if ( + !(await isUserAllowed( + authUser, + { + community: community, + }, + community, + [PERMISSION.MEMBERS_KICK], + )) && + authUser?.id !== memberId + ) { + return API_ERROR.ACCESS_DENIED; + } + + return await deleteMemberById(id, memberId); +}; + export { getCommunityById, createCommunity, @@ -321,4 +378,6 @@ export { getCommunityInvitesByIdAuth, createInvite, createInviteAuth, + deleteMemberById, + deleteMemberByIdAuth, }; diff --git a/src/services/invite/invite.ts b/src/services/invite/invite.ts index e5597a4..d51dc0c 100644 --- a/src/services/invite/invite.ts +++ b/src/services/invite/invite.ts @@ -8,6 +8,9 @@ import { getDB } from "../../store/store.js"; import { getCommunityById } from "../community/community.js"; import { PERMISSION } from "../auth/permission.js"; import { API_ERROR } from "../../controllers/errors.js"; +import { getUserIdsInCommunity } from "../user/user.js"; +import { sendMessageToUsersWS } from "../websocket/websocket.js"; +import { SocketMessageTypes } from "../websocket/types.js"; const getInviteById = async (id: string): Promise => { return await getDB().invite.findUnique({ @@ -63,7 +66,7 @@ const acceptInviteById = async ( }, }); - return await getDB().community.update({ + const updatedCommunity = await getDB().community.update({ where: { id: invite.communityId, }, @@ -75,6 +78,17 @@ const acceptInviteById = async ( }, }, }); + + const userIds = await getUserIdsInCommunity(updatedCommunity.id); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_MEMBERS, + payload: { + communityId: updatedCommunity.id, + }, + }); + + return updatedCommunity; }; const acceptInviteByIdAuth = async ( diff --git a/src/services/message/message.ts b/src/services/message/message.ts index 21c3693..84c9842 100644 --- a/src/services/message/message.ts +++ b/src/services/message/message.ts @@ -10,8 +10,9 @@ import { import { PERMISSION } from "../auth/permission.js"; import { getChannelById } from "../channel/channel.js"; import { getCommunityById } from "../community/community.js"; +import { getUserIdsInCommunityReadMessagesPermission } from "../user/user.js"; import { SocketMessageTypes } from "../websocket/types.js"; -import { sendMessageToUsers } from "../websocket/websocket.js"; +import { sendMessageToUsersWS } from "../websocket/websocket.js"; import type { ICreateMessage, IUpdateMessage } from "./types.js"; const getMessageById = async (id: string): Promise => { @@ -57,27 +58,17 @@ const createMessage = async ( }, }); - const usersInCommunity = await getDB().user.findMany({ - select: { - id: true, - }, - where: { - communities: { - some: { - id: communityId, - }, - }, - }, - }); - const userIds = usersInCommunity.map((user) => user.id); + const userIds = + await getUserIdsInCommunityReadMessagesPermission(communityId); - sendMessageToUsers(userIds, { - type: SocketMessageTypes.NEW_MESSAGE, + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.SET_MESSAGE, payload: { channelId: message.channelId, message: { id: message.id, text: message.text, + iv: message.iv ?? "", edited: message.edited, ownerId: message.ownerId, creationDate: message.creationDate.getTime(), @@ -116,6 +107,7 @@ const createMessageAuth = async ( const updateMessageById = async ( id: string, + communityId: string, update: IUpdateMessage, ): Promise => { const message = await getMessageById(id); @@ -125,7 +117,7 @@ const updateMessageById = async ( const newEditHistory = [...message.editHistory, message.text]; - return await getDB().message.update({ + const updatedMessage = await getDB().message.update({ where: { id: id, }, @@ -135,6 +127,26 @@ const updateMessageById = async ( edited: true, }, }); + + const userIds = + await getUserIdsInCommunityReadMessagesPermission(communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.SET_MESSAGE, + payload: { + channelId: updatedMessage.channelId, + message: { + id: updatedMessage.id, + text: updatedMessage.text, + iv: updatedMessage.iv ?? "", + edited: updatedMessage.edited, + ownerId: updatedMessage.ownerId, + creationDate: updatedMessage.creationDate.getTime(), + }, + }, + }); + + return updatedMessage; }; const updateMessageByIdAuth = async ( @@ -148,6 +160,7 @@ const updateMessageByIdAuth = async ( const community = await getCommunityById(channel?.communityId ?? ""); if ( + !community || !(await isUserOwnerOrAdmin(authUser, { message: message, })) || @@ -156,13 +169,29 @@ const updateMessageByIdAuth = async ( return API_ERROR.ACCESS_DENIED; } - return await updateMessageById(id, update); + return await updateMessageById(id, community?.id, update); }; -const deleteMessageById = async (id: string): Promise => { - return await getDB().message.delete({ +const deleteMessageById = async ( + id: string, + communityId: string, +): Promise => { + const deletedMessage = await getDB().message.delete({ where: { id: id }, }); + + const userIds = + await getUserIdsInCommunityReadMessagesPermission(communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.DELETE_MESSAGE, + payload: { + channelId: deletedMessage.channelId, + messageId: deletedMessage.id, + }, + }); + + return deletedMessage; }; const deleteMessageByIdAuth = async ( @@ -175,6 +204,7 @@ const deleteMessageByIdAuth = async ( const community = await getCommunityById(channel?.communityId ?? ""); if ( + !community || !(await isUserAllowed( authUser, { @@ -187,7 +217,7 @@ const deleteMessageByIdAuth = async ( return API_ERROR.ACCESS_DENIED; } - return await deleteMessageById(id); + return await deleteMessageById(id, community.id); }; export { diff --git a/src/services/message/types.ts b/src/services/message/types.ts index 3d22722..3e1ed19 100644 --- a/src/services/message/types.ts +++ b/src/services/message/types.ts @@ -1,5 +1,6 @@ interface ICreateMessage { text: string; + iv: string; channelId: string; } diff --git a/src/services/role/role.ts b/src/services/role/role.ts index 831b955..eede9ae 100644 --- a/src/services/role/role.ts +++ b/src/services/role/role.ts @@ -4,6 +4,13 @@ import { getDB } from "../../store/store.js"; import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js"; import { PERMISSION } from "../auth/permission.js"; import { getCommunityById } from "../community/community.js"; +import { + getUserIdsInCommunity, + getUserIdsWithRole, + updateCommunitiesWithReadableUsersCache, +} from "../user/user.js"; +import { SocketMessageTypes } from "../websocket/types.js"; +import { sendMessageToUsersWS } from "../websocket/websocket.js"; import type { ICreateRole, IUpdateRole } from "./types.js"; const getRoleById = async (id: string): Promise => { @@ -37,11 +44,22 @@ const getRoleByIdAuth = async ( }; const createRole = async (create: ICreateRole): Promise => { - return await getDB().role.create({ + const createdRole = await getDB().role.create({ data: { ...create, }, }); + + const userIds = await getUserIdsInCommunity(createdRole.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_ROLES, + payload: { + communityId: createdRole.communityId, + }, + }); + + return createdRole; }; const createRoleAuth = async ( @@ -71,7 +89,7 @@ const updateRoleById = async ( id: string, update: IUpdateRole, ): Promise => { - return await getDB().role.update({ + const updatedRole = await getDB().role.update({ where: { id: id, }, @@ -79,6 +97,30 @@ const updateRoleById = async ( ...update, }, }); + + if (!update.permissions) { + return updatedRole; + } + + const usersWithRole = await getUserIdsWithRole(id); + + usersWithRole.forEach((userId) => { + updateCommunitiesWithReadableUsersCache( + updatedRole.communityId, + userId, + ); + }); + + const userIds = await getUserIdsInCommunity(updatedRole.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_ROLES, + payload: { + communityId: updatedRole.communityId, + }, + }); + + return updatedRole; }; const updateRoleByIdAuth = async ( @@ -107,9 +149,29 @@ const updateRoleByIdAuth = async ( }; const deleteRoleById = async (id: string): Promise => { - return await getDB().role.delete({ + const usersWithRole = await getUserIdsWithRole(id); + + const deletedRole = await getDB().role.delete({ where: { id: id }, }); + + usersWithRole.forEach((userId) => { + updateCommunitiesWithReadableUsersCache( + deletedRole.communityId, + userId, + ); + }); + + const userIds = await getUserIdsInCommunity(deletedRole.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_ROLES, + payload: { + communityId: deletedRole.communityId, + }, + }); + + return deletedRole; }; const deleteRoleByIdAuth = async ( @@ -140,7 +202,7 @@ const assignRoleById = async ( id: string, userId: string, ): Promise => { - return await getDB().role.update({ + const updatedRole = await getDB().role.update({ where: { id: id, }, @@ -152,6 +214,26 @@ const assignRoleById = async ( }, }, }); + + const usersWithRole = await getUserIdsWithRole(id); + + usersWithRole.forEach((userId) => { + updateCommunitiesWithReadableUsersCache( + updatedRole.communityId, + userId, + ); + }); + + const userIds = await getUserIdsInCommunity(updatedRole.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_MEMBERS, + payload: { + communityId: updatedRole.communityId, + }, + }); + + return updatedRole; }; const assignRoleByIdAuth = async ( @@ -183,7 +265,7 @@ const unassignRoleById = async ( id: string, userId: string, ): Promise => { - return await getDB().role.update({ + const updatedRole = await getDB().role.update({ where: { id: id, }, @@ -195,6 +277,26 @@ const unassignRoleById = async ( }, }, }); + + const usersWithRole = await getUserIdsWithRole(id); + + usersWithRole.forEach((userId) => { + updateCommunitiesWithReadableUsersCache( + updatedRole.communityId, + userId, + ); + }); + + const userIds = await getUserIdsInCommunity(updatedRole.communityId); + + sendMessageToUsersWS(userIds, { + type: SocketMessageTypes.UPDATE_MEMBERS, + payload: { + communityId: updatedRole.communityId, + }, + }); + + return updatedRole; }; const unassignRoleByIdAuth = async ( diff --git a/src/services/user/user.ts b/src/services/user/user.ts index e322fd7..b1e21df 100644 --- a/src/services/user/user.ts +++ b/src/services/user/user.ts @@ -3,10 +3,82 @@ import type { Session, Community, } from "../../generated/prisma/client.js"; -import { getUserFromAuth, isUserOwnerOrAdmin } from "../auth/helpers.js"; +import { + getUserFromAuth, + getUserPermissions, + isUserOwnerOrAdmin, +} from "../auth/helpers.js"; import { getDB } from "../../store/store.js"; import { API_ERROR } from "../../controllers/errors.js"; import type { ICreateUser, IUpdateUser } from "./types.js"; +import { PERMISSION } from "../auth/permission.js"; + +const communitiesWithReadableUsersCache = new Map>(); + +const initializeCommunitiesWithReadableUsersCache = async () => { + const allCommunities = await getDB().community.findMany({ + select: { + id: true, + ownerId: true, + members: { + select: { + id: true, + roles: { + select: { + communityId: true, + permissions: true, + }, + }, + }, + }, + }, + }); + + for (const community of allCommunities) { + for (const member of community.members) { + let usersInCommunity = communitiesWithReadableUsersCache.get( + community.id, + ); + if (!usersInCommunity) { + usersInCommunity = new Set(); + } + + const hasReadRole = member.roles + .filter((role) => role.communityId === community.id) + .some((role) => + role.permissions.includes(PERMISSION.MESSAGES_READ), + ); + + if (member.id === community.ownerId || hasReadRole) { + usersInCommunity.add(member.id); + } + + communitiesWithReadableUsersCache.set( + community.id, + usersInCommunity, + ); + } + } +}; + +const updateCommunitiesWithReadableUsersCache = async ( + communityId: string, + userId: string, +) => { + let usersInCommunity = communitiesWithReadableUsersCache.get(communityId); + if (!usersInCommunity) { + usersInCommunity = new Set(); + communitiesWithReadableUsersCache.set(communityId, usersInCommunity); + } + + const userPermissions = await getUserPermissions(userId, communityId); + + if (userPermissions.includes(PERMISSION.MESSAGES_READ)) { + usersInCommunity.add(userId); + } else { + usersInCommunity.delete(userId); + } +}; const getLoggedUserAuth = async ( authHeader: string | undefined, @@ -182,7 +254,57 @@ const getUserCommunitiesByIdAuth = async ( return communities; }; +const getUserIdsInCommunity = async ( + communityId: string, +): Promise => { + const usersInCommunity = await getDB().user.findMany({ + select: { + id: true, + }, + where: { + communities: { + some: { + id: communityId, + }, + }, + }, + }); + + return usersInCommunity.map((user) => user.id); +}; + +const getUserIdsInCommunityReadMessagesPermission = async ( + communityId: string, +): Promise => { + const userIds = communitiesWithReadableUsersCache.get(communityId); + if (!userIds) { + return []; + } + + return [...userIds]; +}; + +const getUserIdsWithRole = async (id: string): Promise => { + const usersWithRole = await getDB().user.findMany({ + select: { + id: true, + }, + where: { + roles: { + some: { + id: id, + }, + }, + }, + }); + + return usersWithRole.map((user) => user.id); +}; + export { + communitiesWithReadableUsersCache, + initializeCommunitiesWithReadableUsersCache, + updateCommunitiesWithReadableUsersCache, getLoggedUserAuth, getUserById, getUserByIdAuth, @@ -196,4 +318,7 @@ export { getUserSessionsByIdAuth, getUserCommunitiesById, getUserCommunitiesByIdAuth, + getUserIdsInCommunity, + getUserIdsInCommunityReadMessagesPermission, + getUserIdsWithRole, }; diff --git a/src/services/websocket/types.ts b/src/services/websocket/types.ts index 6cf6695..a35d7f3 100644 --- a/src/services/websocket/types.ts +++ b/src/services/websocket/types.ts @@ -11,9 +11,12 @@ enum SocketRequestTypes { } enum SocketMessageTypes { - NEW_ANNOUNCEMENT = "NEW_ANNOUNCEMENT", - NEW_MESSAGE = "NEW_MESSAGE", - NEW_CHANNEL = "NEW_CHANNEL", + ANNOUNCEMENT = "ANNOUNCEMENT", + SET_MESSAGE = "SET_MESSAGE", + DELETE_MESSAGE = "DELETE_MESSAGE", + UPDATE_CHANNELS = "UPDATE_CHANNELS", + UPDATE_ROLES = "UPDATE_ROLES", + UPDATE_MEMBERS = "UPDATE_MEMBERS", } type SocketRequest = { @@ -22,25 +25,42 @@ type SocketRequest = { type SocketMessage = | { - type: SocketMessageTypes.NEW_ANNOUNCEMENT; + type: SocketMessageTypes.ANNOUNCEMENT; payload: { title: string; description: string; }; } | { - type: SocketMessageTypes.NEW_MESSAGE; + type: SocketMessageTypes.SET_MESSAGE; payload: { channelId: string; message: IGetMessagesResponseMessage; }; } | { - type: SocketMessageTypes.NEW_CHANNEL; + type: SocketMessageTypes.DELETE_MESSAGE; + payload: { + channelId: string; + messageId: string; + }; + } + | { + type: SocketMessageTypes.UPDATE_CHANNELS; + payload: { + communityId: string; + }; + } + | { + type: SocketMessageTypes.UPDATE_ROLES; + payload: { + communityId: string; + }; + } + | { + type: SocketMessageTypes.UPDATE_MEMBERS; payload: { - id: string; communityId: string; - name: string; }; }; diff --git a/src/services/websocket/websocket.ts b/src/services/websocket/websocket.ts index 9df1d26..5d65339 100644 --- a/src/services/websocket/websocket.ts +++ b/src/services/websocket/websocket.ts @@ -53,7 +53,7 @@ const onMessageWsHandler = (connection: ISocketConnection) => { }; }; -const sendMessageToUser = (userId: string, message: SocketMessage) => { +const sendMessageToUserWS = (userId: string, message: SocketMessage) => { const connections = userConnections.get(userId); connections?.forEach((connection) => { @@ -61,15 +61,15 @@ const sendMessageToUser = (userId: string, message: SocketMessage) => { }); }; -const sendMessageToUsers = (userIds: string[], message: SocketMessage) => { +const sendMessageToUsersWS = (userIds: string[], message: SocketMessage) => { userIds?.forEach((userId) => { - sendMessageToUser(userId, message); + sendMessageToUserWS(userId, message); }); }; export { userConnections, handleNewWebSocket, - sendMessageToUser, - sendMessageToUsers, + sendMessageToUserWS, + sendMessageToUsersWS, };