From a26f2a754b7fa6b32cab1e72955b8a22224328b4 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Tue, 16 Dec 2025 17:29:13 +0100 Subject: [PATCH] backend: add room roles and anonymous access management features --- .../src/controllers/room.controller.ts | 34 +- meet-ce/backend/src/helpers/room.helper.ts | 19 +- meet-ce/backend/src/services/room.service.ts | 328 ++++++++++++++---- 3 files changed, 303 insertions(+), 78 deletions(-) diff --git a/meet-ce/backend/src/controllers/room.controller.ts b/meet-ce/backend/src/controllers/room.controller.ts index 5cb9b0d5..eeb8721e 100644 --- a/meet-ce/backend/src/controllers/room.controller.ts +++ b/meet-ce/backend/src/controllers/room.controller.ts @@ -32,7 +32,7 @@ export const createRoom = async (req: Request, res: Response) => { export const getRooms = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const roomService = container.get(RoomService); - const queryParams = req.query as unknown as MeetRoomFilters; + const queryParams = req.query as MeetRoomFilters; logger.verbose('Getting all rooms'); @@ -179,3 +179,35 @@ export const updateRoomStatus = async (req: Request, res: Response) => { handleError(res, error, `updating room status for room '${roomId}'`); } }; + +export const updateRoomRoles = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const { roles } = req.body; + const { roomId } = req.params; + + logger.verbose(`Updating roles permissions for room '${roomId}'`); + + try { + await roomService.updateMeetRoomRoles(roomId, roles); + return res.status(200).json({ message: `Roles permissions for room '${roomId}' updated successfully` }); + } catch (error) { + handleError(res, error, `updating roles permissions for room '${roomId}'`); + } +}; + +export const updateRoomAnonymous = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const { anonymous } = req.body; + const { roomId } = req.params; + + logger.verbose(`Updating anonymous access config for room '${roomId}'`); + + try { + await roomService.updateMeetRoomAnonymous(roomId, anonymous); + return res.status(200).json({ message: `Anonymous access config for room '${roomId}' updated successfully` }); + } catch (error) { + handleError(res, error, `updating anonymous access config for room '${roomId}'`); + } +}; diff --git a/meet-ce/backend/src/helpers/room.helper.ts b/meet-ce/backend/src/helpers/room.helper.ts index 4b4c700c..cd08bb42 100644 --- a/meet-ce/backend/src/helpers/room.helper.ts +++ b/meet-ce/backend/src/helpers/room.helper.ts @@ -61,12 +61,21 @@ export class MeetRoomHelper { * @param room - The MeetRoom object to convert. * @returns An MeetRoomOptions object containing the same properties as the input room. */ - static toOpenViduOptions(room: MeetRoom): MeetRoomOptions { + static toRoomOptions(room: MeetRoom): MeetRoomOptions { return { roomName: room.roomName, autoDeletionDate: room.autoDeletionDate, autoDeletionPolicy: room.autoDeletionPolicy, - config: room.config + config: room.config, + roles: room.roles, + anonymous: { + moderator: { + enabled: room.anonymous.moderator.enabled + }, + speaker: { + enabled: room.anonymous.speaker.enabled + } + } // maxParticipants: room.maxParticipants }; } @@ -75,12 +84,12 @@ export class MeetRoomHelper { * Extracts speaker and moderator secrets from a MeetRoom object's URLs. * * This method parses the 'secret' query parameter from both speaker and moderator - * room URLs associated with the meeting room. + * anonymous access URLs associated with the meeting room. * * @param room - The MeetRoom object containing speakerUrl and moderatorUrl properties * @returns An object containing the extracted secrets with the following properties: - * - speakerSecret: The secret extracted from the speaker room URL - * - moderatorSecret: The secret extracted from the moderator room URL + * - speakerSecret: The secret extracted from the speaker anonymous access URL + * - moderatorSecret: The secret extracted from the moderator anonymous access URL */ static extractSecretsFromRoom(room: MeetRoom): { speakerSecret: string; moderatorSecret: string } { const speakerUrl = room.anonymous.speaker.accessUrl; diff --git a/meet-ce/backend/src/services/room.service.ts b/meet-ce/backend/src/services/room.service.ts index 7b716cce..5aab1672 100644 --- a/meet-ce/backend/src/services/room.service.ts +++ b/meet-ce/backend/src/services/room.service.ts @@ -1,16 +1,18 @@ import { MeetingEndAction, - MeetRecordingAccess, MeetRoom, + MeetRoomAnonymous, + MeetRoomAnonymousConfig, MeetRoomConfig, MeetRoomDeletionErrorCode, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings, MeetRoomDeletionSuccessCode, MeetRoomFilters, - MeetRoomMember, - MeetRoomMemberRole, + MeetRoomMemberPermissions, MeetRoomOptions, + MeetRoomRoles, + MeetRoomRolesConfig, MeetRoomStatus, MeetUser, MeetUserRole @@ -30,6 +32,7 @@ import { internalError, OpenViduMeetError } from '../models/error.model.js'; +import { RoomMemberRepository } from '../repositories/room-member.repository.js'; import { RoomRepository } from '../repositories/room.repository.js'; import { FrontendEventService } from './frontend-event.service.js'; import { LiveKitService } from './livekit.service.js'; @@ -48,6 +51,7 @@ export class RoomService { constructor( @inject(LoggerService) protected logger: LoggerService, @inject(RoomRepository) protected roomRepository: RoomRepository, + @inject(RoomMemberRepository) protected roomMemberRepository: RoomMemberRepository, @inject(RecordingService) protected recordingService: RecordingService, @inject(LiveKitService) protected livekitService: LiveKitService, @inject(FrontendEventService) protected frontendEventService: FrontendEventService, @@ -64,14 +68,73 @@ export class RoomService { * */ async createMeetRoom(roomOptions: MeetRoomOptions): Promise { - const { roomName, autoDeletionDate, autoDeletionPolicy, config } = roomOptions; + const { roomName, autoDeletionDate, autoDeletionPolicy, config, roles, anonymous } = roomOptions; // Generate a unique room ID based on the room name const roomIdPrefix = MeetRoomHelper.createRoomIdPrefixFromRoomName(roomName!) || 'room'; const roomId = `${roomIdPrefix}-${uid(15)}`; + const user = this.requestSessionService.getAuthenticatedUser(); + + if (!user) { + throw internalError('Cannot create room without an authenticated user'); + } + + const defaultModeratorPermissions: MeetRoomMemberPermissions = { + canRecord: true, + canRetrieveRecordings: true, + canDeleteRecordings: true, + canJoinMeeting: true, + canShareAccessLinks: true, + canMakeModerator: true, + canKickParticipants: true, + canEndMeeting: true, + canPublishVideo: true, + canPublishAudio: true, + canShareScreen: true, + canReadChat: true, + canWriteChat: true, + canChangeVirtualBackground: true + }; + const defaultSpeakerPermissions: MeetRoomMemberPermissions = { + canRecord: false, + canRetrieveRecordings: true, + canDeleteRecordings: false, + canJoinMeeting: true, + canShareAccessLinks: false, + canMakeModerator: false, + canKickParticipants: false, + canEndMeeting: false, + canPublishVideo: true, + canPublishAudio: true, + canShareScreen: true, + canReadChat: true, + canWriteChat: true, + canChangeVirtualBackground: true + }; + + const roomRoles: MeetRoomRoles = { + moderator: { + permissions: { ...defaultModeratorPermissions, ...roles?.moderator?.permissions } + }, + speaker: { + permissions: { ...defaultSpeakerPermissions, ...roles?.speaker?.permissions } + } + }; + + const anonymousConfig: MeetRoomAnonymous = { + moderator: { + enabled: anonymous?.moderator?.enabled ?? true, + accessUrl: `/room/${roomId}?secret=${secureUid(10)}` + }, + speaker: { + enabled: anonymous?.speaker?.enabled ?? true, + accessUrl: `/room/${roomId}?secret=${secureUid(10)}` + } + }; + const defaultConfig: MeetRoomConfig = { - recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + recording: { enabled: true }, chat: { enabled: true }, virtualBackground: { enabled: true }, e2ee: { enabled: false } @@ -89,13 +152,15 @@ export class RoomService { const meetRoom: MeetRoom = { roomId, roomName: roomName!, + owner: user.userId, creationDate: Date.now(), // maxParticipants, autoDeletionDate, - autoDeletionPolicy, + autoDeletionPolicy: autoDeletionDate ? autoDeletionPolicy : undefined, config: roomConfig, - moderatorUrl: `/room/${roomId}?secret=${secureUid(10)}`, - speakerUrl: `/room/${roomId}?secret=${secureUid(10)}`, + roles: roomRoles, + anonymous: anonymousConfig, + accessUrl: `/room/${roomId}`, status: MeetRoomStatus.OPEN, meetingEndAction: MeetingEndAction.NONE }; @@ -122,7 +187,7 @@ export class RoomService { name: roomId, metadata: JSON.stringify({ createdBy: MEET_ENV.NAME_ID, - roomOptions: MeetRoomHelper.toOpenViduOptions(meetRoom) + roomOptions: MeetRoomHelper.toRoomOptions(meetRoom) }), emptyTimeout: MEETING_EMPTY_TIMEOUT ? ms(MEETING_EMPTY_TIMEOUT) / 1000 : undefined, departureTimeout: MEETING_DEPARTURE_TIMEOUT ? ms(MEETING_DEPARTURE_TIMEOUT) / 1000 : undefined @@ -163,7 +228,7 @@ export class RoomService { await this.roomRepository.update(room); // Send signal to frontend - await this.frontendEventService.sendRoomConfigUpdatedSignal(roomId, room); + // await this.frontendEventService.sendRoomConfigUpdatedSignal(roomId, room); return room; } @@ -193,18 +258,72 @@ export class RoomService { } /** - * Checks if a meeting room with the specified name exists + * Updates the roles permissions of a specific meeting room. * - * @param roomName - The name of the meeting room to check + * @param roomId - The unique identifier of the meeting room to update + * @param roles - The new roles permissions + * @returns A Promise that resolves to the updated MeetRoom object + */ + async updateMeetRoomRoles(roomId: string, roles: MeetRoomRolesConfig): Promise { + const room = await this.getMeetRoom(roomId); + + if (room.status === MeetRoomStatus.ACTIVE_MEETING) { + throw errorRoomActiveMeeting(roomId); + } + + if (roles.moderator) { + room.roles.moderator.permissions = { + ...room.roles.moderator.permissions, + ...roles.moderator.permissions + }; + } + + if (roles.speaker) { + room.roles.speaker.permissions = { + ...room.roles.speaker.permissions, + ...roles.speaker.permissions + }; + } + + await this.roomRepository.update(room); + return room; + } + + /** + * Updates the anonymous access configuration of a specific meeting room. + * + * @param roomId - The unique identifier of the meeting room to update + * @param anonymous - The new anonymous access configuration + * @returns A Promise that resolves to the updated MeetRoom object + */ + async updateMeetRoomAnonymous(roomId: string, anonymous: MeetRoomAnonymousConfig): Promise { + const room = await this.getMeetRoom(roomId); + + if (room.status === MeetRoomStatus.ACTIVE_MEETING) { + throw errorRoomActiveMeeting(roomId); + } + + if (anonymous.moderator) { + room.anonymous.moderator.enabled = anonymous.moderator.enabled; + } + + if (anonymous.speaker) { + room.anonymous.speaker.enabled = anonymous.speaker.enabled; + } + + await this.roomRepository.update(room); + return room; + } + + /** + * Checks if a meeting room with the specified ID exists + * + * @param roomId - The ID of the meeting room to check * @returns A Promise that resolves to true if the room exists, false otherwise */ - async meetRoomExists(roomName: string): Promise { - try { - await this.getMeetRoom(roomName); - return true; - } catch (err) { - return false; - } + async meetRoomExists(roomId: string): Promise { + const meetRoom = await this.roomRepository.findByRoomId(roomId); + return !!meetRoom; } /** @@ -235,11 +354,11 @@ export class RoomService { throw errorRoomNotFound(roomId); } - // Remove moderatorUrl if the room member is a speaker to prevent access to moderator links - const role = this.requestSessionService.getRoomMemberBaseRole(); + // Remove anonymous access info if the authenticated room member does not have permission to share access links + const permissions = await this.getAuthenticatedRoomMemberPermissions(roomId); - if (role === MeetRoomMemberRole.SPEAKER) { - delete (room as Partial).moderatorUrl; + if (room.anonymous && !permissions.canShareAccessLinks) { + delete (room as Partial).anonymous; } return room; @@ -638,24 +757,78 @@ export class RoomService { return { successful, failed }; } + /** + * Checks if a user is the owner of a room. + * + * @param roomId - The ID of the room + * @param userId - The ID of the user + * @returns A promise that resolves to true if the user is the owner, false otherwise + */ async isRoomOwner(roomId: string, userId: string): Promise { - // TODO: Implement - return false; - } - - async isRoomMember(roomId: string, memberId: string): Promise { - // TODO: Implement - return false; - } - - async getRoomMember(roomId: string, userId: string): Promise { - // TODO: Implement - return null; + const room = await this.roomRepository.findByRoomId(roomId); + return room?.owner === userId; } + /** + * Validates if the provided secret matches one of the room's secrets for anonymous access. + * + * @param roomId - The ID of the room + * @param secret - The secret to validate + * @returns A promise that resolves to true if the secret is valid, false otherwise + */ async isValidRoomSecret(roomId: string, secret: string): Promise { - // TODO: Implement - return false; + const room = await this.roomRepository.findByRoomId(roomId); + + if (!room) return false; + + const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(room); + return secret === moderatorSecret || secret === speakerSecret; + } + + /** + * Retrieves the permissions of the authenticated room member. + * + * - If there's no authenticated user nor room member token, returns all permissions. + * This is necessary for methods invoked by system processes (e.g., room auto-deletion). + * - If the user is admin or the room owner, they have all permissions. + * - If the user is a registered room member, their permissions are obtained from their room member info. + * - If the user is authenticated via room member token, their permissions are obtained from the token metadata. + * + * @param roomId The ID of the room. + * @returns A promise that resolves to the MeetRoomMemberPermissions object. + */ + async getAuthenticatedRoomMemberPermissions(roomId: string): Promise { + const user = this.requestSessionService.getAuthenticatedUser(); + const memberRoomId = this.requestSessionService.getRoomIdFromMember(); + + if (!user && !memberRoomId) { + return this.getAllPermissions(); + } + + // Registered user + if (user) { + const isAdmin = user.role === MeetUserRole.ADMIN; + const isOwner = await this.isRoomOwner(roomId, user.userId); + + // Admins and owners have all permissions + if (isAdmin || isOwner) { + return this.getAllPermissions(); + } + + const member = await this.roomMemberRepository.findByRoomAndMemberId(roomId, user.userId); + + if (member) { + return member.effectivePermissions; + } + } + + // Room member token + if (memberRoomId === roomId) { + const permissions = this.requestSessionService.getRoomMemberPermissions(); + return permissions!; + } + + return this.getNoPermissions(); } /** @@ -666,41 +839,52 @@ export class RoomService { * @returns A promise that resolves to true if the user can access the room, false otherwise. */ async canUserAccessRoom(roomId: string, user: MeetUser): Promise { - switch (user.role) { - case MeetUserRole.ADMIN: - // Admins can access all rooms - return true; - - case MeetUserRole.USER: { - // Users can access rooms they own or are members of - const isOwner = await this.isRoomOwner(roomId, user.userId); - - if (isOwner) { - return true; - } - - const isMember = await this.isRoomMember(roomId, user.userId); - - if (isMember) { - return true; - } - - return false; - } - - case MeetUserRole.ROOM_MEMBER: { - // Room members can only access rooms they are members of - const isMember = await this.isRoomMember(roomId, user.userId); - - if (isMember) { - return true; - } - - return false; - } - - default: - return false; + if (user.role === MeetUserRole.ADMIN) { + // Admins can access all rooms + return true; } + + // Users can access rooms they own or are members of + const isOwner = await this.isRoomOwner(roomId, user.userId); + const isMember = await this.isRoomMember(roomId, user.userId); + return isOwner || isMember; + } + + private getAllPermissions(): MeetRoomMemberPermissions { + return { + canRecord: true, + canRetrieveRecordings: true, + canDeleteRecordings: true, + canJoinMeeting: true, + canShareAccessLinks: true, + canMakeModerator: true, + canKickParticipants: true, + canEndMeeting: true, + canPublishVideo: true, + canPublishAudio: true, + canShareScreen: true, + canReadChat: true, + canWriteChat: true, + canChangeVirtualBackground: true + }; + } + + private getNoPermissions(): MeetRoomMemberPermissions { + return { + canRecord: false, + canRetrieveRecordings: false, + canDeleteRecordings: false, + canJoinMeeting: false, + canShareAccessLinks: false, + canMakeModerator: false, + canKickParticipants: false, + canEndMeeting: false, + canPublishVideo: false, + canPublishAudio: false, + canShareScreen: false, + canReadChat: false, + canWriteChat: false, + canChangeVirtualBackground: false + }; } }