From 576b1f7d984be3ca2959e5152d1f6ae208b0d022 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Fri, 25 Apr 2025 11:49:03 +0200 Subject: [PATCH] backend: Implement recording token generation and update room preferences handling --- backend/src/config/internal-config.ts | 3 +- backend/src/controllers/room.controller.ts | 59 +++++++++---- backend/src/environment.ts | 14 ++- .../room-validator.middleware.ts | 38 ++++++-- backend/src/models/error.model.ts | 4 + backend/src/routes/room.routes.ts | 10 ++- backend/src/services/room.service.ts | 88 +++++++++++++++---- backend/src/services/token.service.ts | 22 ++++- 8 files changed, 193 insertions(+), 45 deletions(-) diff --git a/backend/src/config/internal-config.ts b/backend/src/config/internal-config.ts index 95c4b5c..28b5ff0 100644 --- a/backend/src/config/internal-config.ts +++ b/backend/src/config/internal-config.ts @@ -7,9 +7,10 @@ const INTERNAL_CONFIG = { API_BASE_PATH_V1: '/meet/api/v1', // Cookie names - PARTICIPANT_TOKEN_COOKIE_NAME: 'OvMeetParticipantToken', ACCESS_TOKEN_COOKIE_NAME: 'OvMeetAccessToken', REFRESH_TOKEN_COOKIE_NAME: 'OvMeetRefreshToken', + PARTICIPANT_TOKEN_COOKIE_NAME: 'OvMeetParticipantToken', + RECORDING_TOKEN_COOKIE_NAME: 'OvMeetRecordingToken', // Headers for API requests API_KEY_HEADER: 'x-api-key', diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index 44dd00a..bd89f1a 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -2,8 +2,10 @@ import { MeetRoomFilters, MeetRoomOptions, MeetRoomRoleAndPermissions, Participa import { Request, Response } from 'express'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; +import { MEET_RECORDING_TOKEN_EXPIRATION } from '../environment.js'; import { OpenViduMeetError } from '../models/error.model.js'; import { LoggerService, ParticipantService, RoomService } from '../services/index.js'; +import { getCookieOptions } from '../utils/cookie-utils.js'; export const createRoom = async (req: Request, res: Response) => { const logger = container.get(LoggerService); @@ -122,6 +124,46 @@ export const bulkDeleteRooms = async (req: Request, res: Response) => { } }; +export const updateRoomPreferences = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const roomPreferences = req.body; + const { roomId } = req.params; + + logger.verbose(`Updating room preferences`); + + try { + const room = await roomService.updateMeetRoomPreferences(roomId, roomPreferences); + return res.status(200).json(room); + } catch (error) { + logger.error(`Error saving room preferences: ${error}`); + handleError(res, error); + } +}; + +export const generateRecordingToken = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const { roomId } = req.params; + const { secret } = req.body; + + logger.verbose(`Generating recording token for room '${roomId}'`); + + try { + const token = await roomService.generateRecordingToken(roomId, secret); + + res.cookie( + INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME, + token, + getCookieOptions('/', MEET_RECORDING_TOKEN_EXPIRATION) + ); + return res.status(200).json({ token }); + } catch (error) { + logger.error(`Error generating recording token for room '${roomId}'`); + handleError(res, error); + } +}; + export const getRoomRolesAndPermissions = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const roomService = container.get(RoomService); @@ -177,23 +219,6 @@ export const getRoomRoleAndPermissions = async (req: Request, res: Response) => } }; -export const updateRoomPreferences = async (req: Request, res: Response) => { - const logger = container.get(LoggerService); - const roomService = container.get(RoomService); - const roomPreferences = req.body; - const { roomId } = req.params; - - logger.verbose(`Updating room preferences`); - - try { - const room = await roomService.updateMeetRoomPreferences(roomId, roomPreferences); - return res.status(200).json(room); - } catch (error) { - logger.error(`Error saving room preferences: ${error}`); - handleError(res, error); - } -}; - const handleError = (res: Response, error: OpenViduMeetError | unknown) => { const logger = container.get(LoggerService); logger.error(String(error)); diff --git a/backend/src/environment.ts b/backend/src/environment.ts index b92f14a..f61691d 100644 --- a/backend/src/environment.ts +++ b/backend/src/environment.ts @@ -18,19 +18,25 @@ dotenv.config(envPath ? { path: envPath } : {}); export const { SERVER_PORT = 6080, SERVER_CORS_ORIGIN = '*', + MEET_LOG_LEVEL = 'info', MEET_NAME_ID = 'openviduMeet', + + // Authentication configuration MEET_API_KEY = 'meet-api-key', MEET_USER = 'user', MEET_SECRET = 'user', MEET_ADMIN_USER = 'admin', MEET_ADMIN_SECRET = 'admin', - MEET_PARTICIPANT_TOKEN_EXPIRATION = '6h', + + // Token expiration times MEET_ACCESS_TOKEN_EXPIRATION = '2h', MEET_REFRESH_TOKEN_EXPIRATION = '1d', - MEET_PREFERENCES_STORAGE_MODE = 's3', + MEET_PARTICIPANT_TOKEN_EXPIRATION = '6h', + MEET_RECORDING_TOKEN_EXPIRATION = '2h', + + // Webhook configuration MEET_WEBHOOK_ENABLED = 'false', MEET_WEBHOOK_URL = 'http://localhost:5080/webhook', - MEET_LOG_LEVEL = 'info', // LiveKit configuration LIVEKIT_URL = 'ws://localhost:7880', @@ -38,6 +44,8 @@ export const { LIVEKIT_API_KEY = 'devkey', LIVEKIT_API_SECRET = 'secret', + MEET_PREFERENCES_STORAGE_MODE = 's3', + // S3 configuration MEET_S3_BUCKET = 'openvidu', MEET_S3_SUBBUCKET = 'openvidu-meet', diff --git a/backend/src/middlewares/request-validators/room-validator.middleware.ts b/backend/src/middlewares/request-validators/room-validator.middleware.ts index 1ebf48d..6bc139d 100644 --- a/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -1,14 +1,15 @@ import { MeetChatPreferences, - MeetRoomOptions, + MeetRecordingAccess, MeetRecordingPreferences, + MeetRoomFilters, + MeetRoomOptions, MeetRoomPreferences, - MeetVirtualBackgroundPreferences, - MeetRoomFilters + MeetVirtualBackgroundPreferences } from '@typings-ce'; -import { Request, Response, NextFunction } from 'express'; -import { z } from 'zod'; +import { NextFunction, Request, Response } from 'express'; import ms from 'ms'; +import { z } from 'zod'; import INTERNAL_CONFIG from '../../config/internal-config.js'; /** @@ -55,8 +56,16 @@ const validForceQueryParam = () => }, z.boolean()) .default(false); +const RecordingAccessSchema: z.ZodType = z.enum([ + MeetRecordingAccess.ADMIN, + MeetRecordingAccess.ADMIN_MODERATOR, + MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER, + MeetRecordingAccess.PUBLIC +]); + const RecordingPreferencesSchema: z.ZodType = z.object({ - enabled: z.boolean() + enabled: z.boolean(), + allowAccessTo: RecordingAccessSchema }); const ChatPreferencesSchema: z.ZodType = z.object({ @@ -89,7 +98,7 @@ const RoomRequestOptionsSchema: z.ZodType = z.object({ .optional() .default(''), preferences: RoomPreferencesSchema.optional().default({ - recordingPreferences: { enabled: true }, + recordingPreferences: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER }, chatPreferences: { enabled: true }, virtualBackgroundPreferences: { enabled: true } }) @@ -154,6 +163,10 @@ const BulkDeleteRoomsSchema = z.object({ force: validForceQueryParam() }); +const RecordingTokenRequestSchema = z.object({ + secret: z.string().nonempty('Secret is required') +}); + export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body); @@ -235,6 +248,17 @@ export const withValidRoomDeleteRequest = (req: Request, res: Response, next: Ne next(); }; +export const withValidRoomSecret = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = RecordingTokenRequestSchema.safeParse(req.body); + + if (!success) { + return rejectRequest(res, error); + } + + req.body = data; + next(); +}; + const rejectRequest = (res: Response, error: z.ZodError) => { const errors = error.errors.map((error) => ({ field: error.path.join('.'), diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index 59b48ed..fed57dd 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -113,6 +113,10 @@ export const errorRoomNotFound = (roomId: string): OpenViduMeetError => { return new OpenViduMeetError('Room Error', `The room '${roomId}' does not exist`, 404); }; +export const errorRoomNotFoundOrEmptyRecordings = (roomId: string): OpenViduMeetError => { + return new OpenViduMeetError('Room Error', `The room '${roomId}' does not exist or has no recordings`, 404); +}; + export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenViduMeetError => { return new OpenViduMeetError('Room Error', `The secret '${secret}' is not recognized for room '${roomId}'`, 400); }; diff --git a/backend/src/routes/room.routes.ts b/backend/src/routes/room.routes.ts index dd3aac7..d0048bd 100644 --- a/backend/src/routes/room.routes.ts +++ b/backend/src/routes/room.routes.ts @@ -14,7 +14,8 @@ import { withValidRoomFiltersRequest, withValidRoomId, withValidRoomOptions, - withValidRoomPreferences + withValidRoomPreferences, + withValidRoomSecret } from '../middlewares/index.js'; export const roomRouter = Router(); @@ -63,6 +64,13 @@ internalRoomRouter.put( roomCtrl.updateRoomPreferences ); +internalRoomRouter.post( + '/:roomId/recording-token', + configureCreateRoomAuth, + withValidRoomSecret, + roomCtrl.generateRecordingToken +); + // Roles and permissions internalRoomRouter.get('/:roomId/roles', roomCtrl.getRoomRolesAndPermissions); internalRoomRouter.get('/:roomId/roles/:secret', roomCtrl.getRoomRoleAndPermissions); diff --git a/backend/src/services/room.service.ts b/backend/src/services/room.service.ts index 8b4a71d..af8e548 100644 --- a/backend/src/services/room.service.ts +++ b/backend/src/services/room.service.ts @@ -1,19 +1,29 @@ -import { uid as secureUid } from 'uid/secure'; -import { inject, injectable } from '../config/dependency-injector.config.js'; +import { + MeetRecordingAccess, + MeetRoom, + MeetRoomFilters, + MeetRoomOptions, + MeetRoomPreferences, + ParticipantRole, + RecordingPermissions +} from '@typings-ce'; +import { inject, injectable } from 'inversify'; import { CreateOptions, Room, SendDataOptions } from 'livekit-server-sdk'; -import { LoggerService } from './logger.service.js'; -import { LiveKitService } from './livekit.service.js'; -import { MeetStorageService } from './storage/storage.service.js'; -import { MeetRoom, MeetRoomFilters, MeetRoomOptions, MeetRoomPreferences, ParticipantRole } from '@typings-ce'; -import { MeetRoomHelper } from '../helpers/room.helper.js'; -import { SystemEventService } from './system-event.service.js'; -import { IScheduledTask, TaskSchedulerService } from './task-scheduler.service.js'; -import { errorInvalidRoomSecret, internalError } from '../models/error.model.js'; -import { OpenViduComponentsAdapterHelper } from '../helpers/index.js'; +import { uid as secureUid } from 'uid/secure'; import { uid } from 'uid/single'; -import { MEET_NAME_ID } from '../environment.js'; -import { UtilsHelper } from '../helpers/utils.helper.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; +import { MEET_NAME_ID } from '../environment.js'; +import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js'; +import { errorInvalidRoomSecret, errorRoomNotFoundOrEmptyRecordings, internalError } from '../models/error.model.js'; +import { + IScheduledTask, + LiveKitService, + LoggerService, + MeetStorageService, + SystemEventService, + TaskSchedulerService, + TokenService +} from './index.js'; /** * Service for managing OpenVidu Meet rooms. @@ -28,7 +38,8 @@ export class RoomService { @inject(MeetStorageService) protected storageService: MeetStorageService, @inject(LiveKitService) protected livekitService: LiveKitService, @inject(SystemEventService) protected systemEventService: SystemEventService, - @inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService + @inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService, + @inject(TokenService) protected tokenService: TokenService ) { const roomGarbageCollectorTask: IScheduledTask = { name: 'roomGarbageCollector', @@ -235,6 +246,10 @@ export class RoomService { */ async getRoomRoleBySecret(roomId: string, secret: string): Promise { const room = await this.getMeetRoom(roomId); + return this.getRoomRoleBySecretFromRoom(room, secret); + } + + protected getRoomRoleBySecretFromRoom(room: MeetRoom, secret: string): ParticipantRole { const { moderatorSecret, publisherSecret } = MeetRoomHelper.extractSecretsFromRoom(room); switch (secret) { @@ -243,10 +258,53 @@ export class RoomService { case publisherSecret: return ParticipantRole.PUBLISHER; default: - throw errorInvalidRoomSecret(roomId, secret); + throw errorInvalidRoomSecret(room.roomId, secret); } } + /** + * Generates a token with recording permissions for a specific room. + * + * @param roomId - The unique identifier of the room for which the recording token is being generated. + * @param secret - The secret associated with the room, used to determine the user's role. + * @returns A promise that resolves to the generated recording token as a string. + * @throws An error if the room with the given `roomId` is not found. + */ + async generateRecordingToken(roomId: string, secret: string): Promise { + const room = await this.storageService.getArchivedRoomMetadata(roomId); + + if (!room) { + // If the room is not found, it means that there are no recordings for that room or the room doesn't exist + throw errorRoomNotFoundOrEmptyRecordings(roomId); + } + + const role = this.getRoomRoleBySecretFromRoom(room as MeetRoom, secret); + const permissions = this.getRecordingPermissions(room, role); + return await this.tokenService.generateRecordingToken(roomId, role, permissions); + } + + protected getRecordingPermissions(room: Partial, role: ParticipantRole): RecordingPermissions { + const recordingAccess = room.preferences!.recordingPreferences.allowAccessTo; + + // A participant can delete recordings if they are a moderator and the recording access is not set to admin + const canDeleteRecordings = role === ParticipantRole.MODERATOR && recordingAccess !== MeetRecordingAccess.ADMIN; + + /* A participant can retrieve recordings if + - they can delete recordings + - the recording access is public + - they are a publisher and the recording access includes publishers + */ + const canRetrieveRecordings = + canDeleteRecordings || + recordingAccess === MeetRecordingAccess.PUBLIC || + (role === ParticipantRole.PUBLISHER && recordingAccess === MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER); + + return { + canRetrieveRecordings, + canDeleteRecordings + }; + } + async sendRoomStatusSignalToOpenViduComponents(roomId: string, participantSid: string) { // Check if recording is started in the room const activeEgressArray = await this.livekitService.getActiveEgress(roomId); diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index 6b55f35..1b30b67 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -1,4 +1,4 @@ -import { ParticipantOptions, ParticipantPermissions, ParticipantRole, User } from '@typings-ce'; +import { ParticipantOptions, ParticipantPermissions, ParticipantRole, RecordingPermissions, User } from '@typings-ce'; import { inject, injectable } from 'inversify'; import { AccessToken, AccessTokenOptions, ClaimGrants, TokenVerifier, VideoGrant } from 'livekit-server-sdk'; import { @@ -7,6 +7,7 @@ import { LIVEKIT_URL, MEET_ACCESS_TOKEN_EXPIRATION, MEET_PARTICIPANT_TOKEN_EXPIRATION, + MEET_RECORDING_TOKEN_EXPIRATION, MEET_REFRESH_TOKEN_EXPIRATION } from '../environment.js'; import { LoggerService } from './index.js'; @@ -58,6 +59,25 @@ export class TokenService { return await this.generateJwtToken(tokenOptions, permissions.livekit); } + async generateRecordingToken( + roomId: string, + role: ParticipantRole, + permissions: RecordingPermissions + ): Promise { + this.logger.info(`Generating recording token for room ${roomId}`); + const tokenOptions: AccessTokenOptions = { + ttl: MEET_RECORDING_TOKEN_EXPIRATION, + metadata: JSON.stringify({ + role, + recordingPermissions: permissions + }) + }; + const grants: VideoGrant = { + room: roomId + }; + return await this.generateJwtToken(tokenOptions, grants); + } + private async generateJwtToken(tokenOptions: AccessTokenOptions, grants?: VideoGrant): Promise { const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, tokenOptions);