视频录制开发

This commit is contained in:
2026-05-25 16:39:13 +08:00
parent eb0106d296
commit cc734790ef
6 changed files with 740 additions and 10 deletions

1
.gitignore vendored
View File

@@ -43,6 +43,7 @@ node_modules/
# Coverage # Coverage
coverage/ coverage/
recordings/
*.lcov *.lcov
.nyc_output .nyc_output

View File

@@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) {
toggleVideo(); toggleVideo();
} }
function toggleRecording() { async function toggleRecording() {
const state = store.getState(); try {
const currentState = state.session.localUser.mediaState.recording || false; const result = await store.toggleRecording();
store.updateLocalMedia('recording', !currentState); notify(result.message);
}
if (!currentState) { catch (error) {
notify('\u5f00\u59cb\u5f55\u5236'); notify(error.message || '\u5f55\u5236\u5931\u8d25');
} else {
notify('\u505c\u6b62\u5f55\u5236');
} }
} }

View File

@@ -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 = [];
}
}

View File

@@ -9,6 +9,7 @@ import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './medi
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js'; import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js'; import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import { MeetingRecorder } from './meeting-recorder.js';
const logger = createLogger('store'); const logger = createLogger('store');
class CallStateManager { class CallStateManager {
@@ -27,6 +28,7 @@ class CallStateManager {
this.listeners = []; this.listeners = [];
this.socketEventHandlers = {}; this.socketEventHandlers = {};
this._inviteEventSignaling = null; this._inviteEventSignaling = null;
this.meetingRecorder = new MeetingRecorder();
} }
subscribe(callback) { subscribe(callback) {
this.listeners.push(callback); this.listeners.push(callback);
@@ -107,6 +109,78 @@ class CallStateManager {
await this._updateLocalMediaRefactored(mediaType, value); await this._updateLocalMediaRefactored(mediaType, value);
return; 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) { async _updateLocalMediaRefactored(mediaType, value) {
if (mediaType === 'video' && value) { if (mediaType === 'video' && value) {
await this._enableLocalVideo(); await this._enableLocalVideo();
@@ -423,6 +497,14 @@ class CallStateManager {
this.startActivityDetection(this.state.remoteStream, { isLocal: false }); this.startActivityDetection(this.state.remoteStream, { isLocal: false });
} }
async hangUp() { async hangUp() {
if (this.meetingRecorder?.isRecording()) {
try {
await this.stopRecording();
}
catch (error) {
logger.error('Error stopping recording before hangUp:', error);
}
}
this.clearStatsMessage(); this.clearStatsMessage();
this.stopNetworkQualityDetection(); this.stopNetworkQualityDetection();
if (this.durationInterval) { if (this.durationInterval) {

View File

@@ -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('当前浏览器不支持会议录制');
});
});

View File

@@ -15,6 +15,9 @@ const multer = require('multer');
const AVATAR_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024; 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_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const ALLOWED_AVATAR_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.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 { function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase(); 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; 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 => { export const createServer = (config: Options): express.Express => {
const app: express.Express = express(); const app: express.Express = express();
resetHandler(config.mode); 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'))); app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
return app; return app;