backend: Implement recording token generation and update room preferences handling
This commit is contained in:
parent
5a7185caa3
commit
576b1f7d98
@ -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',
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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('.'),
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user