backend: add getRecordingUrl endpoint and extend getRecordingMedia endpoint to be accesible using recording access secrets
This commit is contained in:
parent
e7fba001e4
commit
7efe31f8f4
@ -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}'`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user