import * as express from 'express'; import * as path from 'path'; import * as fs from 'fs'; import * as morgan from 'morgan'; import { spawn } from 'child_process'; import { v4 as uuid } from 'uuid'; import signaling from './signaling'; import { log, LogLevel } from './log'; import Options from './class/options'; import { reset as resetHandler } from './class/httphandler'; import { initSwagger } from './swagger'; import { ServerAudioRecorderManager } from './class/serveraudiorecorder'; const cors = require('cors'); 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']); type RecordingMetadata = { id?: string; meetingId?: string; filename?: string; originalFilename?: string; mimetype?: string; size?: number; userId?: string; host?: RecordingPerson; participants?: RecordingPerson[]; uploadedAt?: string; updatedAt?: string; }; type RecordingPerson = { participantId?: string; userId?: string; id?: string; name?: string; avatar?: string; role?: string; status?: string; mediaState?: any; }; function safeAvatarExtension(file: any): string { const originalExt = path.extname(file.originalname || '').toLowerCase(); if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { return originalExt; } switch (file.mimetype) { case 'image/jpeg': return '.jpg'; case 'image/png': return '.png'; case 'image/webp': return '.webp'; case 'image/gif': return '.gif'; default: return ''; } } 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; } function getRecordingMimeTypeFromExtension(ext: string): string { return ext.toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; } function parseJsonField(value: any): any { if (value === undefined || value === null || value === '') { return undefined; } if (typeof value !== 'string') { return value; } try { return JSON.parse(value); } catch (_error) { return undefined; } } function sanitizeMetadataString(value: any, maxLength = 200): string { if (value === undefined || value === null) { return ''; } return String(value) .split('') .filter((char) => { const code = char.charCodeAt(0); return code > 31 && code !== 127; }) .join('') .trim() .slice(0, maxLength); } function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined { const parsed = parseJsonField(value); if (!parsed || typeof parsed !== 'object') { return undefined; } const person: RecordingPerson = { participantId: sanitizeMetadataString(parsed.participantId || parsed.connectionId, 120), userId: sanitizeMetadataString(parsed.userId || parsed.id, 120), id: sanitizeMetadataString(parsed.id || parsed.userId, 120), name: sanitizeMetadataString(parsed.name, 120), avatar: sanitizeMetadataString(parsed.avatar, 400), role: sanitizeMetadataString(parsed.role || fallbackRole, 40), status: sanitizeMetadataString(parsed.status, 40) }; if (parsed.mediaState && typeof parsed.mediaState === 'object') { person.mediaState = { audio: Boolean(parsed.mediaState.audio), video: Boolean(parsed.mediaState.video), screenShare: Boolean(parsed.mediaState.screenShare), recording: Boolean(parsed.mediaState.recording), isSpeaking: Boolean(parsed.mediaState.isSpeaking) }; } return person; } function sanitizeRecordingParticipants(value: any): RecordingPerson[] { const parsed = parseJsonField(value); if (!Array.isArray(parsed)) { return []; } return parsed .slice(0, 100) .map((participant) => sanitizeRecordingPerson(participant, 'participant')) .filter((participant) => Boolean(participant)); } function buildFallbackRecordingHost(userId: string | undefined): RecordingPerson | undefined { const safeUserId = sanitizeMetadataString(userId, 120); if (!safeUserId) { return undefined; } return { userId: safeUserId, id: safeUserId, role: 'host' }; } function readRecordingMetadata(metadataPath: string): RecordingMetadata { try { if (!fs.existsSync(metadataPath)) { return {}; } return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); } catch (error) { log(LogLevel.warn, 'Failed to read recording metadata:', error); return {}; } } function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string { const ext = path.extname(filename); const mimetype = normalizeMimeType(metadata.mimetype); if (mimetype && mimetype !== 'text/plain' && mimetype !== 'application/octet-stream') { return mimetype; } return getRecordingMimeTypeFromExtension(ext); } function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) { const filePath = path.join(recordingRoot, meetingId, filename); const metadataPath = path.join(recordingRoot, meetingId, `${filename}.json`); const stat = fs.statSync(filePath); const metadata = readRecordingMetadata(metadataPath); const resolvedMeetingId = metadata.meetingId || meetingId; const resolvedFilename = metadata.filename || filename; const participants = Array.isArray(metadata.participants) ? metadata.participants : []; return { id: metadata.id || path.basename(filename, path.extname(filename)), meetingId: resolvedMeetingId, filename: resolvedFilename, originalFilename: metadata.originalFilename || filename, mimetype: getRecordingMimeType(filename, metadata), size: stat.size, userId: metadata.userId || '', host: metadata.host || buildFallbackRecordingHost(metadata.userId), participants, participantCount: participants.length, uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(), updatedAt: metadata.updatedAt || stat.mtime.toISOString(), modifiedAt: stat.mtime.toISOString(), downloadUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/download`, streamUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/stream` }; } function listRecordings(recordingRoot: string) { if (!fs.existsSync(recordingRoot)) { return []; } const recordings = []; const meetingIds = fs.readdirSync(recordingRoot).filter((name) => { const fullPath = path.join(recordingRoot, name); return name !== '.tmp' && fs.statSync(fullPath).isDirectory(); }); meetingIds.forEach((meetingId) => { const meetingDir = path.join(recordingRoot, meetingId); fs.readdirSync(meetingDir).forEach((filename) => { const ext = path.extname(filename).toLowerCase(); const filePath = path.join(meetingDir, filename); if (!ALLOWED_RECORDING_EXTENSIONS.has(ext) || !fs.statSync(filePath).isFile()) { return; } recordings.push(buildRecordingInfo(recordingRoot, meetingId, filename)); }); }); recordings.sort((a, b) => Date.parse(b.uploadedAt) - Date.parse(a.uploadedAt)); return recordings; } function removeEmptyDirectory(directory: string): void { try { if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) { fs.rmdirSync(directory); } } catch (error) { log(LogLevel.warn, 'Failed to remove empty recording directory:', error); } } function removeFileIfExists(filePath: string | undefined): void { if (!filePath) { return; } try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (error) { log(LogLevel.warn, 'Failed to remove temporary file:', error); } } function getMergedRecordingExtension(videoExt: string): string { return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4'; } function runFfmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg'; const child = spawn(ffmpegPath, args, { windowsHide: true }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0) { resolve(); return; } reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-2000)}`)); }); }); } function mergeVideoWithServerAudio(videoPath: string, audioPath: string, outputPath: string, outputExt: string): Promise { const isWebmOutput = outputExt.toLowerCase() === '.webm'; const args = isWebmOutput ? [ '-y', '-i', videoPath, '-i', audioPath, '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'libopus', '-shortest', outputPath ] : [ '-y', '-i', videoPath, '-i', audioPath, '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', outputPath ]; return runFfmpeg(args); } export const createServer = (config: Options): express.Express => { const app: express.Express = express(); resetHandler(config.mode); if (config.logging !== 'none') { app.use(morgan(config.logging)); } app.use(cors({ origin: '*' })); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.get('/config', (_req, res) => res.json({ useWebSocket: config.type === 'websocket', startupMode: config.mode, logging: config.logging, protocol: config.secure ? 'https' : 'http', port: config.port })); app.use('/signaling', signaling); app.get(['/recordings', '/recordings/'], (_req, res) => { const recordingsPagePath = path.join(__dirname, '../client/public/recordings/index.html'); fs.access(recordingsPagePath, (err) => { if (err) { log(LogLevel.warn, `Can't find file '${recordingsPagePath}'`); res.status(404).send(`Can't find file ${recordingsPagePath}`); return; } res.sendFile(recordingsPagePath); }); }); app.use('/css', express.static(path.join(__dirname, '../client/public/styles'))); app.use('/images', express.static(path.join(__dirname, '../client/public/assets/images'))); app.use(express.static(path.join(__dirname, '../client/public'))); app.use('/module', express.static(path.join(__dirname, '../client/src'))); app.get(['/', '/index.html'], (_req, res) => { const indexPagePath = path.join(__dirname, '../client/public/call/index.html'); fs.access(indexPagePath, (err) => { if (err) { log(LogLevel.warn, `Can't find file '${indexPagePath}'`); res.status(404).send(`Can't find file ${indexPagePath}`); return; } res.sendFile(indexPagePath); }); }); app.get('/connect/connect.html', (_req, res) => { res.redirect('/connect/'); }); app.get('/endcall/endcall.html', (_req, res) => { res.redirect('/endcall/'); }); initSwagger(app, config); const storage = multer.diskStorage({ destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => { const uploadDir = path.join(__dirname, '../client/public/assets/uploads/avatars'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: (_req: any, file: any, cb: (error: Error | null, filename: string) => void) => { cb(null, file.originalname); } }); const upload = multer({ storage, limits: { fileSize: AVATAR_UPLOAD_LIMIT_BYTES }, fileFilter: (_req: express.Request, file: any, cb: (error: Error | null, acceptFile?: boolean) => void) => { if (!isAllowedAvatar(file)) { cb(new Error('Only jpg, png, webp, or gif avatars are allowed')); return; } cb(null, true); } }); app.post('/api/upload/avatar', (req: express.Request, res: express.Response) => { upload.single('avatar')(req, res, (error: Error) => { if (error) { log(LogLevel.warn, 'Avatar upload rejected:', error.message); const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE'; res.status(400).json({ success: false, message: isSizeLimit ? 'Avatar file is too large' : error.message }); return; } const request = req as any; if (!request.file) { res.status(400).json({ success: false, message: 'No file uploaded' }); return; } const ext = safeAvatarExtension(request.file); if (!ext) { fs.unlink(request.file.path, () => undefined); res.status(400).json({ success: false, message: 'Unsupported avatar file type' }); return; } const oldPath = request.file.path; const newFilename = `avatar_${uuid()}${ext}`; const newPath = path.join(path.dirname(oldPath), newFilename); fs.rename(oldPath, newPath, (err) => { if (err) { log(LogLevel.error, 'Error renaming file:', err); res.status(500).json({ success: false, message: 'Avatar rename failed' }); return; } res.json({ success: true, avatarUrl: `/uploads/avatars/${newFilename}` }); }); }); }); const recordingRoot = getRecordingRoot(); const recordingTempDir = path.join(recordingRoot, '.tmp'); const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir); 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/server-audio-recordings/start', async (req: express.Request, res: express.Response) => { try { const offerSdp = req.body.offerSdp || req.body.sdp; const meetingId = sanitizePathSegment(req.body.meetingId, 'unknown'); const started = await serverAudioRecordings.start({ meetingId, offerSdp, iceServers: Array.isArray(req.body.iceServers) ? req.body.iceServers : undefined }); res.json({ success: true, recordingId: started.recordingId, meetingId: started.meetingId, answerSdp: started.answerSdp, candidates: started.candidates }); } catch (error) { log(LogLevel.error, 'Failed to start server audio recording:', error); res.status(400).json({ success: false, message: error instanceof Error ? error.message : 'Failed to start server audio recording' }); } }); app.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => { try { const recordingId = sanitizePathSegment(req.params.recordingId, ''); const candidate = req.body.candidate && typeof req.body.candidate === 'object' ? req.body.candidate : { candidate: req.body.candidate, sdpMid: req.body.sdpMid, sdpMLineIndex: req.body.sdpMLineIndex }; const added = await serverAudioRecordings.addCandidate(recordingId, candidate); if (!added) { res.status(404).json({ success: false, message: 'Server audio recording not found' }); return; } res.json({ success: true }); } catch (error) { log(LogLevel.error, 'Failed to add server audio ICE candidate:', error); res.status(400).json({ success: false, message: error instanceof Error ? error.message : 'Failed to add server audio ICE candidate' }); } }); app.delete('/api/server-audio-recordings/:recordingId', async (req: express.Request, res: express.Response) => { const recordingId = sanitizePathSegment(req.params.recordingId, ''); const cancelled = await serverAudioRecordings.cancel(recordingId); if (!cancelled) { res.status(404).json({ success: false, message: 'Server audio recording not found' }); return; } res.json({ success: true }); }); app.post('/api/server-audio-recordings/:recordingId/stop', (req: express.Request, res: express.Response) => { const recordingId = sanitizePathSegment(req.params.recordingId, ''); const stopPromise = serverAudioRecordings.stop(recordingId); stopPromise.catch(() => undefined); recordingUpload.single('video')(req, res, async (error: Error) => { const request = req as any; let stopped = null; try { stopped = await stopPromise; if (!stopped) { removeFileIfExists(request.file && request.file.path); res.status(404).json({ success: false, message: 'Server audio recording not found' }); return; } if (error) { removeFileIfExists(stopped.audioPath); log(LogLevel.warn, 'Server audio merge 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; } if (!stopped.hasAudio) { removeFileIfExists(request.file && request.file.path); removeFileIfExists(stopped.audioPath); res.status(400).json({ success: false, message: 'No server audio was captured' }); return; } if (!request.file) { res.json({ success: true, recordingId: stopped.recordingId, meetingId: stopped.meetingId, audioOnly: true, audioTrackCount: stopped.audioTrackCount }); return; } const videoExt = safeRecordingExtension(request.file); if (!videoExt) { removeFileIfExists(request.file.path); removeFileIfExists(stopped.audioPath); res.status(400).json({ success: false, message: 'Unsupported recording file type' }); return; } const finalExt = getMergedRecordingExtension(videoExt); const meetingId = sanitizePathSegment(request.body.meetingId || stopped.meetingId, 'unknown'); const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${finalExt}`); const userId = sanitizeMetadataString(request.body.userId, 120); const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId); const participants = sanitizeRecordingParticipants(request.body.participants); const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${stopped.recordingId}${finalExt}`; const meetingDir = path.join(recordingRoot, meetingId); const finalPath = path.join(meetingDir, finalFilename); if (!isPathInside(recordingRoot, finalPath)) { removeFileIfExists(request.file.path); removeFileIfExists(stopped.audioPath); res.status(400).json({ success: false, message: 'Invalid recording path' }); return; } if (!fs.existsSync(meetingDir)) { fs.mkdirSync(meetingDir, { recursive: true }); } await mergeVideoWithServerAudio(request.file.path, stopped.audioPath, finalPath, finalExt); const stat = fs.statSync(finalPath); const metadata = { id: stopped.recordingId, meetingId, filename: finalFilename, originalFilename, mimetype: getRecordingMimeTypeFromExtension(finalExt), size: stat.size, userId, host, participants, serverAudio: { audioTrackCount: stopped.audioTrackCount, startedAt: new Date(stopped.createdAt).toISOString(), stoppedAt: new Date(stopped.stoppedAt).toISOString() }, uploadedAt: new Date().toISOString() }; fs.writeFileSync(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2)); removeFileIfExists(request.file.path); removeFileIfExists(stopped.audioPath); res.json({ success: true, recordingId: stopped.recordingId, meetingId, filename: finalFilename, originalFilename, size: stat.size, merged: true, url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download` }); } catch (mergeError) { removeFileIfExists(request.file && request.file.path); if (stopped) { removeFileIfExists(stopped.audioPath); } log(LogLevel.error, 'Failed to stop server audio recording:', mergeError); res.status(500).json({ success: false, message: mergeError instanceof Error ? mergeError.message : 'Failed to stop server audio recording' }); } }); }); app.get('/api/recordings', (_req: express.Request, res: express.Response) => { try { const recordings = listRecordings(recordingRoot); res.json({ success: true, recordings, totalCount: recordings.length, root: recordingRoot }); } catch (error) { log(LogLevel.error, 'Error listing recordings:', error); res.status(500).json({ success: false, message: 'Failed to list recordings' }); } }); 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 userId = sanitizeMetadataString(request.body.userId, 120); const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId); const participants = sanitizeRecordingParticipants(request.body.participants); 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: getRecordingMimeTypeFromExtension(ext), size: request.file.size, userId, host, participants, 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', (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) || !fs.existsSync(filePath)) { res.status(404).json({ success: false, message: 'Recording not found' }); return; } try { res.json({ success: true, recording: buildRecordingInfo(recordingRoot, meetingId, filename) }); } catch (error) { log(LogLevel.error, 'Error reading recording:', error); res.status(500).json({ success: false, message: 'Failed to read recording' }); } }); app.patch('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); const filename = sanitizePathSegment(req.params.filename, ''); const nextMeetingId = sanitizePathSegment(req.body.meetingId, meetingId); 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 sourceDir = path.join(recordingRoot, meetingId); const sourcePath = path.join(sourceDir, filename); const sourceMetadataPath = path.join(sourceDir, `${filename}.json`); const targetDir = path.join(recordingRoot, nextMeetingId); const targetPath = path.join(targetDir, filename); const targetMetadataPath = path.join(targetDir, `${filename}.json`); if (!isPathInside(recordingRoot, sourcePath) || !isPathInside(recordingRoot, targetPath)) { res.status(400).json({ success: false, message: 'Invalid recording path' }); return; } if (!fs.existsSync(sourcePath)) { res.status(404).json({ success: false, message: 'Recording not found' }); return; } if (sourcePath !== targetPath && fs.existsSync(targetPath)) { res.status(409).json({ success: false, message: 'Recording already exists in target meeting' }); return; } try { if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } if (sourcePath !== targetPath) { fs.renameSync(sourcePath, targetPath); if (fs.existsSync(sourceMetadataPath)) { fs.renameSync(sourceMetadataPath, targetMetadataPath); } } const metadata = readRecordingMetadata(targetMetadataPath); const nextUserId = typeof req.body.userId === 'string' ? sanitizeMetadataString(req.body.userId, 120) : metadata.userId || ''; const shouldSyncHostFromUserId = !metadata.host || metadata.host.userId === metadata.userId || metadata.host.id === metadata.userId; const nextHost = req.body.host !== undefined ? sanitizeRecordingPerson(req.body.host, 'host') || buildFallbackRecordingHost(nextUserId) : shouldSyncHostFromUserId ? buildFallbackRecordingHost(nextUserId) : metadata.host; const nextParticipants = req.body.participants !== undefined ? sanitizeRecordingParticipants(req.body.participants) : Array.isArray(metadata.participants) ? metadata.participants : []; const nextMetadata = { ...metadata, meetingId: nextMeetingId, filename, originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim() ? path.basename(req.body.originalFilename.trim()) : metadata.originalFilename || filename, userId: nextUserId, host: nextHost, participants: nextParticipants, mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext), size: fs.statSync(targetPath).size, uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(), updatedAt: new Date().toISOString() }; fs.writeFileSync(targetMetadataPath, JSON.stringify(nextMetadata, null, 2)); if (sourcePath !== targetPath) { removeEmptyDirectory(sourceDir); } res.json({ success: true, recording: buildRecordingInfo(recordingRoot, nextMeetingId, filename) }); } catch (error) { log(LogLevel.error, 'Error updating recording:', error); res.status(500).json({ success: false, message: 'Failed to update recording' }); } }); app.delete('/api/recordings/:meetingId/:filename', (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 meetingDir = path.join(recordingRoot, meetingId); const filePath = path.join(meetingDir, filename); const metadataPath = path.join(meetingDir, `${filename}.json`); if (!isPathInside(recordingRoot, filePath)) { res.status(400).json({ success: false, message: 'Invalid recording path' }); return; } if (!fs.existsSync(filePath)) { res.status(404).json({ success: false, message: 'Recording not found' }); return; } try { fs.unlinkSync(filePath); if (fs.existsSync(metadataPath)) { fs.unlinkSync(metadataPath); } removeEmptyDirectory(meetingDir); res.json({ success: true }); } catch (error) { log(LogLevel.error, 'Error deleting recording:', error); res.status(500).json({ success: false, message: 'Failed to delete recording' }); } }); app.get('/api/recordings/:meetingId/:filename/stream', (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) || !fs.existsSync(filePath)) { res.status(404).json({ success: false, message: 'Recording not found' }); return; } const stat = fs.statSync(filePath); const range = req.headers.range; const contentType = ext === '.mp4' ? 'video/mp4' : 'video/webm'; if (!range) { res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': contentType, 'Accept-Ranges': 'bytes' }); fs.createReadStream(filePath).pipe(res); return; } const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; if (Number.isNaN(start) || Number.isNaN(end) || start >= stat.size || end >= stat.size || start > end) { res.status(416).set('Content-Range', `bytes */${stat.size}`).end(); return; } res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Content-Type': contentType }); fs.createReadStream(filePath, { start, end }).pipe(res); }); 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/assets/uploads'))); return app; };