diff --git a/.env.template b/.env.template index c61911b..ecfff38 100644 --- a/.env.template +++ b/.env.template @@ -1,5 +1,7 @@ PORT=3006 PAYMENTS_URL=http://payments-api:8003 + +GATEWAY_HEADER_TOKEN=some-gateway-token JWT_SECRET=jwt-secret JITSI_SECRET=jitsi-secret-base64 JITSI_APP_ID=jitsi-app-id diff --git a/package.json b/package.json index fa8ddf6..8f522b4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/swagger": "^11.1.0", "agentkeepalive": "^4.6.0", "axios": "^1.8.4", + "body-parser": "^2.2.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cross-env": "^7.0.3", diff --git a/src/app.module.ts b/src/app.module.ts index ec8db6e..559621e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import configuration from './config/configuration'; import { SequelizeModule, SequelizeModuleOptions } from '@nestjs/sequelize'; import { format } from 'sql-formatter'; import { UserModule } from './modules/user/user.module'; +import { WebhookModule } from './modules/webhook/webhook.module'; const defaultDbConfig = ( configService: ConfigService, @@ -75,6 +76,7 @@ const defaultDbConfig = ( }), CallModule, UserModule, + WebhookModule, ], controllers: [], }) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 22383f3..af07b88 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -17,6 +17,13 @@ export default () => ({ appId: process.env.JITSI_APP_ID, apiKey: process.env.JITSI_API_KEY, }, + jitsiWebhook: { + secret: process.env.JITSI_WEBHOOK_SECRET, + events: { + participantLeft: + process.env.JITSI_WEBHOOK_PARTICIPANT_LEFT_ENABLED === 'true', + }, + }, database: { host: process.env.DB_HOSTNAME, host2: process.env.DB_HOSTNAME2, diff --git a/src/modules/call/call.controller.spec.ts b/src/modules/call/call.controller.spec.ts index 2d99b61..647e625 100644 --- a/src/modules/call/call.controller.spec.ts +++ b/src/modules/call/call.controller.spec.ts @@ -98,8 +98,7 @@ describe('Testing Call Endpoints', () => { mockUserToken.payload.email, ); expect(callUseCase.createCallAndRoom).toHaveBeenCalledWith( - mockUserToken.payload.uuid, - mockUserToken.payload.email, + mockUserToken.payload, ); expect(result).toEqual(mockResponse); }); @@ -143,6 +142,7 @@ describe('Testing Call Endpoints', () => { expect(result).toEqual(mockJoinCallResponse); expect(callUseCase.joinCall).toHaveBeenCalledWith(mockRoomId, { userId: user.uuid, + email: user.email, name: mockJoinCallDto.name, lastName: mockJoinCallDto.lastName, anonymous: mockJoinCallDto.anonymous, @@ -161,6 +161,7 @@ describe('Testing Call Endpoints', () => { expect(result).toEqual(mockJoinCallResponse); expect(callUseCase.joinCall).toHaveBeenCalledWith(mockRoomId, { userId: undefined, + email: mockJoinCallDto.email, name: mockJoinCallDto.name, lastName: mockJoinCallDto.lastName, anonymous: true, @@ -185,6 +186,7 @@ describe('Testing Call Endpoints', () => { name: anonymousDto.name, lastName: anonymousDto.lastName, anonymous: true, + email: mockUserPayload.email, }); }); @@ -252,6 +254,7 @@ describe('Testing Call Endpoints', () => { expect(callUseCase.joinCall).toHaveBeenCalledWith(mockRoomId, { userId: mockUserToken.payload.uuid, + email: mockUserToken.payload.email, name: undefined, lastName: undefined, anonymous: false, diff --git a/src/modules/call/call.controller.ts b/src/modules/call/call.controller.ts index acdd291..8903f17 100644 --- a/src/modules/call/call.controller.ts +++ b/src/modules/call/call.controller.ts @@ -71,7 +71,7 @@ export class CallController { try { await this.callUseCase.validateUserHasNoActiveRoom(uuid, email); - const call = await this.callUseCase.createCallAndRoom(uuid, email); + const call = await this.callUseCase.createCallAndRoom(user); return call; } catch (error) { const err = error as Error; @@ -124,13 +124,14 @@ export class CallController { @User() user: UserTokenData['payload'], @Body() joinCallDto?: JoinCallDto, ): Promise { - const { uuid } = user || {}; + const { uuid, email } = user || {}; return await this.callUseCase.joinCall(roomId, { userId: uuid, name: joinCallDto?.name, lastName: joinCallDto?.lastName, anonymous: joinCallDto?.anonymous || !user, + email: email, }); } diff --git a/src/modules/call/call.service.spec.ts b/src/modules/call/call.service.spec.ts index b44c203..31d135d 100644 --- a/src/modules/call/call.service.spec.ts +++ b/src/modules/call/call.service.spec.ts @@ -7,6 +7,7 @@ import * as uuid from 'uuid'; import configuration from '../../config/configuration'; import { PaymentService, Tier } from '../../externals/payments.service'; import { CallService } from './call.service'; +import { mockUserPayload } from './fixtures'; jest.mock('uuid'); jest.mock('jsonwebtoken'); @@ -39,6 +40,7 @@ describe('Call service', () => { }); it('When the user has meet enabled, then a call token should be created', async () => { + const userPayload = mockUserPayload; const getUserTierSpy = jest .spyOn(paymentService, 'getUserTier') .mockResolvedValue({ @@ -53,7 +55,7 @@ describe('Call service', () => { (uuid.v4 as jest.Mock).mockReturnValue('test-room-id'); (jwt.sign as jest.Mock).mockReturnValue('test-jitsi-token'); - const result = await callService.createCallToken('user-123'); + const result = await callService.createCallToken(userPayload); expect(result).toEqual({ appId: 'jitsi-app-id', @@ -62,10 +64,11 @@ describe('Call service', () => { paxPerCall: 10, }); - expect(getUserTierSpy).toHaveBeenCalledWith('user-123'); + expect(getUserTierSpy).toHaveBeenCalledWith(userPayload.uuid); }); it('When the user does not have meet enabled, then an error indicating so is thrown', async () => { + const userPayload = mockUserPayload; const getUserTierSpy = jest .spyOn(paymentService, 'getUserTier') .mockResolvedValue({ @@ -77,16 +80,17 @@ describe('Call service', () => { }, } as Tier); - await expect(callService.createCallToken('user-123')).rejects.toThrow( + await expect(callService.createCallToken(userPayload)).rejects.toThrow( UnauthorizedException, ); - expect(getUserTierSpy).toHaveBeenCalledWith('user-123'); + expect(getUserTierSpy).toHaveBeenCalledWith(userPayload.uuid); }); describe('createCallTokenForParticipant', () => { it('should create a token for a registered user (non-moderator)', () => { - const userId = 'test-user-id'; + const userPayload = mockUserPayload; + const userId = userPayload.uuid; const roomId = 'test-room-id'; const isAnonymous = false; const isModerator = false; @@ -99,6 +103,7 @@ describe('Call service', () => { roomId, isAnonymous, isModerator, + userPayload, ); expect(result).toStrictEqual({ @@ -145,6 +150,7 @@ describe('Call service', () => { roomId, isAnonymous, isModerator, + mockUserPayload, ); expect(result).toStrictEqual({ diff --git a/src/modules/call/call.service.ts b/src/modules/call/call.service.ts index 53bc0d8..bd8bfa0 100644 --- a/src/modules/call/call.service.ts +++ b/src/modules/call/call.service.ts @@ -13,6 +13,10 @@ import { getJitsiJWTPayload, getJitsiJWTSecret, } from '../../lib/jitsi'; +import { UserTokenData } from '../auth/dto/user.dto'; +import { UserDataForToken } from '../user/user.attributes'; +import { User } from '../user/user.domain'; + export function SignWithRS256AndHeader( payload: object, secret: string, @@ -51,7 +55,8 @@ export class CallService { .getUserTier(userUuid) .catch((err) => { Logger.error( - `Failed to retrieve user tier from payment service: ${err.message}`, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `Failed to retrieve user tier from payment service: ${err?.message}`, ); throw err; }); @@ -61,8 +66,8 @@ export class CallService { return meetFeature; } - async createCallToken(userUuid: string) { - const meetFeatures = await this.getMeetFeatureConfigForUser(userUuid); + async createCallToken(user: User | UserTokenData['payload']) { + const meetFeatures = await this.getMeetFeatureConfigForUser(user.uuid); if (!meetFeatures.enabled) throw new UnauthorizedException( @@ -72,9 +77,9 @@ export class CallService { const newRoom = v4(); const token = generateJitsiJWT( { - id: userUuid, - email: 'example@inxt.com', - name: 'Example', + id: user.uuid, + email: user.email, + name: `${user.name} ${user.lastname}`, }, newRoom, true, @@ -93,12 +98,13 @@ export class CallService { roomId: string, isAnonymous: boolean, isModerator: boolean, + user?: UserDataForToken, ) { const token = generateJitsiJWT( { id: userId, - email: isAnonymous ? 'anonymous@inxt.com' : 'user@inxt.com', - name: isAnonymous ? 'Anonymous' : 'User', + email: isAnonymous ? 'anonymous@inxt.com' : user.email, + name: isAnonymous ? 'Anonymous' : `${user.name} ${user?.lastName}`, }, roomId, isModerator, diff --git a/src/modules/call/call.usecase.spec.ts b/src/modules/call/call.usecase.spec.ts index f7836ab..a57bb67 100644 --- a/src/modules/call/call.usecase.spec.ts +++ b/src/modules/call/call.usecase.spec.ts @@ -15,6 +15,10 @@ import { CallService } from './call.service'; import { CallUseCase } from './call.usecase'; import { mockCallResponse, mockRoomData, mockUserPayload } from './fixtures'; +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'generated-uuid'), +})); + describe('CallUseCase', () => { let callUseCase: CallUseCase; let callService: DeepMocked; @@ -103,12 +107,9 @@ describe('CallUseCase', () => { .spyOn(callUseCase, 'createRoomForCall') .mockResolvedValueOnce(); - const result = await callUseCase.createCallAndRoom( - mockUserPayload.uuid, - mockUserPayload.email, - ); + const result = await callUseCase.createCallAndRoom(mockUserPayload); - expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload.uuid); + expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload); expect(createRoomForCallSpy).toHaveBeenCalledWith( mockCallResponse, mockUserPayload.uuid, @@ -124,13 +125,10 @@ describe('CallUseCase', () => { .mockRejectedValueOnce(error); await expect( - callUseCase.createCallAndRoom( - mockUserPayload.uuid, - mockUserPayload.email, - ), + callUseCase.createCallAndRoom(mockUserPayload), ).rejects.toThrow(error); - expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload.uuid); + expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload); }); }); @@ -274,6 +272,13 @@ describe('CallUseCase', () => { roomId, false, false, + { + anonymous: false, + email: undefined, + lastName: 'Last Name', + name: 'Test User', + userId: 'test-user-id', + }, ); expect(result).toEqual({ token: callToken.token, @@ -314,6 +319,13 @@ describe('CallUseCase', () => { roomId, true, false, + { + anonymous: true, + email: undefined, + lastName: undefined, + name: userData.name, + userId: anonymousUserMock.userId, + }, ); expect(result).toEqual({ token: callToken.token, @@ -421,6 +433,144 @@ describe('CallUseCase', () => { }); }); + describe('processUserData', () => { + it('should handle registered user data correctly', async () => { + const roomId = 'test-room-id'; + const userId = 'test-user-id'; + const name = 'Test User'; + const lastName = 'Last Name'; + const callToken = { + token: 'test-call-token', + appId: 'jitsi-app-id', + }; + const openRoomMock = createMock({ + ...mockRoomData, + isClosed: false, + }); + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(openRoomMock); + + const registeredRoomUser = new RoomUser({ + id: 1, + roomId, + userId, + name, + lastName, + anonymous: false, + }); + + roomUserUseCase.addUserToRoom.mockResolvedValueOnce(registeredRoomUser); + const createCallTokenForParticipantSpy = jest + .spyOn(callService, 'createCallTokenForParticipant') + .mockReturnValueOnce(callToken); + + await callUseCase.joinCall(roomId, { + userId, + name, + lastName, + anonymous: false, + }); + + expect(createCallTokenForParticipantSpy).toHaveBeenCalledWith( + userId, + roomId, + false, + false, + { + anonymous: false, + email: undefined, + lastName: 'Last Name', + name: 'Test User', + userId: 'test-user-id', + }, + ); + }); + + it('should handle anonymous user data correctly', async () => { + const roomId = 'test-room-id'; + const name = 'Anonymous User'; + const callToken = { + token: 'test-call-token', + appId: 'jitsi-app-id', + }; + + const openRoomMock = createMock({ + ...mockRoomData, + isClosed: false, + }); + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(openRoomMock); + + const anonymousRoomUser = new RoomUser({ + id: 1, + roomId, + userId: 'generated-id', + name, + anonymous: true, + }); + + const addUserToRoomSpy = jest + .spyOn(roomUserUseCase, 'addUserToRoom') + .mockResolvedValueOnce(anonymousRoomUser); + jest + .spyOn(callService, 'createCallTokenForParticipant') + .mockReturnValueOnce(callToken); + + await callUseCase.joinCall(roomId, { + name, + anonymous: true, + }); + + expect(addUserToRoomSpy).toHaveBeenCalledWith( + roomId, + expect.objectContaining({ + name, + anonymous: true, + }), + ); + }); + + it('should generate user ID when userId is not provided', async () => { + const roomId = 'test-room-id'; + const name = 'User without ID'; + const callToken = { + token: 'test-call-token', + appId: 'jitsi-app-id', + }; + + const openRoomMock = createMock({ + ...mockRoomData, + isClosed: false, + }); + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(openRoomMock); + + const userWithoutId = new RoomUser({ + id: 1, + roomId, + userId: 'generated-id', + name, + anonymous: true, + }); + + const addUserToRoomSpy = jest + .spyOn(roomUserUseCase, 'addUserToRoom') + .mockResolvedValueOnce(userWithoutId); + jest + .spyOn(callService, 'createCallTokenForParticipant') + .mockReturnValueOnce(callToken); + + await callUseCase.joinCall(roomId, { + name, + }); + + expect(addUserToRoomSpy).toHaveBeenCalledWith( + roomId, + expect.objectContaining({ + name, + anonymous: true, + }), + ); + }); + }); + describe('leaveCall', () => { const roomId = 'test-room-id'; const hostId = 'host-user-id'; diff --git a/src/modules/call/call.usecase.ts b/src/modules/call/call.usecase.ts index 00ff8cc..be266f2 100644 --- a/src/modules/call/call.usecase.ts +++ b/src/modules/call/call.usecase.ts @@ -8,12 +8,15 @@ import { NotFoundException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; +import { UserTokenData } from '../auth/dto/user.dto'; import { RoomUserUseCase } from '../room/room-user.usecase'; import { Room } from '../room/room.domain'; import { RoomUseCase } from '../room/room.usecase'; +import { User } from '../user/user.domain'; import { CallService } from './call.service'; import { CreateCallResponseDto } from './dto/create-call.dto'; import { JoinCallResponseDto } from './dto/join-call.dto'; + @Injectable() export class CallUseCase { private readonly logger = new Logger(CallUseCase.name); @@ -54,19 +57,18 @@ export class CallUseCase { } async createCallAndRoom( - uuid: string, - email: string, + user: User | UserTokenData['payload'], ): Promise { try { - const call = await this.callService.createCallToken(uuid); - await this.createRoomForCall(call, uuid, email); - this.logger.log(`Successfully created call for user: ${email}`); + const call = await this.callService.createCallToken(user); + await this.createRoomForCall(call, user.uuid, user.email); + this.logger.log(`Successfully created call for user: ${user.email}`); return call; } catch (error) { const err = error as Error; this.logger.error( `Failed to create call and room: ${err.message}`, - { userId: uuid, email }, + { userId: user.uuid, email: user.email }, err.stack, ); throw err; @@ -135,6 +137,7 @@ export class CallUseCase { name?: string; lastName?: string; anonymous?: boolean; + email?: string; }, ): Promise { try { @@ -161,6 +164,7 @@ export class CallUseCase { roomId, !!roomUser.anonymous, isOwner, + processedUserData, ); if (processedUserData.userId === room.hostId && room.isClosed) { @@ -207,13 +211,15 @@ export class CallUseCase { name?: string; lastName?: string; anonymous?: boolean; + email?: string; }): { userId: string; name?: string; lastName?: string; anonymous: boolean; + email?: string; } { - const { userId, name, lastName, anonymous = false } = userData; + const { userId, name, lastName, anonymous = false, email } = userData; if (anonymous || !userId) { return { @@ -229,6 +235,7 @@ export class CallUseCase { name, lastName, anonymous: false, + email, }; } diff --git a/src/modules/call/dto/join-call.dto.ts b/src/modules/call/dto/join-call.dto.ts index 49ed24c..b26aa6a 100644 --- a/src/modules/call/dto/join-call.dto.ts +++ b/src/modules/call/dto/join-call.dto.ts @@ -26,6 +26,10 @@ export class JoinCallDto { @IsBoolean() @IsOptional() anonymous?: boolean; + + @IsString() + @IsOptional() + email?: string; } export class JoinCallResponseDto { diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index afbb7db..d0a9ad2 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -11,3 +11,9 @@ export interface UserAttributes { updatedAt?: Date; createdAt?: Date; } + +export interface UserDataForToken { + name?: string; + email?: string; + lastName?: string; +} diff --git a/src/modules/webhook/jitsi/interfaces/JitsiGenericWebHookPayload.ts b/src/modules/webhook/jitsi/interfaces/JitsiGenericWebHookPayload.ts new file mode 100644 index 0000000..96f4ae8 --- /dev/null +++ b/src/modules/webhook/jitsi/interfaces/JitsiGenericWebHookPayload.ts @@ -0,0 +1,89 @@ +import { JitsiParticipantLeftWebHookPayload } from './JitsiParticipantLeftData'; + +export enum JitsiGenericWebHookEvent { + ROOM_CREATED = 'ROOM_CREATED', + PARTICIPANT_LEFT = 'PARTICIPANT_LEFT', + PARTICIPANT_LEFT_LOBBY = 'PARTICIPANT_LEFT_LOBBY', + TRANSCRIPTION_UPLOADED = 'TRANSCRIPTION_UPLOADED', + CHAT_UPLOADED = 'CHAT_UPLOADED', + ROOM_DESTROYED = 'ROOM_DESTROYED', + PARTICIPANT_JOINED = 'PARTICIPANT_JOINED', + PARTICIPANT_JOINED_LOBBY = 'PARTICIPANT_JOINED_LOBBY', + RECORDING_STARTED = 'RECORDING_STARTED', + RECORDING_ENDED = 'RECORDING_ENDED', + RECORDING_UPLOADED = 'RECORDING_UPLOADED', + LIVE_STREAM_STARTED = 'LIVE_STREAM_STARTED', + LIVE_STREAM_ENDED = 'LIVE_STREAM_ENDED', + SETTINGS_PROVISIONING = 'SETTINGS_PROVISIONING', + SIP_CALL_IN_STARTED = 'SIP_CALL_IN_STARTED', + SIP_CALL_IN_ENDED = 'SIP_CALL_IN_ENDED', + SIP_CALL_OUT_STARTED = 'SIP_CALL_OUT_STARTED', + SIP_CALL_OUT_ENDED = 'SIP_CALL_OUT_ENDED', + FEEDBACK = 'FEEDBACK', + DIAL_IN_STARTED = 'DIAL_IN_STARTED', + DIAL_IN_ENDED = 'DIAL_IN_ENDED', + DIAL_OUT_STARTED = 'DIAL_OUT_STARTED', + DIAL_OUT_ENDED = 'DIAL_OUT_ENDED', + USAGE = 'USAGE', + SPEAKER_STATS = 'SPEAKER_STATS', + POLL_CREATED = 'POLL_CREATED', + POLL_ANSWER = 'POLL_ANSWER', + REACTIONS = 'REACTIONS', + AGGREGATED_REACTIONS = 'AGGREGATED_REACTIONS', + SCREEN_SHARING_HISTORY = 'SCREEN_SHARING_HISTORY', + VIDEO_SEGMENT_UPLOADED = 'VIDEO_SEGMENT_UPLOADED', + ROLE_CHANGED = 'ROLE_CHANGED', + RTCSTATS_UPLOADED = 'RTCSTATS_UPLOADED', + TRANSCRIPTION_CHUNK_RECEIVED = 'TRANSCRIPTION_CHUNK_RECEIVED', +} + +export interface JitsiGenericWebHookPayload { + idempotencyKey: string; + customerId: string; + appId: string; + eventType: JitsiGenericWebHookEvent; + sessionId: string; + timestamp: number; + fqn: string; + data: T; +} + +export interface JitsiParticipantJoinedData { + moderator: boolean | 'true' | 'false'; + name: string; + group?: string; + email?: string; + id?: string; + participantJid: string; + participantId: string; + avatar?: string; + isBreakout?: boolean; + breakoutRoomId?: string; +} + +export type JitsiParticipantJoinedWebHookPayload = + JitsiGenericWebHookPayload; + +export interface JitsiRoomCreatedData { + conference: string; + isBreakout?: boolean; + breakoutRoomId?: string; +} + +export type JitsiRoomCreatedWebHookPayload = + JitsiGenericWebHookPayload; + +export interface JitsiRoomDestroyedData { + conference: string; + isBreakout?: boolean; + breakoutRoomId?: string; +} + +export type JitsiRoomDestroyedWebHookPayload = + JitsiGenericWebHookPayload; + +export type JitsiWebhookPayload = + | JitsiParticipantLeftWebHookPayload + | JitsiParticipantJoinedWebHookPayload + | JitsiRoomCreatedWebHookPayload + | JitsiRoomDestroyedWebHookPayload; diff --git a/src/modules/webhook/jitsi/interfaces/JitsiParticipantLeftData.ts b/src/modules/webhook/jitsi/interfaces/JitsiParticipantLeftData.ts new file mode 100644 index 0000000..2475f1f --- /dev/null +++ b/src/modules/webhook/jitsi/interfaces/JitsiParticipantLeftData.ts @@ -0,0 +1,23 @@ +import { JitsiGenericWebHookPayload } from './JitsiGenericWebHookPayload'; + +export interface JitsiParticipantLeftData { + moderator: boolean | 'true' | 'false'; + name: string; + group?: string; + email?: string; + id?: string; + participantJid: string; + participantId: string; + avatar?: string; + disconnectReason: + | 'left' + | 'kicked' + | 'unknown' + | 'switch_room' + | 'unrecoverable_error'; + isBreakout?: boolean; + breakoutRoomId?: string; +} + +export type JitsiParticipantLeftWebHookPayload = + JitsiGenericWebHookPayload; diff --git a/src/modules/webhook/jitsi/interfaces/request.interface.ts b/src/modules/webhook/jitsi/interfaces/request.interface.ts new file mode 100644 index 0000000..1800900 --- /dev/null +++ b/src/modules/webhook/jitsi/interfaces/request.interface.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export interface RequestWithRawBody extends Request { + rawBody?: Buffer; +} diff --git a/src/modules/webhook/jitsi/jitsi-webhook.controller.spec.ts b/src/modules/webhook/jitsi/jitsi-webhook.controller.spec.ts new file mode 100644 index 0000000..c551c4e --- /dev/null +++ b/src/modules/webhook/jitsi/jitsi-webhook.controller.spec.ts @@ -0,0 +1,117 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JitsiWebhookPayload } from './interfaces/JitsiGenericWebHookPayload'; +import { JitsiWebhookController } from './jitsi-webhook.controller'; +import { JitsiWebhookService } from './jitsi-webhook.service'; + +describe('JitsiWebhookController', () => { + let controller: JitsiWebhookController; + let service: JitsiWebhookService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [JitsiWebhookController], + providers: [ + { + provide: JitsiWebhookService, + useValue: { + validateWebhookRequest: jest.fn(), + handleParticipantLeft: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(JitsiWebhookController); + service = module.get(JitsiWebhookService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('handleWebhook', () => { + it('should throw UnauthorizedException if webhook validation fails', async () => { + const mockHeaders = { 'x-jitsi-signature': 'test-signature' }; + + const mockPayload = { + eventType: 'PARTICIPANT_LEFT', + } as unknown as JitsiWebhookPayload; + + jest.spyOn(service, 'validateWebhookRequest').mockReturnValue(false); + + await expect( + controller.handleWebhook(mockPayload, mockHeaders), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw BadRequestException if payload is missing eventType', async () => { + const mockHeaders = { 'x-jitsi-signature': 'test-signature' }; + + const mockPayload = {} as unknown as JitsiWebhookPayload; + + jest.spyOn(service, 'validateWebhookRequest').mockReturnValue(true); + + await expect( + controller.handleWebhook(mockPayload, mockHeaders), + ).rejects.toThrow(BadRequestException); + }); + + it('should handle PARTICIPANT_LEFT event', async () => { + const mockHeaders = { 'x-jitsi-signature': 'test-signature' }; + + const mockPayload = { + eventType: 'PARTICIPANT_LEFT', + } as unknown as JitsiWebhookPayload; + + jest.spyOn(service, 'validateWebhookRequest').mockReturnValue(true); + jest + .spyOn(service, 'handleParticipantLeft') + .mockImplementation((): Promise => Promise.resolve()); + + const result = await controller.handleWebhook(mockPayload, mockHeaders); + + expect(result).toEqual({ success: true }); + expect(service.handleParticipantLeft).toHaveBeenCalledWith(mockPayload); + }); + + it('should ignore unhandled event types', async () => { + const mockHeaders = { 'x-jitsi-signature': 'test-signature' }; + + const mockPayload = { + eventType: 'SOME_OTHER_EVENT', + } as unknown as JitsiWebhookPayload; + + jest.spyOn(service, 'validateWebhookRequest').mockReturnValue(true); + + const result = await controller.handleWebhook(mockPayload, mockHeaders); + + expect(result).toEqual({ success: true }); + expect(service.handleParticipantLeft).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException if an error occurs during processing', async () => { + const mockHeaders = { 'x-jitsi-signature': 'test-signature' }; + const mockPayload = { + eventType: 'PARTICIPANT_LEFT', + } as unknown as JitsiWebhookPayload; + const mockError = new Error('Test error'); + + jest.spyOn(service, 'validateWebhookRequest').mockReturnValue(true); + jest + .spyOn(service, 'handleParticipantLeft') + .mockImplementation((): Promise => Promise.reject(mockError)); + + await expect( + controller.handleWebhook(mockPayload, mockHeaders), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/modules/webhook/jitsi/jitsi-webhook.controller.ts b/src/modules/webhook/jitsi/jitsi-webhook.controller.ts new file mode 100644 index 0000000..153339d --- /dev/null +++ b/src/modules/webhook/jitsi/jitsi-webhook.controller.ts @@ -0,0 +1,89 @@ +import { + BadRequestException, + Body, + Controller, + Headers, + Logger, + Post, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + JitsiGenericWebHookEvent, + JitsiWebhookPayload, +} from './interfaces/JitsiGenericWebHookPayload'; +import { JitsiParticipantLeftWebHookPayload } from './interfaces/JitsiParticipantLeftData'; +import { JitsiWebhookService } from './jitsi-webhook.service'; + +@ApiTags('Jitsi Webhook') +@Controller('webhook/jitsi') +export class JitsiWebhookController { + private readonly logger = new Logger(JitsiWebhookController.name); + + constructor(private readonly jitsiWebhookService: JitsiWebhookService) {} + + @Post() + @ApiOperation({ + summary: 'Handle Jitsi webhook events', + description: + 'Endpoint for receiving and processing Jitsi webhook events, specifically PARTICIPANT_LEFT events', + }) + @ApiBody({ + description: 'Webhook event payload from Jitsi', + type: Object, + }) + @ApiResponse({ + status: 200, + description: 'Event processed successfully', + }) + @ApiResponse({ + status: 400, + description: 'Invalid event payload', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized webhook request', + }) + async handleWebhook( + @Body() payload: JitsiWebhookPayload, + @Headers() headers: Record, + ): Promise<{ success: boolean }> { + this.logger.log(`Received webhook event: ${payload.eventType}`); + + if (!this.jitsiWebhookService.validateWebhookRequest(headers, payload)) { + this.logger.warn('Invalid webhook request'); + throw new UnauthorizedException('Invalid webhook request'); + } + + if (!payload?.eventType) { + this.logger.warn('Invalid payload: missing eventType'); + throw new BadRequestException('Invalid payload: missing eventType'); + } + + try { + switch (payload.eventType) { + case JitsiGenericWebHookEvent.PARTICIPANT_LEFT: + await this.jitsiWebhookService.handleParticipantLeft( + payload as JitsiParticipantLeftWebHookPayload, + ); + break; + + default: + this.logger.log( + `Ignoring unhandled event type: ${payload.eventType}`, + ); + break; + } + + return { success: true }; + } catch (error: unknown) { + if (error instanceof Error) { + this.logger.error( + `Error processing webhook event: ${error.message}`, + error.stack, + ); + } + throw new BadRequestException(`Error processing webhook event`); + } + } +} diff --git a/src/modules/webhook/jitsi/jitsi-webhook.module.ts b/src/modules/webhook/jitsi/jitsi-webhook.module.ts new file mode 100644 index 0000000..d2d479a --- /dev/null +++ b/src/modules/webhook/jitsi/jitsi-webhook.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JitsiWebhookController } from './jitsi-webhook.controller'; +import { JitsiWebhookService } from './jitsi-webhook.service'; +import { ConfigModule } from '@nestjs/config'; +import { HttpClientModule } from '../../../externals/http/http.module'; +import { RoomModule } from '../../room/room.module'; + +@Module({ + imports: [ConfigModule, HttpClientModule, RoomModule], + controllers: [JitsiWebhookController], + providers: [JitsiWebhookService], + exports: [JitsiWebhookService], +}) +export class JitsiWebhookModule {} diff --git a/src/modules/webhook/jitsi/jitsi-webhook.service.spec.ts b/src/modules/webhook/jitsi/jitsi-webhook.service.spec.ts new file mode 100644 index 0000000..1a6853c --- /dev/null +++ b/src/modules/webhook/jitsi/jitsi-webhook.service.spec.ts @@ -0,0 +1,473 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as crypto from 'crypto'; +import { RoomUserUseCase } from '../../room/room-user.usecase'; +import { Room } from '../../room/room.domain'; +import { RoomUseCase } from '../../room/room.usecase'; +import { + JitsiGenericWebHookEvent, + JitsiWebhookPayload, +} from './interfaces/JitsiGenericWebHookPayload'; +import { JitsiParticipantLeftWebHookPayload } from './interfaces/JitsiParticipantLeftData'; +import { JitsiWebhookService } from './jitsi-webhook.service'; + +jest.mock('crypto', () => { + const originalModule = jest.requireActual('crypto'); + return { + ...originalModule, + createHmac: jest.fn().mockImplementation(() => ({ + update: jest.fn().mockReturnThis(), + digest: jest.fn().mockReturnValue('mocked-signature'), + })), + timingSafeEqual: jest.fn() as jest.MockedFunction< + typeof crypto.timingSafeEqual + >, + }; +}); + +describe('JitsiWebhookService', () => { + let service: JitsiWebhookService; + let roomUseCase: DeepMocked; + let roomUserUseCase: DeepMocked; + let configService: DeepMocked; + + const minimalRoom = new Room({ + id: 'test-room-id', + maxUsersAllowed: 10, + hostId: 'host-id', + }); + + beforeEach(async () => { + roomUseCase = createMock(); + roomUserUseCase = createMock(); + configService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JitsiWebhookService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: RoomUseCase, + useValue: roomUseCase, + }, + { + provide: RoomUserUseCase, + useValue: roomUserUseCase, + }, + ], + }).compile(); + + service = module.get(JitsiWebhookService); + + // Mock logger + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleParticipantLeft', () => { + it('should handle participant left event successfully', async () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(minimalRoom); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(removeUserFromRoomSpy).toHaveBeenCalledWith( + 'test-participant-id', + minimalRoom, + ); + }); + + it('should close room when participant owner left the call', async () => { + configService.get.mockImplementation((key) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + const ownerRoom = new Room({ + id: 'test-room-id', + hostId: 'test-participant-id', + maxUsersAllowed: 10, + isClosed: false, + }); + + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(ownerRoom); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const closeRoomSpy = jest + .spyOn(roomUseCase, 'closeRoom') + .mockResolvedValueOnce(undefined); + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(closeRoomSpy).toHaveBeenCalledWith('test-room-id'); + + expect(removeUserFromRoomSpy).toHaveBeenCalledWith( + 'test-participant-id', + ownerRoom, + ); + }); + + it('should not close room when participant that is not the owner left the call', async () => { + configService.get.mockImplementation((key) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + const ownerRoom = new Room({ + id: 'test-room-id', + hostId: 'test-participant-id-not-owner', + maxUsersAllowed: 10, + isClosed: false, + }); + + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(ownerRoom); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const closeRoomSpy = jest + .spyOn(roomUseCase, 'closeRoom') + .mockResolvedValueOnce(undefined); + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(closeRoomSpy).not.toHaveBeenCalled(); + + expect(removeUserFromRoomSpy).toHaveBeenCalledWith( + 'test-participant-id', + ownerRoom, + ); + }); + + it('should skip handling when participantLeftEnabled is false', async () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.events.participantLeft') return false; + return undefined; + }); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(removeUserFromRoomSpy).not.toHaveBeenCalled(); + }); + + it('should handle missing room ID in FQN', async () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: '', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(removeUserFromRoomSpy).not.toHaveBeenCalled(); + }); + + it('should handle missing participant ID', async () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: '', // Empty participant ID + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const removeUserFromRoomSpy = jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockResolvedValueOnce(undefined); + + await service.handleParticipantLeft(mockEvent); + + expect(removeUserFromRoomSpy).not.toHaveBeenCalled(); + }); + + it('should handle errors thrown during processing', async () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.events.participantLeft') return true; + return undefined; + }); + + roomUseCase.getRoomByRoomId.mockResolvedValueOnce(minimalRoom); + + const mockEvent: JitsiParticipantLeftWebHookPayload = { + idempotencyKey: 'test-key', + customerId: 'customer-id', + appId: 'app-id', + eventType: JitsiGenericWebHookEvent.PARTICIPANT_LEFT, + sessionId: 'session-id', + timestamp: Date.now(), + fqn: 'app-id/test-room-id', + data: { + moderator: 'false', + name: 'Test User', + disconnectReason: 'left', + id: 'test-participant-id', + participantJid: 'test-jid', + participantId: 'test-participant-id', + }, + }; + + const error = new Error('Failed to process'); + jest + .spyOn(roomUserUseCase, 'removeUserFromRoom') + .mockRejectedValueOnce(error); + + await expect(service.handleParticipantLeft(mockEvent)).rejects.toThrow( + error, + ); + }); + }); + + describe('validateWebhookRequest', () => { + const mockPayload = { + eventType: 'PARTICIPANT_LEFT', + } as unknown as JitsiWebhookPayload; + + it('should skip validation if webhook secret is not configured', () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.secret') return undefined; + return defaultValue; + }); + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const headers = { 'content-type': 'application/json' }; + + expect(service.validateWebhookRequest(headers, mockPayload)).toBe(true); + }); + + it('should fail validation if signature is missing', () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.secret') return 'webhook-secret'; + return defaultValue; + }); + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const headers = { 'content-type': 'application/json' }; + const rawBody = JSON.stringify({ test: 'data' }); + + expect(service.validateWebhookRequest(headers, mockPayload)).toBe(false); + }); + + it('should fail validation if raw body is missing', () => { + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.secret') return 'webhook-secret'; + return defaultValue; + }); + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const headers = { + 'content-type': 'application/json', + 'x-jaas-signature': 'signature', + }; + + expect(service.validateWebhookRequest(headers, mockPayload)).toBe(false); + }); + + it('should validate correctly with valid signature', () => { + const secret = 'webhook-secret'; + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.secret') return secret; + return defaultValue; + }); + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const signature = + 't=1757430085,v1=LnyXpAysJpOLDj6kZ43+QrzcqpXcPW/do7LlSCfhVVs='; + + const headers = { + 'content-type': 'application/json', + 'x-jaas-signature': signature, + }; + + (crypto.timingSafeEqual as jest.Mock).mockReturnValue(true); + + expect(service.validateWebhookRequest(headers, mockPayload)).toBe(true); + }); + + it('should fail validation with invalid signature', () => { + const secret = 'webhook-secret'; + configService.get.mockImplementation((key, defaultValue) => { + if (key === 'jitsiWebhook.secret') return secret; + return defaultValue; + }); + + service = new JitsiWebhookService( + configService, + roomUseCase, + roomUserUseCase, + ); + + const headers = { + 'content-type': 'application/json', + 'x-jaas-signature': 'invalid-signature', + }; + + (crypto.timingSafeEqual as jest.Mock).mockReturnValue(false); + + expect(service.validateWebhookRequest(headers, mockPayload)).toBe(false); + }); + }); +}); diff --git a/src/modules/webhook/jitsi/jitsi-webhook.service.ts b/src/modules/webhook/jitsi/jitsi-webhook.service.ts new file mode 100644 index 0000000..663e1c5 --- /dev/null +++ b/src/modules/webhook/jitsi/jitsi-webhook.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { RoomUserUseCase } from '../../room/room-user.usecase'; +import { RoomUseCase } from '../../room/room.usecase'; +import { JitsiWebhookPayload } from './interfaces/JitsiGenericWebHookPayload'; +import { JitsiParticipantLeftWebHookPayload } from './interfaces/JitsiParticipantLeftData'; +@Injectable() +export class JitsiWebhookService { + private readonly logger = new Logger(JitsiWebhookService.name); + private readonly webhookSecret: string | undefined; + private readonly participantLeftEnabled: boolean; + + constructor( + private readonly configService: ConfigService, + private readonly roomUseCase: RoomUseCase, + private readonly roomUserUseCase: RoomUserUseCase, + ) { + this.webhookSecret = this.configService.get('jitsiWebhook.secret'); + this.participantLeftEnabled = this.configService.get( + 'jitsiWebhook.events.participantLeft', + true, + ); + } + + /** + * Handles the PARTICIPANT_LEFT event from Jitsi + * @param payload The webhook payload + * @returns A promise that resolves when the event is handled + */ + async handleParticipantLeft( + payload: JitsiParticipantLeftWebHookPayload, + ): Promise { + if (!this.participantLeftEnabled) { + this.logger.log( + 'PARTICIPANT_LEFT event handling is disabled in configuration', + ); + return; + } + + try { + this.logger.log( + `Handling PARTICIPANT_LEFT event for participant: ${payload.data.id}`, + ); + + const roomId = this.extractRoomId(payload.fqn); + + if (!roomId) { + this.logger.warn(`Could not extract room ID from FQN: ${payload.fqn}`); + return; + } + + const participantId = payload.data.id; + + if (!participantId) { + this.logger.warn('Participant ID not found in payload'); + return; + } + + const room = await this.roomUseCase.getRoomByRoomId(roomId); + + if (!room) { + this.logger.warn(`Room with ID ${roomId} not found`); + return; + } + + const isOwner = participantId === room.hostId; + if (isOwner) await this.roomUseCase.closeRoom(roomId); + + await this.roomUserUseCase.removeUserFromRoom(participantId, room); + + this.logger.log( + `Successfully processed PARTICIPANT_LEFT event for participant ${participantId} in room ${roomId}`, + ); + } catch (error) { + this.logger.error('Error handling PARTICIPANT_LEFT event', error); + throw error; + } + } + + /** + * Extracts the room ID from the Jitsi Fully Qualified Name (FQN) + * @param fqn The fully qualified name in format "appId/roomName" + * @returns The room name/ID + */ + private extractRoomId(fqn: string): string | null { + if (!fqn) { + return null; + } + + const parts = fqn.split('/'); + if (parts.length < 2) { + return null; + } + + return parts[parts.length - 1]; + } + + /** + * Validates if a webhook request is from Jitsi using JaaS signature format + * @param headers The request headers + * @param payload The webhook payload + * @returns True if the request is valid + */ + validateWebhookRequest( + headers: Record, + payload: JitsiWebhookPayload, + ): boolean { + if (!this.webhookSecret) { + this.logger.warn('Webhook secret not configured, skipping validation'); + return true; + } + + const signature = headers['x-jaas-signature']; + if (!signature) { + this.logger.warn('No Jitsi signature found in headers'); + return false; + } + + if (!payload) { + this.logger.warn('No payload provided for signature validation'); + return false; + } + + try { + // Parse JaaS signature format: t=timestamp,v1=signature + const parts = signature.split(','); + let timestamp: string | undefined; + let v1Signature: string | undefined; + + for (const part of parts) { + const [prefix, value] = part.split('=', 2); + if (prefix === 't' && value) { + timestamp = value; + } else if (prefix === 'v1' && value) { + v1Signature = value; + } + } + + if (!timestamp || !v1Signature) { + this.logger.warn('Invalid JaaS signature format'); + return false; + } + + const signedPayload = `${timestamp}.${JSON.stringify(payload)}`; + const expectedSignature = crypto + .createHmac('sha256', this.webhookSecret) + .update(signedPayload, 'utf8') + .digest('base64'); + + return crypto.timingSafeEqual( + Buffer.from(v1Signature, 'base64'), + Buffer.from(expectedSignature, 'base64'), + ); + } catch (error) { + this.logger.error('Error validating webhook signature', error); + return false; + } + } +} diff --git a/src/modules/webhook/webhook.module.ts b/src/modules/webhook/webhook.module.ts new file mode 100644 index 0000000..7a0bb18 --- /dev/null +++ b/src/modules/webhook/webhook.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { JitsiWebhookModule } from './jitsi/jitsi-webhook.module'; + +@Module({ + imports: [JitsiWebhookModule], + exports: [JitsiWebhookModule], +}) +export class WebhookModule {} diff --git a/yarn.lock b/yarn.lock index a24a897..0a18f57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3317,6 +3317,21 @@ body-parser@^2.0.1: raw-body "^3.0.0" type-is "^2.0.0" +body-parser@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa" + integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.0" + http-errors "^2.0.0" + iconv-lite "^0.6.3" + on-finished "^2.4.1" + qs "^6.14.0" + raw-body "^3.0.0" + type-is "^2.0.0" + bowser@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" @@ -4842,7 +4857,7 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -iconv-lite@0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==