视频录制开发
This commit is contained in:
190
src/server.ts
190
src/server.ts
@@ -15,6 +15,9 @@ 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();
|
||||
@@ -40,6 +43,52 @@ 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);
|
||||
@@ -148,6 +197,147 @@ export const createServer = (config: Options): express.Express => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user