import * as express from 'express'; import * as path from 'path'; import * as fs from 'fs'; import * as morgan from 'morgan'; 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'; 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; uploadedAt?: string; updatedAt?: string; }; 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 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 { if (metadata.mimetype) { return metadata.mimetype; } return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; } 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; 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 || '', 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); } } 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 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.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 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', (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 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: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '', mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'), 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; };