From 824e1601130bd90ec11e2939b1ee36c4b1be2c01 Mon Sep 17 00:00:00 2001 From: Konstantinos Kopanidis Date: Sun, 21 Jun 2026 20:24:44 +0300 Subject: [PATCH] fix(chat): invitation email hook paths and redirect flow Email invitation links hit a hook that required an authenticated user and returned plain text instead of redirecting. Align the registered hook path with email URLs, allow optional auth on the hook, and redirect unauthenticated users to login with a return URL while sending authenticated users to configurable accept/decline destinations. --- modules/chat/src/config/config.ts | 20 +++ modules/chat/src/routes/InvitationRoutes.ts | 161 ++++++++++++++------ modules/chat/src/utils/index.ts | 20 ++- 3 files changed, 151 insertions(+), 50 deletions(-) diff --git a/modules/chat/src/config/config.ts b/modules/chat/src/config/config.ts index 1f5ced022..24b28a88f 100644 --- a/modules/chat/src/config/config.ts +++ b/modules/chat/src/config/config.ts @@ -37,5 +37,25 @@ export default { format: 'Boolean', default: false, }, + redirect: { + login_uri: { + doc: 'Where unauthenticated users are redirected before accepting or declining. The hook URL is passed as redirectUri.', + format: 'String', + default: '', + optional: true, + }, + accept_uri: { + doc: 'Where users are redirected after accepting an invitation. Supports {roomId} placeholder.', + format: 'String', + default: '', + optional: true, + }, + decline_uri: { + doc: 'Where users are redirected after declining an invitation.', + format: 'String', + default: '', + optional: true, + }, + }, }, }; diff --git a/modules/chat/src/routes/InvitationRoutes.ts b/modules/chat/src/routes/InvitationRoutes.ts index c53d3bba0..fe97379a5 100644 --- a/modules/chat/src/routes/InvitationRoutes.ts +++ b/modules/chat/src/routes/InvitationRoutes.ts @@ -9,11 +9,14 @@ import { import { ConduitNumber, ConduitString, + ConfigController, RoutingManager, } from '@conduitplatform/module-tools'; import { ChatRoom, InvitationToken } from '../models/index.js'; +import { buildInvitationHookUrl } from '../utils/index.js'; import { isNil } from 'lodash-es'; import { status } from '@grpc/grpc-js'; +import { Config } from '../config/index.js'; export class InvitationRoutes { constructor( @@ -24,7 +27,7 @@ export class InvitationRoutes { declareRoutes() { this.routingManager.route( { - path: '/hook/invitations/:answer/:invitationToken', + path: '/hook/chat/invitations/:answer/:invitationToken', description: `A webhook used to respond to a chat room invitation requiring the invitation token.`, action: ConduitRouteActions.GET, @@ -32,12 +35,16 @@ export class InvitationRoutes { answer: ConduitString.Required, invitationToken: ConduitString.Required, }, + middlewares: ['authMiddleware?'], rateLimit: { maxRequests: 50, resetInterval: 3600, }, }, - new ConduitRouteReturnDefinition('InvitationResponse', 'String'), + new ConduitRouteReturnDefinition('InvitationHookResponse', { + result: ConduitString.Optional, + redirect: ConduitString.Optional, + }), this.answerInvitationFromHook.bind(this), ); this.routingManager.route( @@ -114,36 +121,11 @@ export class InvitationRoutes { if (isNil(invitationTokenDoc)) { throw new GrpcError(status.NOT_FOUND, 'Invitation not valid'); } - let message; - const receiver = user._id; - const accepted = answer === 'accept'; - const chatRoom = await ChatRoom.getInstance().findOne({ - _id: invitationTokenDoc.room as string, - }); - if (isNil(chatRoom)) { - throw new GrpcError(status.NOT_FOUND, 'Chat room does not exist'); - } - if (!isNil(invitationTokenDoc) && accepted) { - chatRoom.participants.push(user); - await ChatRoom.getInstance().findByIdAndUpdate(chatRoom._id, chatRoom); - message = 'Invitation accepted'; - this.grpcSdk.router?.socketPush({ - event: 'join-room', - receivers: [user._id], - rooms: [chatRoom._id], - }); - this.grpcSdk.router?.socketPush({ - event: 'room-joined', - receivers: [user._id], - rooms: [], - data: JSON.stringify({ room: chatRoom._id, roomName: chatRoom.name }), - }); - } else { - message = 'Invitation declined'; - } - - const query = { $and: [{ room: chatRoom._id }, { receiver: receiver }] }; - await InvitationToken.getInstance().deleteMany(query); + const message = await this.processInvitationAnswer( + invitationTokenDoc, + answer, + user._id, + ); return message; } @@ -152,15 +134,48 @@ export class InvitationRoutes { ): Promise { const { invitationToken, answer } = call.request.params; const { user } = call.request.context; + const config = ConfigController.getInstance().config; + + if (answer !== 'accept' && answer !== 'decline') { + throw new GrpcError(status.INVALID_ARGUMENT, 'Answer must be accept or decline'); + } + const invitationTokenDoc: InvitationToken | null = await InvitationToken.getInstance().findOne({ token: invitationToken, - receiver: user._id, }); if (isNil(invitationTokenDoc)) { throw new GrpcError(status.NOT_FOUND, 'Invitation not valid'); } - const roomId: string = invitationTokenDoc?.room as string; + + const roomId = invitationTokenDoc.room as string; + const hookUrl = await this.resolveInvitationHookUrl(answer, invitationToken); + + if (isNil(user)) { + return this.redirectUnauthenticatedUser(hookUrl, config); + } + + if (user._id !== invitationTokenDoc.receiver) { + throw new GrpcError( + status.PERMISSION_DENIED, + 'Invitation is not for the current user', + ); + } + + const message = await this.processInvitationAnswer( + invitationTokenDoc, + answer, + user._id, + ); + return this.redirectAfterAnswer(answer, roomId, config, message); + } + + private async processInvitationAnswer( + invitationTokenDoc: InvitationToken, + answer: string, + receiverId: string, + ): Promise { + const roomId = invitationTokenDoc.room as string; const chatRoom = await ChatRoom.getInstance() .findOne({ _id: roomId }) .catch((e: Error) => { @@ -169,39 +184,89 @@ export class InvitationRoutes { if (isNil(chatRoom)) { throw new GrpcError(status.NOT_FOUND, 'Chat room does not exist'); } - const receiver = invitationTokenDoc.receiver; - if ((chatRoom.participants as string[]).indexOf(receiver as string) !== -1) { - throw new GrpcError( - status.NOT_FOUND, - `User is already a member of target chat room`, - ); - } + + const receiver = invitationTokenDoc.receiver as string; const accepted = answer === 'accept'; - let message; - if (!isNil(invitationTokenDoc) && accepted) { - (chatRoom.participants as string[]).push(receiver as string); + const alreadyMember = (chatRoom.participants as string[]).includes(receiver); + + if (alreadyMember) { + await InvitationToken.getInstance().deleteMany({ + $and: [{ room: roomId }, { receiver }], + }); + return accepted ? 'Invitation accepted' : 'Invitation declined'; + } + + let message: string; + if (accepted) { + (chatRoom.participants as string[]).push(receiver); await ChatRoom.getInstance().findByIdAndUpdate(roomId, chatRoom); message = 'Invitation accepted'; this.grpcSdk.router?.socketPush({ event: 'join-room', - receivers: [user._id], + receivers: [receiverId], rooms: [chatRoom._id], }); this.grpcSdk.router?.socketPush({ event: 'room-joined', - receivers: [user._id], + receivers: [receiverId], rooms: [], data: JSON.stringify({ room: chatRoom._id, roomName: chatRoom.name }), }); } else { message = 'Invitation declined'; } - const query = { $and: [{ room: roomId }, { receiver: receiver }] }; - await InvitationToken.getInstance().deleteMany(query); + + await InvitationToken.getInstance().deleteMany({ + $and: [{ room: roomId }, { receiver }], + }); return message; } + private async resolveInvitationHookUrl( + answer: string, + invitationToken: string, + ): Promise { + const routerConfig = await this.grpcSdk.config.get('router'); + return buildInvitationHookUrl( + routerConfig.hostUrl, + answer as 'accept' | 'decline', + invitationToken, + ); + } + + private redirectUnauthenticatedUser( + hookUrl: string, + config: Config, + ): UnparsedRouterResponse { + const loginUri = config.explicit_room_joins.redirect.login_uri?.replace(/\/$/, ''); + if (!loginUri) { + throw new GrpcError( + status.FAILED_PRECONDITION, + 'Invitation login redirect is not configured', + ); + } + const redirectUrl = new URL(loginUri); + redirectUrl.searchParams.set('redirectUri', hookUrl); + return { redirect: redirectUrl.toString() }; + } + + private redirectAfterAnswer( + answer: string, + roomId: string, + config: Config, + fallbackMessage: string, + ): UnparsedRouterResponse { + const redirectTemplate = + answer === 'accept' + ? config.explicit_room_joins.redirect.accept_uri + : config.explicit_room_joins.redirect.decline_uri; + if (!redirectTemplate) { + return fallbackMessage; + } + return { redirect: redirectTemplate.replace(/\{roomId\}/g, roomId) }; + } + async cancelInvitation(call: ParsedRouterRequest): Promise { const { user } = call.request.context; const { id } = call.request.params; diff --git a/modules/chat/src/utils/index.ts b/modules/chat/src/utils/index.ts index d97dcaa22..4f483e563 100644 --- a/modules/chat/src/utils/index.ts +++ b/modules/chat/src/utils/index.ts @@ -63,8 +63,16 @@ export async function sendInvitations( ); if (sendEmail) { const result = { invitationToken, hostUrl: url }; - const acceptLink = `${result.hostUrl}/hook/chat/invitations/accept/${result.invitationToken.token}`; - const declineLink = `${result.hostUrl}/hook/chat/invitations/decline/${result.invitationToken.token}`; + const acceptLink = buildInvitationHookUrl( + result.hostUrl, + 'accept', + result.invitationToken.token, + ); + const declineLink = buildInvitationHookUrl( + result.hostUrl, + 'decline', + result.invitationToken.token, + ); const roomName = room.name; const userName = sender.email; await grpcSdk @@ -95,6 +103,14 @@ export async function sendInvitations( return 'Invitations sent'; } +export function buildInvitationHookUrl( + hostUrl: string, + answer: 'accept' | 'decline', + invitationToken: string, +) { + return `${hostUrl.replace(/\/$/, '')}/hook/chat/invitations/${answer}/${invitationToken}`; +} + export function populateArray(pop: string | string[] | undefined) { if (!pop) return pop; if (typeof pop === 'string' && pop.indexOf(',') !== -1) {