From 7efe31f8f44b89a56575b778a66f7227d91a4bc3 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Mon, 9 Jun 2025 21:03:14 +0200 Subject: [PATCH] backend: add getRecordingUrl endpoint and extend getRecordingMedia endpoint to be accesible using recording access secrets --- .../src/controllers/recording.controller.ts | 34 ++++++++++- .../src/middlewares/recording.middleware.ts | 56 +++++++++++++++++-- .../recording-validator.middleware.ts | 39 ++++++++++++- backend/src/models/error.model.ts | 4 ++ backend/src/routes/recording.routes.ts | 12 +++- 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/backend/src/controllers/recording.controller.ts b/backend/src/controllers/recording.controller.ts index e434be0..e3e8e94 100644 --- a/backend/src/controllers/recording.controller.ts +++ b/backend/src/controllers/recording.controller.ts @@ -2,8 +2,13 @@ import { Request, Response } from 'express'; import { Readable } from 'stream'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; -import { handleError, internalError, rejectRequestFromMeetError } from '../models/error.model.js'; -import { LoggerService, RecordingService } from '../services/index.js'; +import { + errorRecordingNotFound, + handleError, + internalError, + rejectRequestFromMeetError +} from '../models/error.model.js'; +import { LoggerService, MeetStorageService, RecordingService } from '../services/index.js'; export const startRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); @@ -213,3 +218,28 @@ export const getRecordingMedia = async (req: Request, res: Response) => { handleError(res, error, `streaming recording '${recordingId}'`); } }; + +export const getRecordingUrl = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const recordingId = req.params.recordingId; + const privateAccess = req.query.privateAccess === 'true'; + + logger.info(`Getting URL for recording '${recordingId}'`); + + try { + const storageService = container.get(MeetStorageService); + const recordingSecrets = await storageService.getAccessRecordingSecrets(recordingId); + + if (!recordingSecrets) { + const error = errorRecordingNotFound(recordingId); + return rejectRequestFromMeetError(res, error); + } + + const secret = privateAccess ? recordingSecrets.privateAccessSecret : recordingSecrets.publicAccessSecret; + const recordingUrl = `${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}/media?secret=${secret}`; + + return res.status(200).json({ url: recordingUrl }); + } catch (error) { + handleError(res, error, `getting URL for recording '${recordingId}'`); + } +}; diff --git a/backend/src/middlewares/recording.middleware.ts b/backend/src/middlewares/recording.middleware.ts index dff1ad0..d52beb0 100644 --- a/backend/src/middlewares/recording.middleware.ts +++ b/backend/src/middlewares/recording.middleware.ts @@ -4,7 +4,9 @@ import { container } from '../config/index.js'; import { RecordingHelper } from '../helpers/index.js'; import { errorInsufficientPermissions, + errorInvalidRecordingSecret, errorRecordingDisabled, + errorRecordingNotFound, errorRoomMetadataNotFound, handleError, rejectRequestFromMeetError @@ -64,9 +66,15 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res const roomId = extractRoomIdFromRequest(req); const payload = req.session?.tokenClaims; - // If there is no token, it is invoked using the API key, the user is admin or - // the user is anonymous and recording access is public. - // In this case, the user is allowed to access the resource + /** + * If there is no token, the user is allowed to access the resource because one of the following reasons: + * + * - The request is invoked using the API key. + * - The user is admin. + * - The user is anonymous and recording access is public. + * - The user is anonymous and is using the public access secret. + * - The user is using the private access secret and is authenticated. + */ if (!payload) { return next(); } @@ -110,12 +118,48 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo /** * Middleware to configure authentication for retrieving recording media based on recording access. * - * - Admin and recording token are always allowed - * - If recording access is public, anonymous users are allowed + * - API key, admin and recording token are always allowed. + * - If a valid secret is provided in the query, access is granted according to the secret type. + * - If recording access is public, anonymous users are allowed. */ export const configureRecordingMediaAuth = async (req: Request, res: Response, next: NextFunction) => { const storageService = container.get(MeetStorageService); + const secret = req.query.secret as string; + + // If a secret is provided, validate it against the stored secrets + // and apply the appropriate authentication logic. + if (secret) { + try { + const recordingId = req.params.recordingId as string; + const recordingSecrets = await storageService.getAccessRecordingSecrets(recordingId); + + if (!recordingSecrets) { + const error = errorRecordingNotFound(recordingId); + return rejectRequestFromMeetError(res, error); + } + + const authValidators = []; + + switch (secret) { + case recordingSecrets.publicAccessSecret: + authValidators.push(allowAnonymous); + break; + case recordingSecrets.privateAccessSecret: + authValidators.push(tokenAndRoleValidator(UserRole.USER)); + break; + default: + // Invalid secret provided + return rejectRequestFromMeetError(res, errorInvalidRecordingSecret(recordingId, secret)); + } + + return withAuth(...authValidators)(req, res, next); + } catch (error) { + return handleError(res, error, 'retrieving recording secrets'); + } + } + + // If no secret is provided, determine access based on room's recording preferences let recordingAccess: MeetRecordingAccess | undefined; try { @@ -132,8 +176,10 @@ export const configureRecordingMediaAuth = async (req: Request, res: Response, n return handleError(res, error, 'checking recording permissions'); } + // Always allow API key, admin, and recording token const authValidators = [apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator]; + // If access is public, allow anonymous users as well if (recordingAccess === MeetRecordingAccess.PUBLIC) { authValidators.push(allowAnonymous); } diff --git a/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/backend/src/middlewares/request-validators/recording-validator.middleware.ts index 8093aab..84c1a2c 100644 --- a/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -82,6 +82,9 @@ const GetRecordingMediaSchema = z.object({ params: z.object({ recordingId: nonEmptySanitizedRecordingId('recordingId') }), + query: z.object({ + secret: z.string().optional() + }), headers: z .object({ range: z @@ -111,6 +114,23 @@ const GetRecordingsFiltersSchema: z.ZodType = z.object({ fields: z.string().optional() }); +const GetRecordingUrlSchema = z.object({ + params: z.object({ + recordingId: nonEmptySanitizedRecordingId('recordingId') + }), + query: z.object({ + privateAccess: z + .preprocess((val) => { + if (typeof val === 'string') { + return val.toLowerCase() === 'true'; + } + + return val; + }, z.boolean()) + .default(false) + }) +}); + export const withValidStartRecordingRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body); @@ -158,9 +178,10 @@ export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next(); }; -export const withValidGetMediaRequest = (req: Request, res: Response, next: NextFunction) => { +export const withValidGetRecordingMediaRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = GetRecordingMediaSchema.safeParse({ params: req.params, + query: req.query, headers: req.headers }); @@ -169,6 +190,22 @@ export const withValidGetMediaRequest = (req: Request, res: Response, next: Next } req.params.recordingId = data.params.recordingId; + req.query.secret = data.query.secret; req.headers.range = data.headers.range; next(); }; + +export const withValidGetRecordingUrlRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = GetRecordingUrlSchema.safeParse({ + params: req.params, + query: req.query + }); + + if (!success) { + return rejectUnprocessableRequest(res, error); + } + + req.params.recordingId = data.params.recordingId; + req.query.privateAccess = data.query.privateAccess ? 'true' : 'false'; + next(); +}; diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index 9186acc..54cd84b 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -134,6 +134,10 @@ export const errorRoomHasNoParticipants = (roomId: string): OpenViduMeetError => return new OpenViduMeetError('Recording Error', `Room '${roomId}' has no participants`, 409); }; +export const errorInvalidRecordingSecret = (recordingId: string, secret: string): OpenViduMeetError => { + return new OpenViduMeetError('Recording Error', `Secret '${secret}' is not recognized for recording '${recordingId}'`, 400); +}; + const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => { return ( error instanceof OpenViduMeetError && diff --git a/backend/src/routes/recording.routes.ts b/backend/src/routes/recording.routes.ts index 860f2b5..b27c96a 100644 --- a/backend/src/routes/recording.routes.ts +++ b/backend/src/routes/recording.routes.ts @@ -13,7 +13,8 @@ import { withCanRecordPermission, withCanRetrieveRecordingsPermission, withRecordingEnabled, - withValidGetMediaRequest, + withValidGetRecordingMediaRequest, + withValidGetRecordingUrlRequest, withValidRecordingBulkDeleteRequest, withValidRecordingFiltersRequest, withValidRecordingId, @@ -54,11 +55,18 @@ recordingRouter.delete( ); recordingRouter.get( '/:recordingId/media', - withValidGetMediaRequest, + withValidGetRecordingMediaRequest, configureRecordingMediaAuth, withCanRetrieveRecordingsPermission, recordingCtrl.getRecordingMedia ); +recordingRouter.get( + '/:recordingId/url', + withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator), + withValidGetRecordingUrlRequest, + withCanRetrieveRecordingsPermission, + recordingCtrl.getRecordingUrl +); // Internal Recording Routes export const internalRecordingRouter = Router();