1045 lines
36 KiB
TypeScript
1045 lines
36 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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;
|
|
};
|