backend: Implement recording token generation and update room preferences handling

This commit is contained in:
juancarmore 2025-04-25 11:49:03 +02:00
parent 5a7185caa3
commit 576b1f7d98
8 changed files with 193 additions and 45 deletions

View File

@ -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',

View File

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

View File

@ -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',

View File

@ -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<MeetRecordingAccess> = z.enum([
MeetRecordingAccess.ADMIN,
MeetRecordingAccess.ADMIN_MODERATOR,
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER,
MeetRecordingAccess.PUBLIC
]);
const RecordingPreferencesSchema: z.ZodType<MeetRecordingPreferences> = z.object({
enabled: z.boolean()
enabled: z.boolean(),
allowAccessTo: RecordingAccessSchema
});
const ChatPreferencesSchema: z.ZodType<MeetChatPreferences> = z.object({
@ -89,7 +98,7 @@ const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = 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('.'),

View File

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

View File

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

View File

@ -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<ParticipantRole> {
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<string> {
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<MeetRoom>, 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);

View File

@ -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<string> {
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<string> {
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, tokenOptions);