Files
video_socket-server/src/server.ts

659 lines
23 KiB
TypeScript
Raw Normal View History

2026-04-29 15:18:30 +08:00
import * as express from 'express';
import * as path from 'path';
import * as fs from 'fs';
import * as morgan from 'morgan';
2026-05-23 22:47:34 +08:00
import { v4 as uuid } from 'uuid';
2026-04-29 15:18:30 +08:00
import signaling from './signaling';
import { log, LogLevel } from './log';
import Options from './class/options';
2026-05-23 23:49:47 +08:00
import { reset as resetHandler } from './class/httphandler';
2026-04-29 15:18:30 +08:00
import { initSwagger } from './swagger';
const cors = require('cors');
const multer = require('multer');
2026-05-23 22:47:34 +08:00
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']);
2026-05-25 16:39:13 +08:00
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']);
2026-05-23 22:47:34 +08:00
2026-05-25 17:39:57 +08:00
type RecordingMetadata = {
id?: string;
meetingId?: string;
filename?: string;
originalFilename?: string;
mimetype?: string;
size?: number;
userId?: string;
uploadedAt?: string;
updatedAt?: string;
};
2026-05-23 22:47:34 +08:00
function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
return originalExt;
}
2026-05-23 23:49:47 +08:00
2026-05-23 22:47:34 +08:00
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 {
2026-05-23 23:49:47 +08:00
return ALLOWED_AVATAR_MIME_TYPES.has(file.mimetype) && safeAvatarExtension(file).length > 0;
2026-05-23 22:47:34 +08:00
}
2026-05-25 16:39:13 +08:00
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;
}
2026-05-25 17:39:57 +08:00
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);
}
}
2026-04-29 15:18:30 +08:00
export const createServer = (config: Options): express.Express => {
const app: express.Express = express();
resetHandler(config.mode);
2026-05-23 23:49:47 +08:00
if (config.logging !== 'none') {
2026-04-29 15:18:30 +08:00
app.use(morgan(config.logging));
}
2026-05-23 23:49:47 +08:00
app.use(cors({ origin: '*' }));
2026-04-29 15:18:30 +08:00
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
2026-05-23 23:49:47 +08:00
app.get('/config', (_req, res) => res.json({
useWebSocket: config.type === 'websocket',
2026-05-17 22:07:07 +08:00
startupMode: config.mode,
logging: config.logging,
protocol: config.secure ? 'https' : 'http',
port: config.port
}));
2026-05-23 23:49:47 +08:00
2026-04-29 15:18:30 +08:00
app.use('/signaling', signaling);
2026-05-25 17:39:57 +08:00
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);
});
});
2026-05-25 20:37:36 +08:00
app.use('/css', express.static(path.join(__dirname, '../client/public/styles')));
app.use('/images', express.static(path.join(__dirname, '../client/public/assets/images')));
2026-04-29 15:18:30 +08:00
app.use(express.static(path.join(__dirname, '../client/public')));
app.use('/module', express.static(path.join(__dirname, '../client/src')));
2026-05-23 23:49:47 +08:00
2026-05-25 20:37:36 +08:00
app.get(['/', '/index.html'], (_req, res) => {
const indexPagePath = path.join(__dirname, '../client/public/call/index.html');
2026-04-29 15:18:30 +08:00
fs.access(indexPagePath, (err) => {
if (err) {
2026-05-23 23:49:47 +08:00
log(LogLevel.warn, `Can't find file '${indexPagePath}'`);
2026-04-29 15:18:30 +08:00
res.status(404).send(`Can't find file ${indexPagePath}`);
2026-05-23 23:49:47 +08:00
return;
2026-04-29 15:18:30 +08:00
}
2026-05-23 23:49:47 +08:00
res.sendFile(indexPagePath);
2026-04-29 15:18:30 +08:00
});
});
2026-05-23 23:49:47 +08:00
2026-05-25 20:37:36 +08:00
app.get('/connect/connect.html', (_req, res) => {
res.redirect('/connect/');
});
app.get('/endcall/endcall.html', (_req, res) => {
res.redirect('/endcall/');
});
2026-04-29 15:18:30 +08:00
initSwagger(app, config);
const storage = multer.diskStorage({
2026-05-23 23:49:47 +08:00
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
2026-05-25 20:37:36 +08:00
const uploadDir = path.join(__dirname, '../client/public/assets/uploads/avatars');
2026-04-29 15:18:30 +08:00
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
2026-05-23 23:49:47 +08:00
filename: (_req: any, file: any, cb: (error: Error | null, filename: string) => void) => {
2026-04-29 15:18:30 +08:00
cb(null, file.originalname);
}
});
2026-05-23 22:47:34 +08:00
const upload = multer({
2026-05-23 23:49:47 +08:00
storage,
2026-05-23 22:47:34 +08:00
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;
}
2026-05-23 23:49:47 +08:00
2026-05-23 22:47:34 +08:00
cb(null, true);
}
});
2026-04-29 15:18:30 +08:00
2026-05-23 22:47:34 +08:00
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';
2026-05-23 23:49:47 +08:00
res.status(400).json({
2026-05-23 22:47:34 +08:00
success: false,
message: isSizeLimit ? 'Avatar file is too large' : error.message
});
2026-05-23 23:49:47 +08:00
return;
2026-05-23 22:47:34 +08:00
}
const request = req as any;
if (!request.file) {
2026-05-23 23:49:47 +08:00
res.status(400).json({ success: false, message: 'No file uploaded' });
return;
2026-05-23 22:47:34 +08:00
}
2026-04-29 15:18:30 +08:00
2026-05-23 22:47:34 +08:00
const ext = safeAvatarExtension(request.file);
if (!ext) {
fs.unlink(request.file.path, () => undefined);
2026-05-23 23:49:47 +08:00
res.status(400).json({ success: false, message: 'Unsupported avatar file type' });
return;
2026-05-23 22:47:34 +08:00
}
const oldPath = request.file.path;
const newFilename = `avatar_${uuid()}${ext}`;
const newPath = path.join(path.dirname(oldPath), newFilename);
2026-04-29 15:18:30 +08:00
2026-05-23 22:47:34 +08:00
fs.rename(oldPath, newPath, (err) => {
if (err) {
log(LogLevel.error, 'Error renaming file:', err);
2026-05-23 23:49:47 +08:00
res.status(500).json({ success: false, message: 'Avatar rename failed' });
return;
}
2026-04-29 15:18:30 +08:00
2026-05-23 23:49:47 +08:00
res.json({ success: true, avatarUrl: `/uploads/avatars/${newFilename}` });
});
2026-05-23 22:47:34 +08:00
});
2026-04-29 15:18:30 +08:00
});
2026-05-25 16:39:13 +08:00
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);
}
});
2026-05-25 17:39:57 +08:00
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' });
}
});
2026-05-25 16:39:13 +08:00
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`
});
});
});
});
});
2026-05-25 17:39:57 +08:00
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);
});
2026-05-25 16:39:13 +08:00
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);
});
});
2026-05-25 20:37:36 +08:00
app.use('/uploads', express.static(path.join(__dirname, '../client/public/assets/uploads')));
2026-04-29 15:18:30 +08:00
return app;
};