From cc734790efc91c4f659748057c39b7123e3c878f Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Mon, 25 May 2026 16:39:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=BD=95=E5=88=B6=E5=BC=80?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- client/public/call-view-controller.js | 16 +- client/public/meeting-recorder.js | 336 ++++++++++++++++++++++++++ client/public/store.js | 82 +++++++ client/test/meeting-recorder.test.js | 123 ++++++++++ src/server.ts | 190 +++++++++++++++ 6 files changed, 740 insertions(+), 10 deletions(-) create mode 100644 client/public/meeting-recorder.js create mode 100644 client/test/meeting-recorder.test.js diff --git a/.gitignore b/.gitignore index d1b35f2..bfa5d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ node_modules/ # Coverage coverage/ +recordings/ *.lcov .nyc_output @@ -184,4 +185,4 @@ out/ .history/ # Built Visual Studio Code Extensions -*.vsix \ No newline at end of file +*.vsix diff --git a/client/public/call-view-controller.js b/client/public/call-view-controller.js index 2f50250..352816d 100644 --- a/client/public/call-view-controller.js +++ b/client/public/call-view-controller.js @@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) { toggleVideo(); } - function toggleRecording() { - const state = store.getState(); - const currentState = state.session.localUser.mediaState.recording || false; - store.updateLocalMedia('recording', !currentState); - - if (!currentState) { - notify('\u5f00\u59cb\u5f55\u5236'); - } else { - notify('\u505c\u6b62\u5f55\u5236'); + async function toggleRecording() { + try { + const result = await store.toggleRecording(); + notify(result.message); + } + catch (error) { + notify(error.message || '\u5f55\u5236\u5931\u8d25'); } } diff --git a/client/public/meeting-recorder.js b/client/public/meeting-recorder.js new file mode 100644 index 0000000..3286cb8 --- /dev/null +++ b/client/public/meeting-recorder.js @@ -0,0 +1,336 @@ +const DEFAULT_WIDTH = 1280; +const DEFAULT_HEIGHT = 720; +const DEFAULT_FPS = 30; +const MIME_TYPE_CANDIDATES = [ + { mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' }, + { mimeType: 'video/mp4', extension: 'mp4' }, + { mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' }, + { mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' }, + { mimeType: 'video/webm', extension: 'webm' } +]; + +function getSupportedFormat(mediaRecorderCtor) { + if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') { + return { + mimeType: '', + extension: 'webm' + }; + } + + return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || { + mimeType: '', + extension: 'webm' + }; +} + +function isElementVisible(element) { + if (!element || element.classList.contains('hidden')) { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function drawVideoCover(context, video, x, y, width, height) { + const videoWidth = video.videoWidth || width; + const videoHeight = video.videoHeight || height; + const sourceRatio = videoWidth / videoHeight; + const targetRatio = width / height; + let sourceX = 0; + let sourceY = 0; + let sourceWidth = videoWidth; + let sourceHeight = videoHeight; + + if (sourceRatio > targetRatio) { + sourceWidth = videoHeight * targetRatio; + sourceX = (videoWidth - sourceWidth) / 2; + } else { + sourceHeight = videoWidth / targetRatio; + sourceY = (videoHeight - sourceHeight) / 2; + } + + context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); +} + +function drawEmptyFrame(context, canvas) { + context.fillStyle = '#111827'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.fillStyle = '#9ca3af'; + context.font = '24px sans-serif'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2); +} + +function drawGrid(context, videos, canvas) { + const columns = Math.ceil(Math.sqrt(videos.length)); + const rows = Math.ceil(videos.length / columns); + const gap = 8; + const tileWidth = (canvas.width - gap * (columns - 1)) / columns; + const tileHeight = (canvas.height - gap * (rows - 1)) / rows; + + videos.forEach((video, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + const x = column * (tileWidth + gap); + const y = row * (tileHeight + gap); + drawVideoCover(context, video, x, y, tileWidth, tileHeight); + }); +} + +function drawLocalPreview(context, localVideo, canvas) { + const previewWidth = Math.floor(canvas.width * 0.22); + const previewHeight = Math.floor(previewWidth * 9 / 16); + const margin = 24; + const x = canvas.width - previewWidth - margin; + const y = canvas.height - previewHeight - margin; + + context.fillStyle = 'rgba(0, 0, 0, 0.4)'; + context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8); + drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight); +} + +function collectStreams({ localStream, remoteStream, remoteStreams } = {}) { + return [ + localStream, + remoteStream, + ...Object.values(remoteStreams || {}) + ].filter(Boolean); +} + +function collectLiveAudioTracks(streams) { + return streams.flatMap(stream => stream.getAudioTracks()) + .filter(track => track.readyState !== 'ended'); +} + +export class MeetingRecorder { + constructor({ + documentRef = document, + windowRef = window, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + fps = DEFAULT_FPS + } = {}) { + this.document = documentRef; + this.window = windowRef; + this.width = width; + this.height = height; + this.fps = fps; + this.mediaRecorder = null; + this.chunks = []; + this.animationFrameId = null; + this.audioContext = null; + this.audioSources = []; + this.recordingStream = null; + this.connectionId = ''; + } + + isSupported() { + return Boolean( + this.window.MediaRecorder && + this.document.createElement('canvas').captureStream + ); + } + + isRecording() { + return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive'); + } + + async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) { + if (this.isRecording()) { + throw new Error('会议正在录制中'); + } + + if (!this.isSupported()) { + throw new Error('当前浏览器不支持会议录制'); + } + + const canvas = this.document.createElement('canvas'); + canvas.width = this.width; + canvas.height = this.height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('无法创建录制画布'); + } + + this.connectionId = connectionId || ''; + this.chunks = []; + this.canvas = canvas; + this.context = context; + + const canvasStream = canvas.captureStream(this.fps); + const streams = collectStreams({ localStream, remoteStream, remoteStreams }); + const audioTrack = this.createMixedAudioTrack(streams); + const tracks = [ + ...canvasStream.getVideoTracks(), + ...(audioTrack ? [audioTrack] : []) + ]; + + this.recordingStream = new this.window.MediaStream(tracks); + try { + this.startDrawing(); + this.startMediaRecorder(this.recordingStream); + } + catch (error) { + this.cleanup(); + throw error; + } + } + + stop() { + if (!this.isRecording()) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + this.pendingStop = { resolve, reject }; + this.mediaRecorder.stop(); + }); + } + + createMixedAudioTrack(streams) { + const audioTracks = collectLiveAudioTracks(streams); + + if (audioTracks.length === 0) { + return null; + } + + const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext; + if (!AudioContextCtor) { + return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0]; + } + + this.audioContext = new AudioContextCtor(); + const destination = this.audioContext.createMediaStreamDestination(); + + audioTracks.forEach(track => { + const sourceStream = new this.window.MediaStream([track]); + const source = this.audioContext.createMediaStreamSource(sourceStream); + source.connect(destination); + this.audioSources.push(source); + }); + + return destination.stream.getAudioTracks()[0] || null; + } + + startMediaRecorder(stream) { + const MediaRecorderCtor = this.window.MediaRecorder; + const format = getSupportedFormat(MediaRecorderCtor); + const options = format.mimeType ? { mimeType: format.mimeType } : {}; + this.fileExtension = format.extension; + + this.mediaRecorder = new MediaRecorderCtor(stream, options); + this.mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + this.chunks.push(event.data); + } + }; + this.mediaRecorder.onerror = (event) => { + if (this.pendingStop) { + this.pendingStop.reject(event.error || new Error('录制失败')); + this.pendingStop = null; + } + this.cleanup(); + }; + this.mediaRecorder.onstop = () => { + const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' }); + const filename = this.buildFilename(); + const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm'; + this.cleanup(); + if (this.pendingStop) { + this.pendingStop.resolve({ blob, filename, mimeType }); + this.pendingStop = null; + } + }; + + this.mediaRecorder.start(1000); + } + + startDrawing() { + const draw = () => { + this.drawFrame(); + this.animationFrameId = this.window.requestAnimationFrame(draw); + }; + + draw(); + } + + drawFrame() { + const context = this.context; + const canvas = this.canvas; + const videos = this.getRecordableVideos(); + const localVideo = videos.find(video => video.id === 'localVideo'); + const remoteVideos = videos.filter(video => video !== localVideo); + + context.fillStyle = '#020617'; + context.fillRect(0, 0, canvas.width, canvas.height); + + if (remoteVideos.length > 0) { + drawGrid(context, remoteVideos, canvas); + if (localVideo) { + drawLocalPreview(context, localVideo, canvas); + } + return; + } + + if (localVideo) { + drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height); + return; + } + + drawEmptyFrame(context, canvas); + } + + getRecordableVideos() { + return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo')) + .filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2); + } + + download(blob, filename = this.buildFilename()) { + const url = this.window.URL.createObjectURL(blob); + const link = this.document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + this.document.body.appendChild(link); + link.click(); + link.remove(); + + this.window.setTimeout(() => { + this.window.URL.revokeObjectURL(url); + }, 1000); + + return filename; + } + + buildFilename() { + const datePart = new Date().toISOString().replace(/[:.]/g, '-'); + const meetingPart = this.connectionId ? `-${this.connectionId}` : ''; + return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`; + } + + cleanup() { + if (this.animationFrameId) { + this.window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + if (this.recordingStream) { + this.recordingStream.getTracks().forEach(track => track.stop()); + this.recordingStream = null; + } + + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + + this.audioSources = []; + this.mediaRecorder = null; + this.canvas = null; + this.context = null; + this.chunks = []; + } +} diff --git a/client/public/store.js b/client/public/store.js index c78f105..d5892b4 100644 --- a/client/public/store.js +++ b/client/public/store.js @@ -9,6 +9,7 @@ import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './medi import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js'; import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js'; import { createLogger } from './logger.js'; +import { MeetingRecorder } from './meeting-recorder.js'; const logger = createLogger('store'); class CallStateManager { @@ -27,6 +28,7 @@ class CallStateManager { this.listeners = []; this.socketEventHandlers = {}; this._inviteEventSignaling = null; + this.meetingRecorder = new MeetingRecorder(); } subscribe(callback) { this.listeners.push(callback); @@ -107,6 +109,78 @@ class CallStateManager { await this._updateLocalMediaRefactored(mediaType, value); return; } + async toggleRecording() { + const isRecording = this.state.session.localUser.mediaState.recording || false; + + if (isRecording) { + return this.stopRecording(); + } + + return this.startRecording(); + } + async startRecording() { + if (this.state.session.status !== 'ongoing') { + throw new Error('会议连接成功后才能开始录制'); + } + + await this.meetingRecorder.start({ + localStream: this.state.localStream, + remoteStream: this.state.remoteStream, + remoteStreams: this.state.remoteStreams, + connectionId: this.connectionId + }); + await this._updateLocalMediaRefactored('recording', true); + + return { + recording: true, + message: '开始录制会议' + }; + } + async stopRecording() { + const result = await this.meetingRecorder.stop(); + await this._updateLocalMediaRefactored('recording', false); + if (!result) { + return { + recording: false, + message: '停止录制会议' + }; + } + + try { + const uploadResult = await this.uploadRecording(result); + return { + recording: false, + message: uploadResult?.url ? `录制已上传到服务器:${uploadResult.url}` : `录制已上传:${result.filename}` + }; + } + catch (error) { + logger.error('Recording upload failed:', error); + this.meetingRecorder.download(result.blob, result.filename); + return { + recording: false, + message: `上传失败,已下载到本地:${result.filename}` + }; + } + } + async uploadRecording({ blob, filename }) { + const formData = new FormData(); + formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown'); + formData.append('userId', this.state.session.localUser.id || ''); + formData.append('filename', filename); + formData.append('recording', blob, filename); + + const response = await fetch('/api/recordings', { + method: 'POST', + body: formData + }); + + const responseBody = await response.json().catch(() => ({})); + if (!response.ok || responseBody.success === false) { + throw new Error(responseBody.message || 'Recording upload failed'); + } + + return responseBody; + } async _updateLocalMediaRefactored(mediaType, value) { if (mediaType === 'video' && value) { await this._enableLocalVideo(); @@ -423,6 +497,14 @@ class CallStateManager { this.startActivityDetection(this.state.remoteStream, { isLocal: false }); } async hangUp() { + if (this.meetingRecorder?.isRecording()) { + try { + await this.stopRecording(); + } + catch (error) { + logger.error('Error stopping recording before hangUp:', error); + } + } this.clearStatsMessage(); this.stopNetworkQualityDetection(); if (this.durationInterval) { diff --git a/client/test/meeting-recorder.test.js b/client/test/meeting-recorder.test.js new file mode 100644 index 0000000..7c7f507 --- /dev/null +++ b/client/test/meeting-recorder.test.js @@ -0,0 +1,123 @@ +import { MeetingRecorder } from '../public/meeting-recorder.js'; + +class MediaStreamMock { + constructor(tracks = []) { + this.tracks = tracks; + } + + getTracks() { + return this.tracks; + } + + getAudioTracks() { + return this.tracks.filter(track => track.kind === 'audio'); + } + + getVideoTracks() { + return this.tracks.filter(track => track.kind === 'video'); + } +} + +class MediaRecorderMock { + static isTypeSupported() { + return true; + } + + constructor(stream, options) { + this.stream = stream; + this.mimeType = options.mimeType; + this.state = 'inactive'; + } + + start() { + this.state = 'recording'; + } + + stop() { + this.state = 'inactive'; + this.ondataavailable({ data: new Blob(['recording'], { type: this.mimeType }) }); + this.onstop(); + } +} + +function createTrack(kind) { + return { + kind, + readyState: 'live', + stop: jest.fn(), + clone: jest.fn(() => createTrack(kind)) + }; +} + +function createWindowMock({ mediaRecorder = MediaRecorderMock } = {}) { + return { + MediaRecorder: mediaRecorder, + MediaStream: MediaStreamMock, + URL: { + createObjectURL: jest.fn(() => 'blob:recording'), + revokeObjectURL: jest.fn() + }, + requestAnimationFrame: jest.fn(() => 1), + cancelAnimationFrame: jest.fn(), + setTimeout: jest.fn((callback) => { + callback(); + return 1; + }) + }; +} + +describe('MeetingRecorder', () => { + beforeEach(() => { + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + fillStyle: '', + font: '', + textAlign: '', + textBaseline: '', + fillRect: jest.fn(), + fillText: jest.fn(), + drawImage: jest.fn() + })); + HTMLCanvasElement.prototype.captureStream = jest.fn(() => new MediaStreamMock([createTrack('video')])); + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + test('starts and stops recording a meeting file', async () => { + const windowRef = createWindowMock(); + const recorder = new MeetingRecorder({ documentRef: document, windowRef }); + const localVideo = document.createElement('video'); + localVideo.id = 'localVideo'; + localVideo.srcObject = new MediaStreamMock([createTrack('video')]); + Object.defineProperty(localVideo, 'readyState', { value: 2 }); + Object.defineProperty(localVideo, 'videoWidth', { value: 640 }); + Object.defineProperty(localVideo, 'videoHeight', { value: 360 }); + localVideo.getBoundingClientRect = () => ({ width: 320, height: 180 }); + document.body.appendChild(localVideo); + + await recorder.start({ + localStream: new MediaStreamMock([createTrack('audio')]), + connectionId: '123-456-789' + }); + + expect(recorder.isRecording()).toBe(true); + + const result = await recorder.stop(); + + expect(result.filename).toContain('meeting-recording-123-456-789'); + expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2'); + expect(result.filename).toMatch(/\.mp4$/); + expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled(); + expect(recorder.isRecording()).toBe(false); + }); + + test('reports unsupported browsers', async () => { + HTMLCanvasElement.prototype.captureStream = undefined; + const windowRef = createWindowMock({ mediaRecorder: undefined }); + const recorder = new MeetingRecorder({ documentRef: document, windowRef }); + + await expect(recorder.start()).rejects.toThrow('当前浏览器不支持会议录制'); + }); +}); diff --git a/src/server.ts b/src/server.ts index 6159469..e28eb34 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,9 @@ const multer = require('multer'); const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024; const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); +const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024; +const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']); +const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']); function safeAvatarExtension(file: any): string { const originalExt = path.extname(file.originalname || '').toLowerCase(); @@ -40,6 +43,52 @@ function isAllowedAvatar(file: any): boolean { return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0; } +function getRecordingRoot(): string { + return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings')); +} + +function getRecordingUploadLimitBytes(): number { + const value = Number(process.env.RECORDING_MAX_BYTES); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES; +} + +function sanitizePathSegment(value: string | undefined, fallback: string): string { + const sanitized = (value || fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120); + return sanitized || fallback; +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function safeRecordingExtension(file: any): string { + const originalExt = path.extname(file.originalname || '').toLowerCase(); + if (ALLOWED_RECORDING_EXTENSIONS.has(originalExt)) { + return originalExt; + } + + switch (normalizeMimeType(file.mimetype)) { + case 'video/mp4': + return '.mp4'; + case 'video/webm': + return '.webm'; + default: + return ''; + } +} + +function normalizeMimeType(mimetype: string | undefined): string { + return (mimetype || '').split(';')[0].trim().toLowerCase(); +} + +function isAllowedRecording(file: any): boolean { + const mimetype = normalizeMimeType(file.mimetype); + const ext = safeRecordingExtension(file); + const isCompatibleMime = ALLOWED_RECORDING_MIME_TYPES.has(mimetype) || mimetype.startsWith('video/') || mimetype === 'text/plain' || mimetype === ''; + return ext.length > 0 && isCompatibleMime; +} + export const createServer = (config: Options): express.Express => { const app: express.Express = express(); resetHandler(config.mode); @@ -148,6 +197,147 @@ export const createServer = (config: Options): express.Express => { }); }); + const recordingRoot = getRecordingRoot(); + const recordingTempDir = path.join(recordingRoot, '.tmp'); + const recordingStorage = multer.diskStorage({ + destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => { + if (!fs.existsSync(recordingTempDir)) { + fs.mkdirSync(recordingTempDir, { recursive: true }); + } + cb(null, recordingTempDir); + }, + filename: (_req: any, file: any, cb: (error: Error | null, filename: string) => void) => { + cb(null, `${uuid()}${safeRecordingExtension(file)}`); + } + }); + + const recordingUpload = multer({ + storage: recordingStorage, + limits: { + fileSize: getRecordingUploadLimitBytes() + }, + fileFilter: (_req: express.Request, file: any, cb: (error: Error | null, acceptFile?: boolean) => void) => { + if (!isAllowedRecording(file)) { + log(LogLevel.warn, 'Recording upload rejected by type filter:', { + originalname: file.originalname, + mimetype: file.mimetype, + normalizedMimetype: normalizeMimeType(file.mimetype), + extension: safeRecordingExtension(file) + }); + cb(new Error('Only mp4 or webm recordings are allowed')); + return; + } + + cb(null, true); + } + }); + + app.post('/api/recordings', (req: express.Request, res: express.Response) => { + recordingUpload.single('recording')(req, res, (error: Error) => { + if (error) { + log(LogLevel.warn, 'Recording upload rejected:', error.message); + const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE'; + res.status(400).json({ + success: false, + message: isSizeLimit ? 'Recording file is too large' : error.message + }); + return; + } + + const request = req as any; + if (!request.file) { + res.status(400).json({ success: false, message: 'No recording uploaded' }); + return; + } + + const ext = safeRecordingExtension(request.file); + if (!ext) { + fs.unlink(request.file.path, () => undefined); + res.status(400).json({ success: false, message: 'Unsupported recording file type' }); + return; + } + + const recordingId = uuid(); + const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown'); + const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`); + const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`; + const meetingDir = path.join(recordingRoot, meetingId); + const finalPath = path.join(meetingDir, finalFilename); + + if (!isPathInside(recordingRoot, finalPath)) { + fs.unlink(request.file.path, () => undefined); + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + fs.mkdir(meetingDir, { recursive: true }, (mkdirError) => { + if (mkdirError) { + fs.unlink(request.file.path, () => undefined); + log(LogLevel.error, 'Error creating recording directory:', mkdirError); + res.status(500).json({ success: false, message: 'Recording directory unavailable' }); + return; + } + + fs.rename(request.file.path, finalPath, (renameError) => { + if (renameError) { + fs.unlink(request.file.path, () => undefined); + log(LogLevel.error, 'Error saving recording:', renameError); + res.status(500).json({ success: false, message: 'Recording save failed' }); + return; + } + + const metadata = { + id: recordingId, + meetingId, + filename: finalFilename, + originalFilename, + mimetype: normalizeMimeType(request.file.mimetype), + size: request.file.size, + userId: request.body.userId || '', + uploadedAt: new Date().toISOString() + }; + fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined); + + res.json({ + success: true, + recordingId, + meetingId, + filename: finalFilename, + originalFilename, + size: request.file.size, + url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download` + }); + }); + }); + }); + }); + + app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const filePath = path.join(recordingRoot, meetingId, filename); + if (!isPathInside(recordingRoot, filePath)) { + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + fs.access(filePath, fs.constants.R_OK, (accessError) => { + if (accessError) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + res.download(filePath, filename); + }); + }); + app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads'))); return app;