Compare commits

2 Commits

Author SHA1 Message Date
83cf098c5f 本地音视频合并测试 2026-06-02 21:42:03 +08:00
d74a0c8121 优化 2026-05-25 22:58:11 +08:00
19 changed files with 2228 additions and 209 deletions

View File

@@ -1,8 +1,8 @@
/* eslint-disable no-undef */ /* eslint-disable no-undef */
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { TextEncoder, TextDecoder } from 'util'; import { TextEncoder, TextDecoder } from 'util';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock'; import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
import ResizeObserverMock from './test/resizeobservermock'; import ResizeObserverMock from './test/helpers/resizeobservermock.js';
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined. // note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
export function createMessageElement(message, formatTimestamp) { export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble'; let messageClass = 'chat-bubble';
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
messageDiv.className = messageClass; messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id; messageDiv.dataset.messageId = message.id;
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/') const header = document.createElement('div');
? ` header.className = 'message-header';
<div class="message-image-container">
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
</div>
`
: `
<div class="message-text">
${message.content}
</div>
`;
messageDiv.innerHTML = ` const avatar = document.createElement('img');
<div class="message-header"> avatar.className = 'message-avatar';
<img src="${message.senderAvatar}" class="message-avatar"> avatar.src = textValue(message.senderAvatar);
<div> avatar.alt = textValue(message.senderName, '\u7528\u6237');
<span class="message-sender">${message.senderName}</span> header.appendChild(avatar);
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
</div> const headerText = document.createElement('div');
</div> headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
<div class="message-content"> headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
${contentHTML} header.appendChild(headerText);
</div>
`; const content = document.createElement('div');
content.className = 'message-content';
const rawContent = textValue(message.content);
if (message.type === 'file' && rawContent.startsWith('data:image/')) {
const imageContainer = document.createElement('div');
imageContainer.className = 'message-image-container';
const image = document.createElement('img');
image.src = rawContent;
image.className = 'message-image';
image.alt = textValue(message.fileName, '\u56fe\u7247');
imageContainer.appendChild(image);
if (message.fileName) {
imageContainer.appendChild(createTextElement('div', 'message-image-name', message.fileName));
}
content.appendChild(imageContainer);
} else {
content.appendChild(createTextElement('div', 'message-text', rawContent));
}
messageDiv.appendChild(header);
messageDiv.appendChild(content);
return messageDiv; return messageDiv;
} }

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
function createParticipantPlaceholder() { function createParticipantPlaceholder() {
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden'; placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
export function createParticipantTile(connectionId, displayName) { export function createParticipantTile(connectionId, displayName) {
const tile = document.createElement('div'); const tile = document.createElement('div');
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center'; tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
tile.dataset.participantId = connectionId; tile.dataset.participantId = textValue(connectionId);
const video = document.createElement('video'); const video = document.createElement('video');
video.className = 'w-full h-full object-contain'; video.className = 'w-full h-full object-contain';
video.autoplay = true; video.autoplay = true;
video.playsinline = true; video.playsinline = true;
video.muted = false; video.muted = false;
video.id = `participantVideo_${connectionId}`; video.id = `participantVideo_${textValue(connectionId)}`;
tile.appendChild(video); tile.appendChild(video);
tile.appendChild(createParticipantPlaceholder()); tile.appendChild(createParticipantPlaceholder());
const label = document.createElement('div'); const label = document.createElement('div');
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2'; label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`; label.appendChild(createIconElement('fas fa-user text-purple-400'));
label.appendChild(createTextElement('span', '', displayName, '\u53c2\u4e0e\u8005'));
tile.appendChild(label); tile.appendChild(label);
const liveTag = document.createElement('div'); const liveTag = document.createElement('div');
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1'; liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`; const pulse = document.createElement('span');
pulse.className = 'w-1.5 h-1.5 bg-white rounded-full animate-pulse';
liveTag.appendChild(pulse);
liveTag.appendChild(createTextElement('span', '', '\u5728\u7ebf'));
tile.appendChild(liveTag); tile.appendChild(liveTag);
return tile; return tile;
} }
export function getParticipantTile(grid, participantId) { export function getParticipantTile(grid, participantId) {
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || null; if (!grid) return null;
const expectedId = textValue(participantId);
return Array.from(grid.querySelectorAll('[data-participant-id]'))
.find(tile => tile.dataset.participantId === expectedId) || null;
} }
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) { export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
const DEFAULT_NETWORK_QUALITY = { const DEFAULT_NETWORK_QUALITY = {
label: '\u672a\u77e5', label: '\u672a\u77e5',
statusIconClass: 'fas fa-question-circle text-gray-400', statusIconClass: 'fas fa-question-circle text-gray-400',
@@ -50,18 +52,18 @@ const NETWORK_QUALITY_DISPLAY = {
} }
}; };
function getRoleTagMarkup(user, role) { function getRoleTagMeta(user, role) {
if (role === 'local') { if (role === 'local') {
return user.isHost return user.isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>' ? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>'; : { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
} }
if (role === 'participant') { if (role === 'participant') {
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>'; return { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
} }
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'; return { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' };
} }
function getDatasetUserId(role, id) { function getDatasetUserId(role, id) {
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
} }
} }
function getAvatarMarkup(user, role) { function createAvatarImage(user) {
const image = document.createElement('img');
image.src = textValue(user.avatar);
image.alt = textValue(user.name, '\u7528\u6237');
image.className = 'w-10 h-10 rounded-full object-cover';
return image;
}
function createAvatarElement(user, role) {
if (role === 'local') { if (role === 'local') {
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`; return createAvatarImage(user);
} }
return ` const wrapper = document.createElement('div');
<div class="relative"> wrapper.className = 'relative';
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover"> wrapper.appendChild(createAvatarImage(user));
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div> const statusDot = document.createElement('div');
`; statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
wrapper.appendChild(statusDot);
return wrapper;
} }
function getRightMarkup(mediaState, role, muteIconMarkup) { function createAudioWaveElement() {
const wave = document.createElement('div');
wave.className = 'audio-wave w-6';
for (let i = 0; i < 5; i += 1) {
wave.appendChild(document.createElement('span'));
}
return wave;
}
function createRightElement(mediaState, role, muteIcon) {
if (role !== 'participant') { if (role !== 'participant') {
return muteIconMarkup; return muteIcon;
} }
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio) const right = document.createElement('div');
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>' right.className = 'flex items-center gap-2';
: '';
return ` if (muteIcon) {
<div class="flex items-center gap-2"> right.appendChild(muteIcon);
${muteIconMarkup} }
${speakingMarkup} if (mediaState.isSpeaking && mediaState.audio) {
</div> right.appendChild(createAudioWaveElement());
`; }
return right.childNodes.length > 0 ? right : null;
} }
export function getCallTitle(connectionId) { export function getCallTitle(connectionId) {
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
export function createUserEntryElement({ user, role, id }) { export function createUserEntryElement({ user, role, id }) {
const entry = document.createElement('div'); const entry = document.createElement('div');
const mediaMeta = getMediaStatusMeta(user.mediaState); const mediaMeta = getMediaStatusMeta(user.mediaState);
const muteIconMarkup = mediaMeta.showMuteIcon const muteIcon = mediaMeta.showMuteIcon
? `<i class="${mediaMeta.muteIconClass}"></i>` ? createIconElement(mediaMeta.muteIconClass)
: ''; : '';
const baseClass = 'flex items-center gap-3 p-2 rounded-lg'; const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
entry.className = role === 'local' entry.className = role === 'local'
? `${baseClass} hover:bg-white/5` ? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`; : `${baseClass} bg-white/5`;
entry.dataset.userId = getDatasetUserId(role, id); entry.dataset.userId = getDatasetUserId(role, id);
entry.innerHTML = `
${getAvatarMarkup(user, role)} entry.appendChild(createAvatarElement(user, role));
<div class="flex-1">
<div class="text-sm font-medium"> const details = document.createElement('div');
${user.name} details.className = 'flex-1';
${getRoleTagMarkup(user, role)}
</div> const nameRow = document.createElement('div');
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div> nameRow.className = 'text-sm font-medium';
</div> nameRow.appendChild(document.createTextNode(textValue(user.name)));
${getRightMarkup(user.mediaState, role, muteIconMarkup)} const roleTag = getRoleTagMeta(user, role);
`; nameRow.appendChild(createTextElement('span', roleTag.className, roleTag.label));
details.appendChild(nameRow);
const mediaStatus = createTextElement('div', mediaMeta.className, mediaMeta.text);
if (role === 'local') {
mediaStatus.dataset.field = 'localUser.mediaStatus';
}
details.appendChild(mediaStatus);
entry.appendChild(details);
const right = createRightElement(user.mediaState, role, muteIcon || null);
if (right) {
entry.appendChild(right);
}
return entry; return entry;
} }

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>'; const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>'; const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09'; const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
const USER_COUNT_SUFFIX = '\u4eba'; const USER_COUNT_SUFFIX = '\u4eba';
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf'; const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
function escapeHtml(value) { function getRoleTagClass(role) {
return String(value || '') if (role === 'host') {
.replace(/&/g, '&amp;') return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
.replace(/</g, '&lt;') }
.replace(/>/g, '&gt;') if (role === 'participant') {
.replace(/"/g, '&quot;') return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
.replace(/'/g, '&#39;'); }
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
} }
export async function fetchOnlineUsers() { export async function fetchOnlineUsers() {
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const roomTitle = document.createElement('div'); const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2'; roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.innerHTML = ` roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span> roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
`;
section.appendChild(roomTitle); section.appendChild(roomTitle);
const roomList = document.createElement('div'); const roomList = document.createElement('div');
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const userItem = document.createElement('div'); const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2'; userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
userItem.innerHTML = `
<div class="flex items-center gap-3 min-w-0"> const profile = document.createElement('div');
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover"> profile.className = 'flex items-center gap-3 min-w-0';
<div class="min-w-0">
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div> const avatarImage = document.createElement('img');
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div> avatarImage.src = textValue(avatar);
</div> avatarImage.alt = textValue(userName);
</div> avatarImage.className = 'w-8 h-8 rounded-full object-cover';
<div class="flex items-center gap-2"> profile.appendChild(avatarImage);
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
${isSelf ? `<span class="text-xs text-gray-500">${SELF_LABEL}</span>` : ''} const info = document.createElement('div');
</div> info.className = 'min-w-0';
`; info.appendChild(createTextElement('div', 'text-sm text-white truncate', userName));
info.appendChild(createTextElement('div', 'text-xs text-gray-400 truncate', identity));
profile.appendChild(info);
const status = document.createElement('div');
status.className = 'flex items-center gap-2';
status.appendChild(createTextElement('span', getRoleTagClass(user.role), roleLabel));
if (isSelf) {
status.appendChild(createTextElement('span', 'text-xs text-gray-500', SELF_LABEL));
}
userItem.appendChild(profile);
userItem.appendChild(status);
roomList.appendChild(userItem); roomList.appendChild(userItem);
}); });

View File

@@ -0,0 +1,18 @@
export function textValue(value, fallback = '') {
return value == null || value === '' ? fallback : String(value);
}
export function createTextElement(tagName, className, value, fallback = '') {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
element.textContent = textValue(value, fallback);
return element;
}
export function createIconElement(className) {
const icon = document.createElement('i');
icon.className = className;
return icon;
}

View File

@@ -2,10 +2,11 @@ import * as Logger from "../utils/logger.js";
export class Signaling extends EventTarget { export class Signaling extends EventTarget {
constructor(interval = 1000) { constructor(interval = 1000, baseUrl = null) {
super(); super();
this.running = false; this.running = false;
this.interval = interval; this.interval = interval;
this.baseUrl = baseUrl;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec)); this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
} }
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
} }
url(method, parameter = '') { url(method, parameter = '') {
let ret = location.origin + '/signaling'; let ret = (this.baseUrl || location.origin) + '/signaling';
if (method) if (method)
ret += '/' + method; ret += '/' + method;
if (parameter) if (parameter)
@@ -151,17 +152,18 @@ export class Signaling extends EventTarget {
export class WebSocketSignaling extends EventTarget { export class WebSocketSignaling extends EventTarget {
constructor(interval = 1000) { constructor(interval = 1000, websocketUrl = null) {
super(); super();
this.interval = interval; this.interval = interval;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec)); this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
let websocketUrl; if (!websocketUrl) {
if (location.protocol === "https:") { if (location.protocol === "https:") {
websocketUrl = "wss://" + location.host; websocketUrl = "wss://" + location.host;
} else { } else {
websocketUrl = "ws://" + location.host; websocketUrl = "ws://" + location.host;
} }
}
this.websocket = new WebSocket(websocketUrl); this.websocket = new WebSocket(websocketUrl);
this.connectionId = null; this.connectionId = null;

View File

@@ -1,3 +1,4 @@
import { jest } from '@jest/globals';
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js'; import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
class MediaStreamMock { class MediaStreamMock {
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
const result = await recorder.stop(); const result = await recorder.stop();
expect(result.filename).toContain('meeting-recording-123-456-789'); expect(result.filename).toContain('meeting-recording-123-456-789');
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2'); expect(result.mimeType.toLowerCase()).toBe('video/mp4;codecs=avc1.42e01e,mp4a.40.2');
expect(result.filename).toMatch(/\.mp4$/); expect(result.filename).toMatch(/\.mp4$/);
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled(); expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
expect(recorder.isRecording()).toBe(false); expect(recorder.isRecording()).toBe(false);

View File

@@ -0,0 +1,88 @@
import { createMessageElement } from '../../public/call/chat/renderer-chat.js';
import { createParticipantTile, getParticipantTile } from '../../public/call/participants/renderer-participant-grid.js';
import { createUserEntryElement } from '../../public/call/renderers/renderer-ui.js';
import { renderOnlineUsers } from '../../public/call/signaling/connect-directory.js';
const formatTimestamp = value => value;
const unsafeText = '<img src=x onerror=alert(1)>Alice';
function mediaState(overrides = {}) {
return {
audio: true,
video: true,
isSpeaking: false,
...overrides
};
}
describe('safe dynamic rendering', () => {
afterEach(() => {
document.body.innerHTML = '';
});
test('renders chat text as text, not markup', () => {
const element = createMessageElement({
id: 'msg-1',
type: 'text',
isSelf: false,
senderName: unsafeText,
senderAvatar: '/images/p1.png',
content: unsafeText,
timestamp: 'now'
}, formatTimestamp);
expect(element.querySelector('.message-text').textContent).toBe(unsafeText);
expect(element.querySelector('.message-content img')).toBeNull();
expect(element.querySelector('.message-sender').textContent).toBe(unsafeText);
});
test('renders participant names safely and finds ids without selector injection', () => {
const participantId = 'room"] [data-bad="1';
const tile = createParticipantTile(participantId, unsafeText);
const grid = document.createElement('div');
grid.appendChild(tile);
expect(tile.querySelector('.absolute.bottom-3 span').textContent).toBe(unsafeText);
expect(tile.querySelector('.absolute.bottom-3 img')).toBeNull();
expect(getParticipantTile(grid, participantId)).toBe(tile);
});
test('renders user list entries without interpreting user profile fields as HTML', () => {
const entry = createUserEntryElement({
role: 'participant',
id: 'participant-1',
user: {
name: unsafeText,
avatar: '/images/p2.png',
mediaState: mediaState({ audio: false })
}
});
expect(entry.textContent).toContain(unsafeText);
expect(entry.querySelectorAll('img')).toHaveLength(1);
});
test('renders online users without injecting markup from directory data', () => {
const onlineUsersList = document.createElement('div');
const usersContainer = document.createElement('div');
const onlineUsersSummary = document.createElement('div');
renderOnlineUsers({
users: [{
name: unsafeText,
userId: unsafeText,
avatar: '/images/p1.png',
role: 'participant',
connectionId: 'room-1'
}],
currentUserId: 'other-user',
onlineUsersList,
usersContainer,
onlineUsersSummary
});
expect(usersContainer.textContent).toContain(unsafeText);
expect(usersContainer.querySelector('button')).toBeNull();
expect(usersContainer.querySelectorAll('img')).toHaveLength(1);
});
});

View File

@@ -1,18 +1,49 @@
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import fs from 'fs';
import * as Path from 'path'; import * as Path from 'path';
import process from 'process';
import { setup, teardown } from 'jest-dev-server'; import { setup, teardown } from 'jest-dev-server';
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js"; import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
import { MockSignaling, reset } from "../mocks/mocksignaling.js"; import { MockSignaling, reset } from "../mocks/mocksignaling.js";
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js"; import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
const portNumber = 8081; const portNumber = 8081;
const runSignalingIntegration = process.env.RUN_SIGNALING_INTEGRATION === '1';
const signalingModes = runSignalingIntegration
? [{ mode: "mock" }, { mode: "http" }, { mode: "websocket" }]
: [{ mode: "mock" }];
jest.setTimeout(10000); jest.setTimeout(10000);
describe.each([ function buildServerCommand(args = '') {
{ mode: "mock" }, const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
{ mode: "http" }, const buildEntryPath = Path.resolve('../build/index.js');
{ mode: "websocket" }, const serverCommand = fs.existsSync(binaryPath)
])('signaling test in public mode', ({ mode }) => { ? `"${binaryPath}"`
: `"${process.execPath}" "${buildEntryPath}"`;
return `${serverCommand} ${args}`.trim();
}
function getPortForMode(mode, isPrivate) {
if (mode === 'mock') {
return portNumber;
}
const publicPorts = { http: portNumber + 1, websocket: portNumber + 2 };
const privatePorts = { http: portNumber + 3, websocket: portNumber + 4 };
return (isPrivate ? privatePorts : publicPorts)[mode];
}
function createHttpSignaling(port) {
return new Signaling(1, `http://localhost:${port}`);
}
function createWebSocketSignaling(port) {
return new WebSocketSignaling(1, `ws://localhost:${port}`);
}
describe.each(signalingModes)('signaling test in public mode', ({ mode }) => {
let signaling1; let signaling1;
let signaling2; let signaling2;
const connectionId1 = "12345"; const connectionId1 = "12345";
@@ -26,22 +57,22 @@ describe.each([
signaling1 = new MockSignaling(1); signaling1 = new MockSignaling(1);
signaling2 = new MockSignaling(1); signaling2 = new MockSignaling(1);
} else { } else {
const path = Path.resolve(`../bin~/${serverExeName()}`); const serverPort = getPortForMode(mode, false);
let cmd = `${path} -p ${portNumber}`; let cmd = buildServerCommand(`-p ${serverPort}`);
if (mode == "http") { if (mode == "http") {
cmd += " -t http"; cmd += " -t http";
} }
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' }); await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") { if (mode == "http") {
signaling1 = new Signaling(1); signaling1 = createHttpSignaling(serverPort);
signaling2 = new Signaling(1); signaling2 = createHttpSignaling(serverPort);
} }
if (mode == "websocket") { if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1); signaling1 = createWebSocketSignaling(serverPort);
signaling2 = new WebSocketSignaling(1); signaling2 = createWebSocketSignaling(serverPort);
} }
} }
@@ -50,8 +81,8 @@ describe.each([
}); });
afterAll(async () => { afterAll(async () => {
await signaling1.stop(); await signaling1?.stop();
await signaling2.stop(); await signaling2?.stop();
signaling1 = null; signaling1 = null;
signaling2 = null; signaling2 = null;
@@ -207,11 +238,7 @@ describe.each([
}); });
}); });
describe.each([ describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
{ mode: "mock" },
{ mode: "http" },
{ mode: "websocket" },
])('signaling test in private mode', ({ mode }) => {
let signaling1; let signaling1;
let signaling2; let signaling2;
const connectionId = "12345"; const connectionId = "12345";
@@ -226,22 +253,22 @@ describe.each([
return; return;
} }
const path = Path.resolve(`../bin~/${serverExeName()}`); const serverPort = getPortForMode(mode, true);
let cmd = `${path} -p ${portNumber} -m private`; let cmd = buildServerCommand(`-p ${serverPort} -m private`);
if (mode == "http") { if (mode == "http") {
cmd += " -t http"; cmd += " -t http";
} }
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' }); await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") { if (mode == "http") {
signaling1 = new Signaling(1); signaling1 = createHttpSignaling(serverPort);
signaling2 = new Signaling(1); signaling2 = createHttpSignaling(serverPort);
} }
if (mode == "websocket") { if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1); signaling1 = createWebSocketSignaling(serverPort);
signaling2 = new WebSocketSignaling(1); signaling2 = createWebSocketSignaling(serverPort);
} }
await signaling1.start(); await signaling1.start();
@@ -249,8 +276,8 @@ describe.each([
}); });
afterAll(async () => { afterAll(async () => {
await signaling1.stop(); await signaling1?.stop();
await signaling2.stop(); await signaling2?.stop();
signaling1 = null; signaling1 = null;
signaling2 = null; signaling2 = null;

1335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
"swagger-jsdoc": "^6.2.1", "swagger-jsdoc": "^6.2.1",
"swagger-ui-express": "^4.5.0", "swagger-ui-express": "^4.5.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"werift": "^0.23.0",
"ws": "^8.8.1" "ws": "^8.8.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -7,7 +7,7 @@ import Offer from './offer';
import Answer from './answer'; import Answer from './answer';
import Candidate from './candidate'; import Candidate from './candidate';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, onGetRooms as onGetWsRooms } from './websockethandler'; import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, } from './websockethandler';
import { log, LogLevel } from '../log'; import { log, LogLevel } from '../log';
/** /**
* 断开连接记录类 * 断开连接记录类
@@ -996,57 +996,6 @@ function postCandidate(req: Request, res: Response): void {
arr.push(candidate); arr.push(candidate);
res.sendStatus(200); res.sendStatus(200);
} }
/**
* @swagger
* /signaling/rooms:
* get:
* summary: 获取房间和用户信息
* description: 获取所有房间的信息包括房间ID和链接的用户
* security:
* - sessionAuth: []
* responses:
* 200:
* description: 成功获取房间和用户信息
* content:
* application/json:
* schema:
* type: object
* properties:
* rooms:
* type: array
* items:
* type: object
* properties:
* roomId:
* type: string
* description: 房间ID
* users:
* type: array
* items:
* type: object
* properties:
* sessionId:
* type: string
* description: 会话ID
* connected:
* type: boolean
* description: 连接状态
* userCount:
* type: number
* description: 用户数量
* totalRooms:
* type: number
* description: 总房间数
*/
function onGetConnections(req: Request, res: Response): void {
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : undefined;
const wsRooms = onGetWsRooms(connectionId).map((room) => ({
...room,
users: room.members
}));
res.json({ rooms: wsRooms, totalRooms: wsRooms.length });
}
/** /**
* @swagger * @swagger
@@ -1160,7 +1109,6 @@ export {
postOffer, // 处理offer信令消息 postOffer, // 处理offer信令消息
postAnswer, // 处理answer信令消息 postAnswer, // 处理answer信令消息
postCandidate, // 处理candidate信令消息 postCandidate, // 处理candidate信令消息
onGetConnections, // 获取房间和用户信息
getAllConnectionIds, // 获取所有连接ID getAllConnectionIds, // 获取所有连接ID
getOnlineUsers // 获取在线WebSocket用户列表 getOnlineUsers // 获取在线WebSocket用户列表
}; };

View File

@@ -0,0 +1,197 @@
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { RTCPeerConnection } from 'werift';
import { MediaRecorder } from 'werift/nonstandard';
import { log, LogLevel } from '../log';
type ServerAudioRecordingSession = {
recordingId: string;
meetingId: string;
peerConnection: RTCPeerConnection;
audioPath: string;
createdAt: number;
recorder?: MediaRecorder;
audioTrackCount: number;
localCandidates: any[];
};
type StartServerAudioRecordingOptions = {
meetingId?: string;
offerSdp: string;
iceServers?: any[];
};
type StartServerAudioRecordingResult = {
recordingId: string;
meetingId: string;
answerSdp: string;
candidates: any[];
audioPath: string;
};
type StoppedServerAudioRecording = {
recordingId: string;
meetingId: string;
audioPath: string;
hasAudio: boolean;
audioTrackCount: number;
createdAt: number;
stoppedAt: number;
};
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection, timeoutMs: number): Promise<void> {
if (peerConnection.iceGatheringState === 'complete') {
return Promise.resolve();
}
return new Promise((resolve) => {
let done = false;
const subscription = peerConnection.iceGatheringStateChange.subscribe((state) => {
if (state === 'complete') {
finish();
}
});
const timer = setTimeout(finish, timeoutMs);
function finish(): void {
if (done) {
return;
}
done = true;
clearTimeout(timer);
subscription.unSubscribe();
resolve();
}
});
}
function toJsonCandidate(candidate: any): any {
if (!candidate) {
return candidate;
}
return typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
}
export class ServerAudioRecorderManager {
private sessions: Map<string, ServerAudioRecordingSession> = new Map<string, ServerAudioRecordingSession>();
constructor(private tempDir: string) {}
async start(options: StartServerAudioRecordingOptions): Promise<StartServerAudioRecordingResult> {
if (!options.offerSdp || typeof options.offerSdp !== 'string') {
throw new Error('offerSdp is required');
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
const recordingId = uuid();
const meetingId = options.meetingId || 'unknown';
const audioPath = path.join(this.tempDir, `${recordingId}.server-audio.webm`);
const peerConnection = new RTCPeerConnection({
iceServers: Array.isArray(options.iceServers) ? options.iceServers : []
});
const session: ServerAudioRecordingSession = {
recordingId,
meetingId,
peerConnection,
audioPath,
createdAt: Date.now(),
audioTrackCount: 0,
localCandidates: []
};
peerConnection.onIceCandidate.subscribe((candidate) => {
if (candidate) {
session.localCandidates.push(toJsonCandidate(candidate));
}
});
peerConnection.onTrack.subscribe((track) => {
if (track.kind !== 'audio') {
return;
}
session.audioTrackCount += 1;
if (session.recorder) {
log(LogLevel.warn, `Ignoring extra server audio track for recording ${recordingId}`);
return;
}
session.recorder = new MediaRecorder({
path: audioPath,
tracks: [track]
});
session.recorder.onError.subscribe((error) => {
log(LogLevel.error, `Server audio recorder error for ${recordingId}:`, error);
});
log(LogLevel.log, `Server audio track received for recording ${recordingId}`);
});
await peerConnection.setRemoteDescription({ type: 'offer', sdp: options.offerSdp });
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
await waitForIceGatheringComplete(peerConnection, 3000);
this.sessions.set(recordingId, session);
return {
recordingId,
meetingId,
answerSdp: (peerConnection.localDescription && peerConnection.localDescription.sdp) || answer.sdp,
candidates: session.localCandidates,
audioPath
};
}
async addCandidate(recordingId: string, candidate: any): Promise<boolean> {
const session = this.sessions.get(recordingId);
if (!session) {
return false;
}
await session.peerConnection.addIceCandidate(candidate || {});
return true;
}
async stop(recordingId: string): Promise<StoppedServerAudioRecording | null> {
const session = this.sessions.get(recordingId);
if (!session) {
return null;
}
this.sessions.delete(recordingId);
if (session.recorder) {
await session.recorder.stop();
}
await session.peerConnection.close();
const hasAudio = fs.existsSync(session.audioPath) && fs.statSync(session.audioPath).size > 0;
return {
recordingId: session.recordingId,
meetingId: session.meetingId,
audioPath: session.audioPath,
hasAudio,
audioTrackCount: session.audioTrackCount,
createdAt: session.createdAt,
stoppedAt: Date.now()
};
}
async cancel(recordingId: string): Promise<boolean> {
const stopped = await this.stop(recordingId);
if (!stopped) {
return false;
}
if (fs.existsSync(stopped.audioPath)) {
fs.unlinkSync(stopped.audioPath);
}
return true;
}
}

View File

@@ -0,0 +1,82 @@
# Server Audio Recording API
This API lets Unity keep local video recording while the server records app audio as an extra WebRTC peer. When Unity stops local recording, upload the local video to the stop endpoint and the server merges it with the recorded audio.
## 1. Start
`POST /api/server-audio-recordings/start`
Body:
```json
{
"meetingId": "room-001",
"offerSdp": "v=0...",
"iceServers": []
}
```
Response:
```json
{
"success": true,
"recordingId": "uuid",
"meetingId": "room-001",
"answerSdp": "v=0...",
"candidates": []
}
```
Unity should create a peer connection with an audio track only, send its offer SDP here, then set the returned answer SDP as the remote description.
## 2. Trickle ICE
`POST /api/server-audio-recordings/{recordingId}/candidate`
Body:
```json
{
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
```
## 3. Stop And Merge
`POST /api/server-audio-recordings/{recordingId}/stop`
Content type: `multipart/form-data`
Fields:
- `video`: the local video file recorded by Unity.
- `meetingId`: optional, overrides the start meeting id.
- `filename`: optional display filename.
- `userId`: optional host user id.
- `host`: optional JSON host metadata.
- `participants`: optional JSON participant metadata array.
Response:
```json
{
"success": true,
"recordingId": "uuid",
"meetingId": "room-001",
"filename": "2026-06-02T13-00-00-000Z-uuid.mp4",
"merged": true,
"url": "/api/recordings/room-001/2026-06-02T13-00-00-000Z-uuid.mp4/download"
}
```
The server keeps the local video track, replaces audio with the server-recorded app audio, and stores the merged file in the existing `recordings` directory.
## 4. Cancel
`DELETE /api/server-audio-recordings/{recordingId}`
Use this if local recording is aborted and no merged output should be saved.

View File

@@ -2,12 +2,14 @@ import * as express from 'express';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import { spawn } from 'child_process';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import signaling from './signaling'; import signaling from './signaling';
import { log, LogLevel } from './log'; import { log, LogLevel } from './log';
import Options from './class/options'; import Options from './class/options';
import { reset as resetHandler } from './class/httphandler'; import { reset as resetHandler } from './class/httphandler';
import { initSwagger } from './swagger'; import { initSwagger } from './swagger';
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
const cors = require('cors'); const cors = require('cors');
const multer = require('multer'); const multer = require('multer');
@@ -139,7 +141,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
return ''; return '';
} }
return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength); 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 { function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
@@ -285,6 +295,75 @@ function removeEmptyDirectory(directory: string): void {
} }
} }
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 => { export const createServer = (config: Options): express.Express => {
const app: express.Express = express(); const app: express.Express = express();
resetHandler(config.mode); resetHandler(config.mode);
@@ -419,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
const recordingRoot = getRecordingRoot(); const recordingRoot = getRecordingRoot();
const recordingTempDir = path.join(recordingRoot, '.tmp'); const recordingTempDir = path.join(recordingRoot, '.tmp');
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
const recordingStorage = multer.diskStorage({ const recordingStorage = multer.diskStorage({
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => { destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
if (!fs.existsSync(recordingTempDir)) { if (!fs.existsSync(recordingTempDir)) {
@@ -452,6 +532,194 @@ export const createServer = (config: Options): express.Express => {
} }
}); });
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) => { app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
try { try {
const recordings = listRecordings(recordingRoot); const recordings = listRecordings(recordingRoot);

View File

@@ -6,7 +6,7 @@ const router: express.Router = express.Router();
// 不需要会话ID的路由 // 不需要会话ID的路由
router.get('/connection-ids', handler.getAllConnectionIds); router.get('/connection-ids', handler.getAllConnectionIds);
router.get('/users', handler.getOnlineUsers); router.get('/users', handler.getOnlineUsers);
router.get('/rooms', handler.onGetConnections); // router.get('/rooms', handler.onGetConnections);
// 需要会话ID的路由 // 需要会话ID的路由
router.use(handler.checkSessionId); router.use(handler.checkSessionId);

12
src/types/werift-nonstandard.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare module 'werift/nonstandard' {
export class MediaRecorder {
onError: {
subscribe: (execute: (error: Error) => void) => { unSubscribe: () => void };
};
constructor(props: any);
addTrack(track: any): Promise<void>;
stop(): Promise<void>;
}
}

View File

@@ -3,10 +3,12 @@
"exclude": ["node_modules", "**/*.spec.ts"], "exclude": ["node_modules", "**/*.spec.ts"],
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node",
"target": "es5", "target": "es5",
"lib": ["dom","es5"], "lib": ["dom","es5"],
"sourceMap": true, "sourceMap": true,
"outDir":"build", "outDir":"build",
"rootDir":"src" "rootDir":"src",
"skipLibCheck": true
} }
} }