backend: add room roles and anonymous access management features

This commit is contained in:
juancarmore 2025-12-16 17:29:13 +01:00
parent 6b781aac8e
commit a26f2a754b
3 changed files with 303 additions and 78 deletions

View File

@ -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}'`);
}
};

View File

@ -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;

View File

@ -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<MeetRoom> {
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<MeetRoom> {
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<MeetRoom> {
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<boolean> {
try {
await this.getMeetRoom(roomName);
return true;
} catch (err) {
return false;
}
async meetRoomExists(roomId: string): Promise<boolean> {
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<MeetRoom>).moderatorUrl;
if (room.anonymous && !permissions.canShareAccessLinks) {
delete (room as Partial<MeetRoom>).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<boolean> {
// TODO: Implement
return false;
}
async isRoomMember(roomId: string, memberId: string): Promise<boolean> {
// TODO: Implement
return false;
}
async getRoomMember(roomId: string, userId: string): Promise<MeetRoomMember | null> {
// 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<boolean> {
// 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<MeetRoomMemberPermissions> {
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<boolean> {
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
};
}
}