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']); 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; } 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.use(express.static(path.join(__dirname, '../client/public'))); app.use('/module', express.static(path.join(__dirname, '../client/src'))); app.get('/', (_req, res) => { const indexPagePath = path.join(__dirname, '../client/public/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); }); }); 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/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.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; };