From 1724b45a5d7d9e71a0968a915333491f72e5ee09 Mon Sep 17 00:00:00 2001 From: Aseer KT Date: Fri, 12 Jul 2024 15:26:20 +0530 Subject: [PATCH] feat: leave and kick group members --- pnpm-lock.yaml | 63 ++++++++++++ server/package.json | 3 + server/src/index.ts | 9 +- .../src/modules/groups/groups.controller.ts | 99 ++++++++++++++----- server/src/modules/groups/groups.routes.ts | 9 ++ server/src/redis/handlers.ts | 7 +- 6 files changed, 166 insertions(+), 24 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462e73d..234cd29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: express: specifier: ^4.19.2 version: 4.19.2 + helmet: + specifier: ^7.1.0 + version: 7.1.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -74,6 +77,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + morgan: + specifier: ^1.10.0 + version: 1.10.0 pg: specifier: ^8.12.0 version: 8.12.0 @@ -99,6 +105,9 @@ importers: '@types/lodash': specifier: ^4.17.6 version: 4.17.6 + '@types/morgan': + specifier: ^1.9.9 + version: 1.9.9 '@types/node': specifier: ^20.14.5 version: 20.14.5 @@ -1760,6 +1769,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/morgan@1.9.9': + resolution: {integrity: sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==} + '@types/node@20.14.10': resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} @@ -2110,6 +2122,10 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2955,6 +2971,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@7.1.0: + resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==} + engines: {node: '>=16.0.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3443,6 +3463,10 @@ packages: mlly@1.7.1: resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + morgan@1.10.0: + resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} + engines: {node: '>= 0.8.0'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3519,10 +3543,18 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4020,6 +4052,9 @@ packages: resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6234,6 +6269,10 @@ snapshots: '@types/mime@1.3.5': {} + '@types/morgan@1.9.9': + dependencies: + '@types/node': 20.14.10 + '@types/node@20.14.10': dependencies: undici-types: 5.26.5 @@ -6670,6 +6709,10 @@ snapshots: base64id@2.0.0: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + binary-extensions@2.3.0: {} body-parser@1.20.2: @@ -7653,6 +7696,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@7.1.0: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -8100,6 +8145,16 @@ snapshots: pkg-types: 1.1.3 ufo: 1.5.3 + morgan@1.10.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.0.2 + transitivePeerDependencies: + - supports-color + ms@2.0.0: {} ms@2.1.2: {} @@ -8153,10 +8208,16 @@ snapshots: obuf@1.1.2: {} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + on-finished@2.4.1: dependencies: ee-first: 1.1.1 + on-headers@1.0.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8584,6 +8645,8 @@ snapshots: has-symbols: 1.0.3 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-regex-test@1.0.3: diff --git a/server/package.json b/server/package.json index 71ad6bc..251cda9 100644 --- a/server/package.json +++ b/server/package.json @@ -28,9 +28,11 @@ "dotenv": "^16.4.5", "drizzle-orm": "^0.31.2", "express": "^4.19.2", + "helmet": "^7.1.0", "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "morgan": "^1.10.0", "pg": "^8.12.0", "socket.io": "^4.7.5" }, @@ -41,6 +43,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.6", + "@types/morgan": "^1.9.9", "@types/node": "^20.14.5", "@types/pg": "^8.11.6", "drizzle-kit": "^0.22.8", diff --git a/server/src/index.ts b/server/src/index.ts index 89b906e..6338afe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,8 @@ import { setupMaster, setupWorker } from '@socket.io/sticky' import 'colors' import cors from 'cors' import express from 'express' +import helmet from 'helmet' +import morgan from 'morgan' import cluster from 'node:cluster' import { createServer } from 'node:http' import { availableParallelism } from 'node:os' @@ -61,7 +63,12 @@ const createApp = async () => { const app = express() - app.use(cors({ origin: config.corsOrigin }), express.json()) + app.use( + cors({ origin: config.corsOrigin }), + helmet(), + express.json(), + morgan(config.isProd ? 'combined' : 'dev'), + ) const server = createServer(app) diff --git a/server/src/modules/groups/groups.controller.ts b/server/src/modules/groups/groups.controller.ts index 08dde6d..ac348b9 100644 --- a/server/src/modules/groups/groups.controller.ts +++ b/server/src/modules/groups/groups.controller.ts @@ -1,9 +1,9 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' -import { deleteGroupMembersRoles } from '@/redis/handlers' +import { deleteGroupRoles, deleteMemberRole } from '@/redis/handlers' import { getGroupRoomId } from '@/socket/helpers' import { TypedIOServer } from '@/socket/socket.interface' -import { notFound } from '@/utils/api' +import { badRequest, notFound } from '@/utils/api' import { and, count, @@ -68,6 +68,35 @@ export const getGroup: RequestHandler = async (req, res, next) => { } } +export const getNonGroupMembers: RequestHandler = async (req, res, next) => { + try { + const groupMembers = await db + .select({ userId: members.userId }) + .from(members) + .where(eq(members.groupId, Number(req.params.groupId))) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...columns } = getTableColumns(users) + const rows = await db + .select(columns) + .from(users) + .where( + and( + like(users.username, `%${req.query.query}%`), + notInArray( + users.id, + groupMembers.map(m => m.userId), + ), + ), + ) + .limit(Number(req.query.limit) || 5) + .orderBy(users.username) + + res.json(rows) + } catch (error) { + next(error) + } +} + export const listGroups: RequestHandler = async (req, res, next) => { try { const userGroupIds = await db @@ -241,7 +270,7 @@ export const deleteGroup: RequestHandler = async (req, res, next) => { } // TODO: move these db operations to queue - await deleteGroupMembersRoles(groupId) + await deleteGroupRoles(groupId) await db.delete(messages).where(eq(messages.groupId, groupId)) await db.delete(members).where(eq(members.groupId, groupId)) @@ -251,30 +280,56 @@ export const deleteGroup: RequestHandler = async (req, res, next) => { } } -export const getNonGroupMembers: RequestHandler = async (req, res, next) => { +export const leaveGroup: RequestHandler = async (req, res, next) => { try { - const groupMembers = await db - .select({ userId: members.userId }) - .from(members) - .where(eq(members.groupId, Number(req.params.groupId))) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { password, ...columns } = getTableColumns(users) - const rows = await db - .select(columns) - .from(users) + const groupId = Number(req.params.groupId) + if (req.group?.role === 'owner' && req.user!.id === req.body.ownerId) { + return badRequest(res, 'New owner id should be of different user') + } + await db.transaction(async tx => { + if (req.group?.role === 'owner') { + await tx + .update(members) + .set({ role: 'owner' }) + .where( + and( + eq(members.groupId, groupId), + eq(members.userId, req.body.newOwnerId), + ), + ) + } + + await tx + .delete(members) + .where( + and(eq(members.groupId, groupId), eq(members.userId, req.user!.id)), + ) + await deleteMemberRole(groupId, req.user!.id) + }) + res.json({ message: 'Left the room successfully' }) + } catch (error) { + next(error) + } +} + +export const kickMember: RequestHandler = async (req, res, next) => { + try { + if (req.user!.id === Number(req.params.memberId)) { + return badRequest(res, 'Cannot kick yourself') + } + await db + .delete(members) .where( and( - like(users.username, `%${req.query.query}%`), - notInArray( - users.id, - groupMembers.map(m => m.userId), - ), + eq(members.groupId, Number(req.params.groupId)), + eq(members.userId, Number(req.params.memberId)), ), ) - .limit(Number(req.query.limit) || 5) - .orderBy(users.username) - - res.json(rows) + await deleteMemberRole( + Number(req.params.groupId), + Number(req.params.memberId), + ) + res.json({ message: 'Kicked member successfully' }) } catch (error) { next(error) } diff --git a/server/src/modules/groups/groups.routes.ts b/server/src/modules/groups/groups.routes.ts index 69bdbf7..daec6e3 100644 --- a/server/src/modules/groups/groups.routes.ts +++ b/server/src/modules/groups/groups.routes.ts @@ -11,6 +11,8 @@ import { deleteGroup, getGroup, getNonGroupMembers, + kickMember, + leaveGroup, listGroups, } from './groups.controller' @@ -22,6 +24,13 @@ router.get('/', listGroups) router.get('/:groupId', hasGroupPermission('member'), getGroup) router.delete('/:groupId', hasGroupPermission('owner'), deleteGroup) +router.delete('/:groupId/leave', hasGroupPermission('member'), leaveGroup) +router.delete( + '/:groupId/members/:memberId', + hasGroupPermission('admin'), + kickMember, +) + router.post('/:groupId/members', hasGroupPermission('admin'), addGroupMembers) router.get('/:groupId/members', hasGroupPermission('member'), getGroupMembers) router.get( diff --git a/server/src/redis/handlers.ts b/server/src/redis/handlers.ts index e8f2955..e377c12 100644 --- a/server/src/redis/handlers.ts +++ b/server/src/redis/handlers.ts @@ -41,11 +41,16 @@ export const getMemberRole = (groupId: number, userId: number) => { return redisClient.hget(cacheKey, userId.toString()) } -export const deleteGroupMembersRoles = (groupId: number) => { +export const deleteGroupRoles = (groupId: number) => { const cacheKey = redisKeys.MEMBER_ROLES(groupId) return redisClient.hdel(cacheKey) } +export const deleteMemberRole = (groupId: number, memberId: number) => { + const cacheKey = redisKeys.MEMBER_ROLES(groupId) + return redisClient.hdel(cacheKey, memberId.toString()) +} + // ONLINE USER export const getOnlineUsers = async () => {