diff --git a/backend/src/controllers/recording.controller.ts b/backend/src/controllers/recording.controller.ts index cd00fa5..369a6ae 100644 --- a/backend/src/controllers/recording.controller.ts +++ b/backend/src/controllers/recording.controller.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; -import { OpenViduMeetError } from '../models/error.model.js'; +import { internalError, OpenViduMeetError } from '../models/error.model.js'; import { LoggerService, RecordingService } from '../services/index.js'; +import { Readable } from 'stream'; export const startRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); @@ -170,43 +171,84 @@ export const getRecordingMedia = async (req: Request, res: Response) => { const recordingId = req.params.recordingId; const range = req.headers.range; + let fileStream: Readable | undefined; try { logger.info(`Streaming recording ${recordingId}`); const recordingService = container.get(RecordingService); - const { fileSize, fileStream, start, end } = await recordingService.getRecordingAsStream(recordingId, range); + const result = await recordingService.getRecordingAsStream(recordingId, range); + const { fileSize, start, end } = result; + fileStream = result.fileStream; + fileStream.on('error', (streamError) => { + logger.error(`Error streaming recording ${recordingId}: ${streamError.message}`); + + if (!res.headersSent) { + const error = internalError(streamError); + res.status(error.statusCode).json({ name: 'Recording Error', message: error.message }); + } + + res.end(); + }); + + // Handle client disconnection + req.on('close', () => { + if (fileStream && !fileStream.destroyed) { + logger.debug(`Client closed connection for recording media ${recordingId}`); + fileStream.destroy(); + } + }); + + // Handle partial requests (HTTP Range requests) if (range && fileSize && start !== undefined && end !== undefined) { const contentLength = end - start + 1; + // Set headers for partial content response res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': contentLength, - 'Content-Type': 'video/mp4' + 'Content-Type': 'video/mp4', + 'Cache-Control': 'public, max-age=3600' }); - - fileStream.on('error', (streamError) => { - logger.error(`Error while streaming the file: ${streamError.message}`); - res.end(); - }); - - fileStream.pipe(res).on('finish', () => res.end()); } else { - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', 'video/mp4'); - - if (fileSize) res.setHeader('Content-Length', fileSize); - - fileStream.pipe(res).on('finish', () => res.end()); + // Set headers for full content response + res.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Type': 'video/mp4', + 'Content-Length': fileSize || undefined, + 'Cache-Control': 'public, max-age=3600' + }); } + + fileStream + .pipe(res) + .on('finish', () => { + logger.debug(`Finished streaming recording ${recordingId}`); + + res.end(); + }) + .on('error', (err) => { + logger.error(`Error in response stream for ${recordingId}: ${err.message}`); + + if (!res.headersSent) { + res.status(500).end(); + } + }); } catch (error) { + if (fileStream && !fileStream.destroyed) { + fileStream.destroy(); + } + if (error instanceof OpenViduMeetError) { logger.error(`Error streaming recording: ${error.message}`); return res.status(error.statusCode).json({ name: error.name, message: error.message }); } - return res.status(500).json({ name: 'Recording Error', message: 'Unexpected error streaming recording' }); + logger.error(`Unexpected error streaming recording ${recordingId}: ${error}`); + return res + .status(500) + .json({ name: 'Recording Error', message: 'An unexpected error occurred while processing the recording' }); } }; diff --git a/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/backend/src/middlewares/request-validators/recording-validator.middleware.ts index ebb3db6..7f96d23 100644 --- a/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -77,6 +77,22 @@ const BulkDeleteRecordingsSchema = z.object({ ) }); +const GetRecordingMediaSchema = z.object({ + params: z.object({ + recordingId: nonEmptySanitizedRecordingId('recordingId') + }), + headers: z + .object({ + range: z + .string() + .regex(/^bytes=\d+-\d*$/, { + message: 'Invalid range header format. Expected: bytes=start-end' + }) + .optional() + }) + .passthrough() // Allow other headers to pass through +}); + const GetRecordingsFiltersSchema: z.ZodType = z.object({ maxItems: z.coerce .number() @@ -141,6 +157,21 @@ export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next(); }; +export const withValidGetMediaRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = GetRecordingMediaSchema.safeParse({ + params: req.params, + headers: req.headers + }); + + if (!success) { + return rejectRequest(res, error); + } + + req.params.recordingId = data.params.recordingId; + req.headers.range = data.headers.range; + next(); +}; + const rejectRequest = (res: Response, error: z.ZodError) => { const errors = error.errors.map((error) => ({ field: error.path.join('.'), diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index fed57dd..77cb58c 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -1,4 +1,4 @@ -type StatusError = 400 | 401 | 403 | 404 | 406 | 409 | 422 | 500 | 503; +type StatusError = 400 | 401 | 403 | 404 | 406 | 409 | 416 | 422 | 500 | 503; export class OpenViduMeetError extends Error { name: string; statusCode: StatusError; @@ -79,6 +79,14 @@ export const errorRecordingStartTimeout = (roomId: string): OpenViduMeetError => return new OpenViduMeetError('Recording Error', `Recording in room '${roomId}' timed out while starting`, 503); }; +export const errorRecordingRangeNotSatisfiable = (recordingId: string, fileSize: number): OpenViduMeetError => { + return new OpenViduMeetError( + 'Recording Error', + `Recording '${recordingId}' range not satisfiable. File size: ${fileSize}`, + 416 + ); +}; + export const errorRoomHasNoParticipants = (roomId: string): OpenViduMeetError => { return new OpenViduMeetError('Recording Error', `The room '${roomId}' has no participants`, 409); }; diff --git a/backend/src/routes/recording.routes.ts b/backend/src/routes/recording.routes.ts index 67b9f01..99c0762 100644 --- a/backend/src/routes/recording.routes.ts +++ b/backend/src/routes/recording.routes.ts @@ -13,6 +13,7 @@ import { withCanRecordPermission, withCanRetrieveRecordingsPermission, withRecordingEnabled, + withValidGetMediaRequest, withValidRecordingBulkDeleteRequest, withValidRecordingFiltersRequest, withValidRecordingId, diff --git a/backend/src/services/recording.service.ts b/backend/src/services/recording.service.ts index 6d492fb..9d1c37f 100644 --- a/backend/src/services/recording.service.ts +++ b/backend/src/services/recording.service.ts @@ -13,6 +13,7 @@ import { errorRecordingCannotBeStoppedWhileStarting, errorRecordingNotFound, errorRecordingNotStopped, + errorRecordingRangeNotSatisfiable, errorRecordingStartTimeout, errorRoomHasNoParticipants, errorRoomNotFound, @@ -356,8 +357,13 @@ export class RecordingService { recordingId: string, range?: string ): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> { - const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB + const DEFAULT_RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB const recordingInfo: MeetRecordingInfo = await this.getRecording(recordingId); + + if (recordingInfo.status !== MeetRecordingStatus.COMPLETE) { + throw errorRecordingNotStopped(recordingId); + } + const recordingPath = `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/${RecordingHelper.extractFilename(recordingInfo)}`; if (!recordingPath) throw new Error(`Error extracting path from recording ${recordingId}`); @@ -365,23 +371,59 @@ export class RecordingService { const data = await this.s3Service.getHeaderObject(recordingPath); const fileSize = data.ContentLength; - if (range && fileSize) { + if (!fileSize) { + this.logger.error(`Error getting file size for recording ${recordingId}`); + throw internalError(`Error getting file size for recording ${recordingId}`); + } + + if (range) { // Parse the range header - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const endRange = parts[1] ? parseInt(parts[1], 10) : start + RECORDING_FILE_PORTION_SIZE; - const end = Math.min(endRange, fileSize - 1); + const matches = range.match(/^bytes=(\d+)-(\d*)$/)!; + + const start = parseInt(matches[1], 10); + let end = matches[2] ? parseInt(matches[2], 10) : start + DEFAULT_RECORDING_FILE_PORTION_SIZE; + + // Validate the range values + if (isNaN(start) || isNaN(end) || start < 0) { + this.logger.warn(`Invalid range values for recording ${recordingId}: start=${start}, end=${end}`); + this.logger.warn(`Returning full stream for recording ${recordingId}`); + return this.getFullStreamResponse(recordingPath, fileSize); + } + + if (start >= fileSize) { + this.logger.error( + `Invalid range values for recording ${recordingId}: start=${start}, end=${end}, fileSize=${fileSize}` + ); + throw errorRecordingRangeNotSatisfiable(recordingId, fileSize); + } + + // Adjust the end value to ensure it doesn't exceed the file size + end = Math.min(end, fileSize - 1); + + // If the start is greater than the end, return the full stream + if (start > end) { + this.logger.warn(`Invalid range values after adjustment: start=${start}, end=${end}`); + return this.getFullStreamResponse(recordingPath, fileSize); + } + const fileStream = await this.s3Service.getObjectAsStream(recordingPath, MEET_S3_BUCKET, { start, end }); return { fileSize, fileStream, start, end }; } else { - const fileStream = await this.s3Service.getObjectAsStream(recordingPath); - return { fileSize, fileStream }; + return this.getFullStreamResponse(recordingPath, fileSize); } } + protected async getFullStreamResponse( + recordingPath: string, + fileSize: number + ): Promise<{ fileSize: number; fileStream: Readable }> { + const fileStream = await this.s3Service.getObjectAsStream(recordingPath, MEET_S3_BUCKET); + return { fileSize, fileStream }; + } + /** * Acquires a Redis-based lock to indicate that a recording is active for a specific room. *