backend: enhace get all rooms and recordings methods to filter results based on user's role and permissions
This commit is contained in:
parent
0deae8ad29
commit
b8507b824b
@ -53,18 +53,9 @@ export const stopRecording = async (req: Request, res: Response) => {
|
||||
export const getRecordings = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
const recordingService = container.get(RecordingService);
|
||||
const requestSessionService = container.get(RequestSessionService);
|
||||
const queryParams = req.query;
|
||||
|
||||
// If room member token is present, retrieve only recordings for the room associated with the token
|
||||
const roomId = requestSessionService.getRoomIdFromMember();
|
||||
|
||||
if (roomId) {
|
||||
queryParams.roomId = roomId;
|
||||
logger.info(`Getting recordings for room '${roomId}'`);
|
||||
} else {
|
||||
logger.info('Getting all recordings');
|
||||
}
|
||||
logger.info('Getting all recordings');
|
||||
|
||||
try {
|
||||
const { recordings, isTruncated, nextPageToken } = await recordingService.getAllRecordings(queryParams);
|
||||
|
||||
@ -60,13 +60,24 @@ export abstract class BaseRepository<TDomain, TDocument extends Document> {
|
||||
* WARNING: Use with caution on large collections. Consider using findMany() with pagination instead.
|
||||
*
|
||||
* @param filter - Base MongoDB query filter
|
||||
* @param fields - Optional comma-separated list of fields to select from database
|
||||
* @returns Array of domain objects matching the filter
|
||||
*/
|
||||
protected async findAll(filter: FilterQuery<TDocument> = {}): Promise<TDomain[]> {
|
||||
protected async findAll(filter: FilterQuery<TDocument> = {}, fields?: string): Promise<TDomain[]> {
|
||||
try {
|
||||
const documents = await this.model.find(filter).exec();
|
||||
let query = this.model.find(filter);
|
||||
|
||||
if (fields) {
|
||||
const fieldSelection = fields
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field !== '')
|
||||
.join(' ');
|
||||
query = query.select(fieldSelection);
|
||||
}
|
||||
|
||||
// Transform documents to domain objects
|
||||
const documents = await query.exec();
|
||||
return documents.map((doc) => this.toDomain(doc));
|
||||
} catch (error) {
|
||||
this.logger.error('Error finding all documents with filter:', filter, error);
|
||||
|
||||
@ -89,6 +89,7 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
|
||||
* even when the sort field has duplicate values.
|
||||
*
|
||||
* @param options - Query options
|
||||
* @param options.roomIds - Optional array of room IDs to filter by
|
||||
* @param options.roomId - Optional room ID for exact match filtering
|
||||
* @param options.roomName - Optional room name for regex match filtering (case-insensitive)
|
||||
* @param options.status - Optional recording status to filter by
|
||||
@ -99,12 +100,13 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
|
||||
* @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'desc')
|
||||
* @returns Object containing recordings array, pagination info, and optional next page token
|
||||
*/
|
||||
async find(options: MeetRecordingFilters = {}): Promise<{
|
||||
async find(options: MeetRecordingFilters & { roomIds?: string[] } = {}): Promise<{
|
||||
recordings: TRecording[];
|
||||
isTruncated: boolean;
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
const {
|
||||
roomIds,
|
||||
roomId,
|
||||
roomName,
|
||||
status,
|
||||
@ -118,6 +120,11 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
|
||||
// Build base filter
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (roomIds && roomIds.length > 0) {
|
||||
// Filter by multiple room IDs
|
||||
filter.roomId = { $in: roomIds };
|
||||
}
|
||||
|
||||
if (roomId && roomName) {
|
||||
// Both defined: OR filter with exact roomId match and regex roomName match
|
||||
filter.$or = [{ roomId }, { roomName: new RegExp(roomName, 'i') }];
|
||||
|
||||
@ -113,11 +113,67 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
|
||||
/**
|
||||
* Finds room members by their memberIds.
|
||||
*
|
||||
* @param roomId - The ID of the room
|
||||
* @param memberIds - Array of member identifiers
|
||||
* @returns Array of found room members
|
||||
*/
|
||||
async findByRoomAndMemberIds(memberIds: string[]): Promise<MeetRoomMember[]> {
|
||||
return await this.findAll({ memberId: { $in: memberIds } });
|
||||
async findByRoomAndMemberIds(roomId: string, memberIds: string[], fields?: string): Promise<MeetRoomMember[]> {
|
||||
return await this.findAll({ roomId, memberId: { $in: memberIds } }, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all room IDs where a user is a member.
|
||||
*
|
||||
* @param memberId - The ID of the member (userId)
|
||||
* @returns Array of room IDs where the user is a member
|
||||
*/
|
||||
async getRoomIdsByMemberId(memberId: string): Promise<string[]> {
|
||||
const members = await this.findAll({ memberId }, 'roomId');
|
||||
return members.map((m) => m.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all room IDs where a member has a specific permission enabled.
|
||||
* Takes into account both base role permissions and custom permissions.
|
||||
*
|
||||
* @param memberId - The ID of the member (userId)
|
||||
* @param permission - The permission key to check (e.g., 'canRetrieveRecordings')
|
||||
* @returns Array of room IDs where the member has the specified permission
|
||||
*/
|
||||
async getRoomIdsByMemberIdWithPermission(
|
||||
memberId: string,
|
||||
permission: keyof MeetRoomMemberPermissions
|
||||
): Promise<string[]> {
|
||||
// Get all memberships for this user
|
||||
const members = await this.findAll({ memberId }, 'roomId,baseRole,customPermissions');
|
||||
|
||||
if (members.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch all rooms
|
||||
const roomIds = members.map((m) => m.roomId);
|
||||
const rooms = await this.roomRepository.findByRoomIds(roomIds, 'roomId,roles');
|
||||
const roomsMap = new Map(rooms.map((room) => [room.roomId, room]));
|
||||
|
||||
// Filter members where the permission is enabled
|
||||
const roomIdsWithPermission: string[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const room = roomsMap.get(member.roomId);
|
||||
|
||||
if (!room) continue;
|
||||
|
||||
// Compute effective permissions
|
||||
const basePermissions = room.roles[member.baseRole].permissions;
|
||||
const effectivePermission = member.customPermissions?.[permission] ?? basePermissions[permission];
|
||||
|
||||
if (effectivePermission) {
|
||||
roomIdsWithPermission.push(member.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
return roomIdsWithPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -68,6 +68,18 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
|
||||
return document ? this.enrichRoomWithBaseUrls(document) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds rooms by their roomIds.
|
||||
* Returns rooms with enriched URLs (including base URL).
|
||||
*
|
||||
* @param roomIds - Array of room identifiers
|
||||
* @param fields - Comma-separated list of fields to include in the result
|
||||
* @returns Array of found rooms
|
||||
*/
|
||||
async findByRoomIds(roomIds: string[], fields?: string): Promise<TRoom[]> {
|
||||
return await this.findAll({ roomId: { $in: roomIds } }, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds rooms with optional filtering, pagination, and sorting.
|
||||
* Returns rooms with enriched URLs (including base URL).
|
||||
@ -78,6 +90,8 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
|
||||
* @param options - Query options
|
||||
* @param options.roomName - Optional room name to filter by (case-insensitive partial match)
|
||||
* @param options.status - Optional room status to filter by
|
||||
* @param options.owner - Optional owner userId to filter by
|
||||
* @param options.roomIds - Optional array of room IDs to filter by, representing rooms the user is a member of
|
||||
* @param options.fields - Comma-separated list of fields to include in the result
|
||||
* @param options.maxItems - Maximum number of results to return (default: 100)
|
||||
* @param options.nextPageToken - Token for pagination (encoded cursor with last sortField value and _id)
|
||||
@ -85,7 +99,7 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
|
||||
* @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'desc')
|
||||
* @returns Object containing rooms array, pagination info, and optional next page token
|
||||
*/
|
||||
async find(options: MeetRoomFilters = {}): Promise<{
|
||||
async find(options: MeetRoomFilters & { owner?: string; roomIds?: string[] } = {}): Promise<{
|
||||
rooms: TRoom[];
|
||||
isTruncated: boolean;
|
||||
nextPageToken?: string;
|
||||
@ -93,6 +107,8 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
|
||||
const {
|
||||
roomName,
|
||||
status,
|
||||
owner,
|
||||
roomIds,
|
||||
fields,
|
||||
maxItems = 100,
|
||||
nextPageToken,
|
||||
@ -103,6 +119,15 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
|
||||
// Build base filter
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
// Handle owner and roomIds with $or when both are present
|
||||
if (owner && roomIds && roomIds.length > 0) {
|
||||
filter.$or = [{ owner }, { roomId: { $in: roomIds } }];
|
||||
} else if (owner) {
|
||||
filter.owner = owner;
|
||||
} else if (roomIds && roomIds.length > 0) {
|
||||
filter.roomId = { $in: roomIds };
|
||||
}
|
||||
|
||||
if (roomName) {
|
||||
filter.roomName = new RegExp(roomName, 'i');
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings';
|
||||
import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus, MeetUserRole } from '@openvidu-meet/typings';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { EgressStatus, EncodedFileOutput, EncodedFileType, RoomCompositeOptions } from 'livekit-server-sdk';
|
||||
import ms from 'ms';
|
||||
@ -24,12 +24,14 @@ import {
|
||||
OpenViduMeetError
|
||||
} from '../models/error.model.js';
|
||||
import { RecordingRepository } from '../repositories/recording.repository.js';
|
||||
import { RoomMemberRepository } from '../repositories/room-member.repository.js';
|
||||
import { RoomRepository } from '../repositories/room.repository.js';
|
||||
import { DistributedEventService } from './distributed-event.service.js';
|
||||
import { FrontendEventService } from './frontend-event.service.js';
|
||||
import { LiveKitService } from './livekit.service.js';
|
||||
import { LoggerService } from './logger.service.js';
|
||||
import { MutexService, RedisLock } from './mutex.service.js';
|
||||
import { RequestSessionService } from './request-session.service.js';
|
||||
import { BlobStorageService } from './storage/blob-storage.service.js';
|
||||
|
||||
@injectable()
|
||||
@ -39,7 +41,9 @@ export class RecordingService {
|
||||
@inject(MutexService) protected mutexService: MutexService,
|
||||
@inject(DistributedEventService) protected systemEventService: DistributedEventService,
|
||||
@inject(RoomRepository) protected roomRepository: RoomRepository,
|
||||
@inject(RoomMemberRepository) protected roomMemberRepository: RoomMemberRepository,
|
||||
@inject(RecordingRepository) protected recordingRepository: RecordingRepository,
|
||||
@inject(RequestSessionService) protected requestSessionService: RequestSessionService,
|
||||
@inject(BlobStorageService) protected blobStorageService: BlobStorageService,
|
||||
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
|
||||
@inject(LoggerService) protected logger: LoggerService
|
||||
@ -196,10 +200,15 @@ export class RecordingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of all recordings stored in MongoDB.
|
||||
* Retrieves a list of recordings based on the provided filtering, pagination, and sorting options.
|
||||
*
|
||||
* If the request is made with a room member token, only recordings for the associated room are returned.
|
||||
* If the request is made by an authenticated user, access is determined by the user's role and permissions:
|
||||
* - ADMIN: Can see all recordings
|
||||
* - USER: Can see recordings from rooms they own OR where they are members with canRetrieveRecordings permission
|
||||
* - ROOM_MEMBER: Can see recordings from rooms where they are members with canRetrieveRecordings permission
|
||||
*
|
||||
* @param maxItems - The maximum number of items to retrieve in a single request.
|
||||
* @param nextPageToken - (Optional) A token to retrieve the next page of results.
|
||||
* @param filters - Filtering, pagination and sorting options
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - `recordings`: An array of `MeetRecordingInfo` objects representing the recordings.
|
||||
* - `isTruncated`: A boolean indicating whether there are more items to retrieve.
|
||||
@ -212,7 +221,48 @@ export class RecordingService {
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await this.recordingRepository.find(filters);
|
||||
const user = this.requestSessionService.getAuthenticatedUser();
|
||||
const memberRoomId = this.requestSessionService.getRoomIdFromMember();
|
||||
const queryOptions: MeetRecordingFilters & { roomIds?: string[]; owner?: string } = { ...filters };
|
||||
|
||||
// If room member token is present, retrieve only recordings for the room associated with the token
|
||||
if (memberRoomId) {
|
||||
queryOptions.roomId = memberRoomId;
|
||||
} else if (user && user.role !== MeetUserRole.ADMIN) {
|
||||
// For USER and ROOM_MEMBER roles,
|
||||
// get room IDs where user is member WITH canRetrieveRecordings permission
|
||||
const memberRoomIds = await this.roomMemberRepository.getRoomIdsByMemberIdWithPermission(
|
||||
user.userId,
|
||||
'canRetrieveRecordings'
|
||||
);
|
||||
|
||||
let ownedRoomIds: string[] = [];
|
||||
|
||||
// If USER role, also get owned room IDs
|
||||
if (user.role === MeetUserRole.USER) {
|
||||
const ownedRooms = await this.roomRepository.find({
|
||||
owner: user.userId,
|
||||
fields: 'roomId'
|
||||
});
|
||||
ownedRoomIds = ownedRooms.rooms.map((r) => r.roomId);
|
||||
}
|
||||
|
||||
// Combine owned rooms and member rooms with permission
|
||||
const accessibleRoomIds = [...new Set([...ownedRoomIds, ...memberRoomIds])];
|
||||
|
||||
if (accessibleRoomIds.length === 0) {
|
||||
// User has no access to any rooms, return empty result
|
||||
return {
|
||||
recordings: [],
|
||||
isTruncated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Apply roomIds filter
|
||||
queryOptions.roomIds = accessibleRoomIds;
|
||||
}
|
||||
|
||||
const response = await this.recordingRepository.find(queryOptions);
|
||||
this.logger.info(`Retrieved ${response.recordings.length} recordings.`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@ -204,7 +204,7 @@ export class RoomMemberService {
|
||||
deleted: string[];
|
||||
failed: { memberId: string; error: string }[];
|
||||
}> {
|
||||
const membersToDelete = await this.roomMemberRepository.findByRoomAndMemberIds(memberIds);
|
||||
const membersToDelete = await this.roomMemberRepository.findByRoomAndMemberIds(roomId, memberIds, 'memberId');
|
||||
const foundMemberIds = membersToDelete.map((m) => m.memberId);
|
||||
|
||||
const failed = memberIds
|
||||
|
||||
@ -327,17 +327,38 @@ export class RoomService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of rooms.
|
||||
* @returns A Promise that resolves to an array of {@link MeetRoom} objects.
|
||||
* @throws If there was an error retrieving the rooms.
|
||||
* Retrieves a list of rooms based on the provided filtering, pagination, and sorting options.
|
||||
*
|
||||
* If the request is made by an authenticated user, access is determined by the user's role:
|
||||
* - ADMIN: Can see all rooms
|
||||
* - USER: Can see rooms they own or are members of
|
||||
* - ROOM_MEMBER: Can see rooms they are members of
|
||||
*
|
||||
* @param filters - Filtering, pagination and sorting options
|
||||
* @returns A Promise that resolves to paginated room list
|
||||
* @throws If there was an error retrieving the rooms
|
||||
*/
|
||||
async getAllMeetRooms(filters: MeetRoomFilters): Promise<{
|
||||
rooms: MeetRoom[];
|
||||
isTruncated: boolean;
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
const response = await this.roomRepository.find(filters);
|
||||
return response;
|
||||
const user = this.requestSessionService.getAuthenticatedUser();
|
||||
const queryOptions: MeetRoomFilters & { roomIds?: string[]; owner?: string } = { ...filters };
|
||||
|
||||
// Admin can see all rooms - no additional filters needed
|
||||
if (user && user.role !== MeetUserRole.ADMIN) {
|
||||
// For USER and ROOM_MEMBER roles, get the list of room IDs they are members of
|
||||
const memberRoomIds = await this.roomMemberRepository.getRoomIdsByMemberId(user.userId);
|
||||
queryOptions.roomIds = memberRoomIds;
|
||||
|
||||
// If USER role, also filter by rooms they own
|
||||
if (user.role === MeetUserRole.USER) {
|
||||
queryOptions.owner = user.userId;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.roomRepository.find(queryOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -773,6 +794,18 @@ export class RoomService {
|
||||
return room?.owner === userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is a member of a room.
|
||||
*
|
||||
* @param roomId - The ID of the room
|
||||
* @param memberId - The ID of the member (userId)
|
||||
* @returns A promise that resolves to true if the user is a member, false otherwise
|
||||
*/
|
||||
async isRoomMember(roomId: string, memberId: string): Promise<boolean> {
|
||||
const member = await this.roomMemberRepository.findByRoomAndMemberId(roomId, memberId);
|
||||
return !!member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the provided secret matches one of the room's secrets for anonymous access.
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user