backend: add getRecordingUrl endpoint and extend getRecordingMedia endpoint to be accesible using recording access secrets

This commit is contained in:
juancarmore 2025-06-09 21:03:14 +02:00
parent e7fba001e4
commit 7efe31f8f4
5 changed files with 135 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@ -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 &&

View File

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