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 { Readable } from 'stream';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
import { handleError, internalError, rejectRequestFromMeetError } from '../models/error.model.js'; import {
import { LoggerService, RecordingService } from '../services/index.js'; 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) => { export const startRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
@ -213,3 +218,28 @@ export const getRecordingMedia = async (req: Request, res: Response) => {
handleError(res, error, `streaming recording '${recordingId}'`); 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 { RecordingHelper } from '../helpers/index.js';
import { import {
errorInsufficientPermissions, errorInsufficientPermissions,
errorInvalidRecordingSecret,
errorRecordingDisabled, errorRecordingDisabled,
errorRecordingNotFound,
errorRoomMetadataNotFound, errorRoomMetadataNotFound,
handleError, handleError,
rejectRequestFromMeetError rejectRequestFromMeetError
@ -64,9 +66,15 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res
const roomId = extractRoomIdFromRequest(req); const roomId = extractRoomIdFromRequest(req);
const payload = req.session?.tokenClaims; 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. * If there is no token, the user is allowed to access the resource because one of the following reasons:
// In this case, the user is allowed to access the resource *
* - 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) { if (!payload) {
return next(); 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. * Middleware to configure authentication for retrieving recording media based on recording access.
* *
* - Admin and recording token are always allowed * - API key, admin and recording token are always allowed.
* - If recording access is public, anonymous users are 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) => { export const configureRecordingMediaAuth = async (req: Request, res: Response, next: NextFunction) => {
const storageService = container.get(MeetStorageService); 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; let recordingAccess: MeetRecordingAccess | undefined;
try { try {
@ -132,8 +176,10 @@ export const configureRecordingMediaAuth = async (req: Request, res: Response, n
return handleError(res, error, 'checking recording permissions'); return handleError(res, error, 'checking recording permissions');
} }
// Always allow API key, admin, and recording token
const authValidators = [apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator]; const authValidators = [apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator];
// If access is public, allow anonymous users as well
if (recordingAccess === MeetRecordingAccess.PUBLIC) { if (recordingAccess === MeetRecordingAccess.PUBLIC) {
authValidators.push(allowAnonymous); authValidators.push(allowAnonymous);
} }

View File

@ -82,6 +82,9 @@ const GetRecordingMediaSchema = z.object({
params: z.object({ params: z.object({
recordingId: nonEmptySanitizedRecordingId('recordingId') recordingId: nonEmptySanitizedRecordingId('recordingId')
}), }),
query: z.object({
secret: z.string().optional()
}),
headers: z headers: z
.object({ .object({
range: z range: z
@ -111,6 +114,23 @@ const GetRecordingsFiltersSchema: z.ZodType<MeetRecordingFilters> = z.object({
fields: z.string().optional() 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) => { export const withValidStartRecordingRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body); const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body);
@ -158,9 +178,10 @@ export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response,
next(); 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({ const { success, error, data } = GetRecordingMediaSchema.safeParse({
params: req.params, params: req.params,
query: req.query,
headers: req.headers headers: req.headers
}); });
@ -169,6 +190,22 @@ export const withValidGetMediaRequest = (req: Request, res: Response, next: Next
} }
req.params.recordingId = data.params.recordingId; req.params.recordingId = data.params.recordingId;
req.query.secret = data.query.secret;
req.headers.range = data.headers.range; req.headers.range = data.headers.range;
next(); 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); 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 => { const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
return ( return (
error instanceof OpenViduMeetError && error instanceof OpenViduMeetError &&

View File

@ -13,7 +13,8 @@ import {
withCanRecordPermission, withCanRecordPermission,
withCanRetrieveRecordingsPermission, withCanRetrieveRecordingsPermission,
withRecordingEnabled, withRecordingEnabled,
withValidGetMediaRequest, withValidGetRecordingMediaRequest,
withValidGetRecordingUrlRequest,
withValidRecordingBulkDeleteRequest, withValidRecordingBulkDeleteRequest,
withValidRecordingFiltersRequest, withValidRecordingFiltersRequest,
withValidRecordingId, withValidRecordingId,
@ -54,11 +55,18 @@ recordingRouter.delete(
); );
recordingRouter.get( recordingRouter.get(
'/:recordingId/media', '/:recordingId/media',
withValidGetMediaRequest, withValidGetRecordingMediaRequest,
configureRecordingMediaAuth, configureRecordingMediaAuth,
withCanRetrieveRecordingsPermission, withCanRetrieveRecordingsPermission,
recordingCtrl.getRecordingMedia recordingCtrl.getRecordingMedia
); );
recordingRouter.get(
'/:recordingId/url',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withValidGetRecordingUrlRequest,
withCanRetrieveRecordingsPermission,
recordingCtrl.getRecordingUrl
);
// Internal Recording Routes // Internal Recording Routes
export const internalRecordingRouter = Router(); export const internalRecordingRouter = Router();