Compare commits

4 Commits

Author SHA1 Message Date
83cf098c5f 本地音视频合并测试 2026-06-02 21:42:03 +08:00
d74a0c8121 优化 2026-05-25 22:58:11 +08:00
e6dfb28ef2 房间成员信息刷新 2026-05-25 22:21:50 +08:00
ad93ef342b 完成新页面开发 2026-05-25 21:57:58 +08:00
24 changed files with 2742 additions and 239 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) {
if (role === 'local') { const image = document.createElement('img');
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`; image.src = textValue(user.avatar);
} image.alt = textValue(user.name, '\u7528\u6237');
image.className = 'w-10 h-10 rounded-full object-cover';
return ` return image;
<div class="relative">
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
`;
} }
function getRightMarkup(mediaState, role, muteIconMarkup) { function createAvatarElement(user, role) {
if (role !== 'participant') { if (role === 'local') {
return muteIconMarkup; return createAvatarImage(user);
} }
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio) const wrapper = document.createElement('div');
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>' wrapper.className = 'relative';
: ''; wrapper.appendChild(createAvatarImage(user));
return ` const statusDot = document.createElement('div');
<div class="flex items-center gap-2"> statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
${muteIconMarkup} wrapper.appendChild(statusDot);
${speakingMarkup}
</div> return wrapper;
`; }
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') {
return muteIcon;
}
const right = document.createElement('div');
right.className = 'flex items-center gap-2';
if (muteIcon) {
right.appendChild(muteIcon);
}
if (mediaState.isSpeaking && mediaState.audio) {
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

@@ -91,7 +91,16 @@ export function buildSocketUserInfoPayload(userInfo, localUser) {
} }
export function sendSocketUserInfo(signaling, payload) { export function sendSocketUserInfo(signaling, payload) {
if (!signaling || typeof signaling.sendMessage !== 'function') { if (!signaling) {
return;
}
if (typeof signaling.sendUserInfo === 'function') {
signaling.sendUserInfo(payload);
return;
}
if (typeof signaling.sendMessage !== 'function') {
return; return;
} }

View File

@@ -164,9 +164,14 @@ class CallStateManager {
} }
async uploadRecording({ blob, filename }) { async uploadRecording({ blob, filename }) {
const formData = new FormData(); const formData = new FormData();
const people = this.buildRecordingPeopleMetadata();
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown'); formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
formData.append('userId', this.state.session.localUser.id || ''); formData.append('userId', this.state.session.localUser.id || '');
formData.append('filename', filename); formData.append('filename', filename);
if (people.host) {
formData.append('host', JSON.stringify(people.host));
}
formData.append('participants', JSON.stringify(people.participants));
formData.append('recording', blob, filename); formData.append('recording', blob, filename);
const response = await fetch('/api/recordings', { const response = await fetch('/api/recordings', {
@@ -181,6 +186,46 @@ class CallStateManager {
return responseBody; return responseBody;
} }
buildRecordingPeopleMetadata() {
const localUser = this.state.session.localUser || {};
const remoteUser = this.state.session.remoteUser || {};
const members = Object.entries(this.state.participants || {}).map(([participantId, participant]) => (
this._buildRecordingPerson(participant, participant.role || 'participant', participantId)
));
const remoteHost = members.find(member => member.role === 'host');
const localPerson = this._buildRecordingPerson(
localUser,
this.role === 'host' || localUser.isHost ? 'host' : 'participant',
this.selfParticipantId || (this.role === 'host' ? 'host' : 'local')
);
if (localPerson.role === 'host') {
return {
host: localPerson,
participants: members.filter(member => member.role !== 'host')
};
}
return {
host: remoteHost || this._buildRecordingPerson(remoteUser, 'host', 'host'),
participants: [
localPerson,
...members.filter(member => member.role !== 'host' && member.participantId !== localPerson.participantId)
]
};
}
_buildRecordingPerson(user = {}, role = 'participant', participantId = '') {
return {
participantId,
userId: user.id || user.userId || '',
id: user.id || user.userId || '',
name: user.name || '',
avatar: user.avatar || '',
role,
status: user.status || '',
mediaState: user.mediaState ? { ...user.mediaState } : undefined
};
}
async _updateLocalMediaRefactored(mediaType, value) { async _updateLocalMediaRefactored(mediaType, value) {
if (mediaType === 'video' && value) { if (mediaType === 'video' && value) {
await this._enableLocalVideo(); await this._enableLocalVideo();

View File

@@ -56,11 +56,31 @@
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off"> <input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
</div> </div>
<select id="typeFilter" class="recordings-select"> <div class="recordings-format-filter" id="typeFilterControl">
<select id="typeFilter" class="recordings-select-native" aria-hidden="true" tabindex="-1">
<option value="all">全部格式</option> <option value="all">全部格式</option>
<option value="mp4">MP4</option> <option value="mp4">MP4</option>
<option value="webm">WebM</option> <option value="webm">WebM</option>
</select> </select>
<button id="typeFilterButton" class="recordings-filter-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="typeFilterText">全部格式</span>
<i class="fas fa-chevron-down"></i>
</button>
<div id="typeFilterMenu" class="recordings-filter-menu hidden" role="listbox" aria-label="录制格式筛选">
<button class="recordings-filter-option is-active" type="button" role="option" aria-selected="true" data-type-value="all">
<span>全部格式</span>
<small>所有录制</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="mp4">
<span>MP4</span>
<small>标准视频</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="webm">
<span>WebM</span>
<small>网页录制</small>
</button>
</div>
</div>
</section> </section>
<section class="recordings-content"> <section class="recordings-content">
@@ -127,6 +147,8 @@
<tr> <tr>
<th>文件</th> <th>文件</th>
<th>会议</th> <th>会议</th>
<th>房主</th>
<th>参与者</th>
<th>大小</th> <th>大小</th>
<th>上传时间</th> <th>上传时间</th>
<th>操作</th> <th>操作</th>
@@ -179,7 +201,7 @@
<input id="editOriginalFilename" class="recordings-input" type="text" required> <input id="editOriginalFilename" class="recordings-input" type="text" required>
</div> </div>
<div> <div>
<label class="recordings-label" for="editUserId">用户 ID</label> <label class="recordings-label" for="editUserId">房主用户 ID</label>
<input id="editUserId" class="recordings-input" type="text"> <input id="editUserId" class="recordings-input" type="text">
</div> </div>
</div> </div>

View File

@@ -9,6 +9,10 @@ const elements = {
refreshBtn: document.getElementById('refreshBtn'), refreshBtn: document.getElementById('refreshBtn'),
searchInput: document.getElementById('searchInput'), searchInput: document.getElementById('searchInput'),
typeFilter: document.getElementById('typeFilter'), typeFilter: document.getElementById('typeFilter'),
typeFilterControl: document.getElementById('typeFilterControl'),
typeFilterButton: document.getElementById('typeFilterButton'),
typeFilterText: document.getElementById('typeFilterText'),
typeFilterMenu: document.getElementById('typeFilterMenu'),
clearSearchBtn: document.getElementById('clearSearchBtn'), clearSearchBtn: document.getElementById('clearSearchBtn'),
uploadForm: document.getElementById('uploadForm'), uploadForm: document.getElementById('uploadForm'),
uploadBtn: document.getElementById('uploadBtn'), uploadBtn: document.getElementById('uploadBtn'),
@@ -41,6 +45,12 @@ const elements = {
notificationText: document.getElementById('notificationText') notificationText: document.getElementById('notificationText')
}; };
const typeFilterLabels = {
all: '全部格式',
mp4: 'MP4',
webm: 'WebM'
};
function recordingKey(recording) { function recordingKey(recording) {
return `${recording.meetingId}/${recording.filename}`; return `${recording.meetingId}/${recording.filename}`;
} }
@@ -89,6 +99,87 @@ function formatDate(value) {
}); });
} }
function getPersonId(person) {
return person?.userId || person?.id || '';
}
function getPersonName(person) {
return person?.name || getPersonId(person) || '-';
}
function getRecordingHost(recording) {
return recording.host || (recording.userId ? {
userId: recording.userId,
id: recording.userId,
role: 'host'
} : null);
}
function getRecordingParticipants(recording) {
return Array.isArray(recording.participants) ? recording.participants : [];
}
function getPeopleSearchText(recording) {
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return [
host?.participantId,
host?.userId,
host?.id,
host?.name,
...participants.flatMap(participant => [
participant.participantId,
participant.userId,
participant.id,
participant.name
])
].filter(Boolean).join(' ');
}
function renderPersonSummary(person) {
if (!person) {
return '-';
}
const name = getPersonName(person);
const id = getPersonId(person);
return id && id !== name ? `${name} (${id})` : name;
}
function renderPeopleList(people) {
if (!people.length) {
return '<div class="recordings-person-empty">暂无参与者</div>';
}
return people.map((person) => `
<div class="recordings-person">
<img src="${escapeHtml(person.avatar || '/images/p2.png')}" alt="">
<div>
<strong>${escapeHtml(getPersonName(person))}</strong>
<span>${escapeHtml(getPersonId(person) || person.participantId || '-')}</span>
</div>
</div>
`).join('');
}
function setTypeFilter(value) {
const nextValue = typeFilterLabels[value] ? value : 'all';
elements.typeFilter.value = nextValue;
elements.typeFilterText.textContent = typeFilterLabels[nextValue];
elements.typeFilterMenu.querySelectorAll('[data-type-value]').forEach((option) => {
const isActive = option.dataset.typeValue === nextValue;
option.classList.toggle('is-active', isActive);
option.setAttribute('aria-selected', String(isActive));
});
}
function setTypeFilterMenuOpen(isOpen) {
elements.typeFilterControl.classList.toggle('is-open', isOpen);
elements.typeFilterMenu.classList.toggle('hidden', !isOpen);
elements.typeFilterButton.setAttribute('aria-expanded', String(isOpen));
}
function showNotification(message, isError = false) { function showNotification(message, isError = false) {
elements.notificationText.textContent = message; elements.notificationText.textContent = message;
elements.notification.classList.toggle('recordings-notification-error', isError); elements.notification.classList.toggle('recordings-notification-error', isError);
@@ -143,7 +234,8 @@ function applyFilters() {
recording.meetingId, recording.meetingId,
recording.filename, recording.filename,
recording.originalFilename, recording.originalFilename,
recording.userId recording.userId,
getPeopleSearchText(recording)
].join(' ').toLowerCase(); ].join(' ').toLowerCase();
return (type === 'all' || extension === type) && (!query || haystack.includes(query)); return (type === 'all' || extension === type) && (!query || haystack.includes(query));
}); });
@@ -169,6 +261,8 @@ function renderTable() {
const key = recordingKey(recording); const key = recordingKey(recording);
const active = key === state.selectedKey ? 'recordings-row-active' : ''; const active = key === state.selectedKey ? 'recordings-row-active' : '';
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase()); const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return ` return `
<tr class="${active}" data-key="${escapeHtml(key)}"> <tr class="${active}" data-key="${escapeHtml(key)}">
@@ -182,6 +276,8 @@ function renderTable() {
</button> </button>
</td> </td>
<td>${escapeHtml(recording.meetingId)}</td> <td>${escapeHtml(recording.meetingId)}</td>
<td>${escapeHtml(renderPersonSummary(host))}</td>
<td>${participants.length}</td>
<td>${formatBytes(recording.size)}</td> <td>${formatBytes(recording.size)}</td>
<td>${formatDate(recording.uploadedAt)}</td> <td>${formatDate(recording.uploadedAt)}</td>
<td> <td>
@@ -222,6 +318,8 @@ function selectRecording(recording) {
} }
state.selectedKey = recordingKey(recording); state.selectedKey = recordingKey(recording);
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
elements.previewVideo.src = recording.streamUrl; elements.previewVideo.src = recording.streamUrl;
elements.previewPlaceholder.classList.add('hidden'); elements.previewPlaceholder.classList.add('hidden');
elements.previewTitle.textContent = recording.originalFilename || recording.filename; elements.previewTitle.textContent = recording.originalFilename || recording.filename;
@@ -232,6 +330,14 @@ function selectRecording(recording) {
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div> <div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div> <div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div> <div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
<div class="recordings-people-section">
<div class="recordings-people-title">房主</div>
${renderPeopleList(host ? [host] : [])}
</div>
<div class="recordings-people-section">
<div class="recordings-people-title">参与者 (${participants.length})</div>
${renderPeopleList(participants)}
</div>
<div class="recordings-preview-actions"> <div class="recordings-preview-actions">
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}"> <a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
@@ -349,9 +455,32 @@ function bindEvents() {
elements.refreshBtn.addEventListener('click', loadRecordings); elements.refreshBtn.addEventListener('click', loadRecordings);
elements.searchInput.addEventListener('input', applyFilters); elements.searchInput.addEventListener('input', applyFilters);
elements.typeFilter.addEventListener('change', applyFilters); elements.typeFilter.addEventListener('change', applyFilters);
elements.typeFilterButton.addEventListener('click', () => {
setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open'));
});
elements.typeFilterMenu.addEventListener('click', (event) => {
const option = event.target.closest('[data-type-value]');
if (!option) {
return;
}
setTypeFilter(option.dataset.typeValue);
setTypeFilterMenuOpen(false);
applyFilters();
});
document.addEventListener('click', (event) => {
if (!elements.typeFilterControl.contains(event.target)) {
setTypeFilterMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setTypeFilterMenuOpen(false);
}
});
elements.clearSearchBtn.addEventListener('click', () => { elements.clearSearchBtn.addEventListener('click', () => {
elements.searchInput.value = ''; elements.searchInput.value = '';
elements.typeFilter.value = 'all'; setTypeFilter('all');
applyFilters(); applyFilters();
}); });
elements.recordingFile.addEventListener('change', () => { elements.recordingFile.addEventListener('change', () => {
@@ -397,4 +526,5 @@ function bindEvents() {
} }
bindEvents(); bindEvents();
setTypeFilter(elements.typeFilter.value);
loadRecordings(); loadRecordings();

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

@@ -266,11 +266,13 @@ body {
} }
.recordings-toolbar { .recordings-toolbar {
position: relative;
z-index: 50;
min-height: 76px; min-height: 76px;
border-radius: 16px; border-radius: 16px;
padding: 14px; padding: 14px;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px; grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
@@ -296,7 +298,6 @@ body {
} }
.recordings-search, .recordings-search,
.recordings-select,
.recordings-input { .recordings-input {
height: 42px; height: 42px;
border-radius: 12px; border-radius: 12px;
@@ -327,13 +328,118 @@ body {
color: #64748b; color: #64748b;
} }
.recordings-select { .recordings-format-filter {
padding: 0 12px; position: relative;
outline: 0; z-index: 20;
min-width: 152px;
} }
.recordings-select option { .recordings-select-native {
color: #0f172a; position: absolute;
inset: 0;
width: 100%;
height: 42px;
opacity: 0;
pointer-events: none;
}
.recordings-filter-trigger {
width: 100%;
height: 42px;
padding: 0 12px 0 14px;
border: 1px solid rgba(129, 140, 248, 0.45);
border-radius: 12px;
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94));
color: #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 14px;
font-weight: 700;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24);
}
.recordings-filter-trigger i {
color: #c7d2fe;
font-size: 12px;
transition: transform 0.2s ease;
}
.recordings-filter-trigger:hover,
.recordings-format-filter.is-open .recordings-filter-trigger {
border-color: rgba(165, 180, 252, 0.9);
background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96));
}
.recordings-filter-trigger:focus-visible {
border-color: rgba(129, 140, 248, 0.95);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.recordings-format-filter.is-open .recordings-filter-trigger i {
transform: rotate(180deg);
}
.recordings-filter-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 40;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.recordings-filter-option {
position: relative;
width: 100%;
min-height: 50px;
padding: 8px 34px 8px 10px;
border: 0;
border-radius: 10px;
background: transparent;
color: #e2e8f0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
text-align: left;
cursor: pointer;
transition: background 0.18s, color 0.18s;
}
.recordings-filter-option span {
font-size: 14px;
font-weight: 700;
}
.recordings-filter-option small {
color: #64748b;
font-size: 11px;
line-height: 1;
}
.recordings-filter-option:hover,
.recordings-filter-option.is-active {
background: rgba(79, 70, 229, 0.22);
color: #ffffff;
}
.recordings-filter-option.is-active::after {
content: "\f00c";
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: #a5b4fc;
font-family: "Font Awesome 6 Free";
font-size: 12px;
font-weight: 900;
} }
.recordings-content { .recordings-content {
@@ -415,7 +521,6 @@ body {
} }
.recordings-input:focus, .recordings-input:focus,
.recordings-select:focus,
.recordings-search:focus-within { .recordings-search:focus-within {
border-color: rgba(129, 140, 248, 0.9); border-color: rgba(129, 140, 248, 0.9);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
@@ -534,11 +639,13 @@ body {
font-weight: 600; font-weight: 600;
} }
.recordings-table th:nth-child(1) { width: 38%; } .recordings-table th:nth-child(1) { width: 28%; }
.recordings-table th:nth-child(2) { width: 18%; } .recordings-table th:nth-child(2) { width: 12%; }
.recordings-table th:nth-child(3) { width: 12%; } .recordings-table th:nth-child(3) { width: 15%; }
.recordings-table th:nth-child(4) { width: 18%; } .recordings-table th:nth-child(4) { width: 8%; }
.recordings-table th:nth-child(5) { width: 14%; } .recordings-table th:nth-child(5) { width: 9%; }
.recordings-table th:nth-child(6) { width: 14%; }
.recordings-table th:nth-child(7) { width: 14%; }
.recordings-table tbody tr { .recordings-table tbody tr {
transition: background 0.2s; transition: background 0.2s;
@@ -670,6 +777,53 @@ body {
word-break: break-all; word-break: break-all;
} }
.recordings-people-section {
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recordings-people-title {
margin-bottom: 10px;
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.recordings-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.recordings-person img {
width: 34px;
height: 34px;
border-radius: 999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.recordings-person strong,
.recordings-person span {
display: block;
max-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recordings-person strong {
color: #ffffff;
font-size: 13px;
}
.recordings-person span,
.recordings-person-empty {
color: #64748b;
font-size: 12px;
}
.recordings-preview-actions { .recordings-preview-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -738,7 +892,7 @@ body {
} }
.recordings-search, .recordings-search,
.recordings-select { .recordings-format-filter {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -755,6 +909,6 @@ body {
} }
.recordings-table { .recordings-table {
min-width: 760px; min-width: 920px;
} }
} }

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;
@@ -301,6 +303,12 @@ export class WebSocketSignaling extends EventTarget {
this.websocket.send(sendJson); this.websocket.send(sendJson);
} }
sendUserInfo(payload) {
const sendJson = JSON.stringify({ type: 'host-userInfo', data: payload });
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendInviteCall(payload) { sendInviteCall(payload) {
const sendJson = JSON.stringify({ type: 'invite-call', data: payload }); const sendJson = JSON.stringify({ type: 'invite-call', data: payload });
Logger.log(sendJson); Logger.log(sendJson);

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');
@@ -27,10 +29,23 @@ type RecordingMetadata = {
mimetype?: string; mimetype?: string;
size?: number; size?: number;
userId?: string; userId?: string;
host?: RecordingPerson;
participants?: RecordingPerson[];
uploadedAt?: string; uploadedAt?: string;
updatedAt?: 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 { function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase(); const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
@@ -101,6 +116,96 @@ function isAllowedRecording(file: any): boolean {
return ext.length > 0 && isCompatibleMime; 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 { function readRecordingMetadata(metadataPath: string): RecordingMetadata {
try { try {
if (!fs.existsSync(metadataPath)) { if (!fs.existsSync(metadataPath)) {
@@ -115,11 +220,13 @@ function readRecordingMetadata(metadataPath: string): RecordingMetadata {
} }
function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string { function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
if (metadata.mimetype) { const ext = path.extname(filename);
return metadata.mimetype; const mimetype = normalizeMimeType(metadata.mimetype);
if (mimetype && mimetype !== 'text/plain' && mimetype !== 'application/octet-stream') {
return mimetype;
} }
return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; return getRecordingMimeTypeFromExtension(ext);
} }
function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) { function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) {
@@ -129,6 +236,7 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
const metadata = readRecordingMetadata(metadataPath); const metadata = readRecordingMetadata(metadataPath);
const resolvedMeetingId = metadata.meetingId || meetingId; const resolvedMeetingId = metadata.meetingId || meetingId;
const resolvedFilename = metadata.filename || filename; const resolvedFilename = metadata.filename || filename;
const participants = Array.isArray(metadata.participants) ? metadata.participants : [];
return { return {
id: metadata.id || path.basename(filename, path.extname(filename)), id: metadata.id || path.basename(filename, path.extname(filename)),
@@ -138,6 +246,9 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
mimetype: getRecordingMimeType(filename, metadata), mimetype: getRecordingMimeType(filename, metadata),
size: stat.size, size: stat.size,
userId: metadata.userId || '', userId: metadata.userId || '',
host: metadata.host || buildFallbackRecordingHost(metadata.userId),
participants,
participantCount: participants.length,
uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(), uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
updatedAt: metadata.updatedAt || stat.mtime.toISOString(), updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
modifiedAt: stat.mtime.toISOString(), modifiedAt: stat.mtime.toISOString(),
@@ -184,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);
@@ -318,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)) {
@@ -351,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);
@@ -394,6 +763,9 @@ export const createServer = (config: Options): express.Express => {
const recordingId = uuid(); const recordingId = uuid();
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown'); const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`); 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 finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
const meetingDir = path.join(recordingRoot, meetingId); const meetingDir = path.join(recordingRoot, meetingId);
const finalPath = path.join(meetingDir, finalFilename); const finalPath = path.join(meetingDir, finalFilename);
@@ -425,9 +797,11 @@ export const createServer = (config: Options): express.Express => {
meetingId, meetingId,
filename: finalFilename, filename: finalFilename,
originalFilename, originalFilename,
mimetype: normalizeMimeType(request.file.mimetype), mimetype: getRecordingMimeTypeFromExtension(ext),
size: request.file.size, size: request.file.size,
userId: request.body.userId || '', userId,
host,
participants,
uploadedAt: new Date().toISOString() uploadedAt: new Date().toISOString()
}; };
fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined); fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined);
@@ -516,6 +890,16 @@ export const createServer = (config: Options): express.Express => {
} }
const metadata = readRecordingMetadata(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 = { const nextMetadata = {
...metadata, ...metadata,
meetingId: nextMeetingId, meetingId: nextMeetingId,
@@ -523,8 +907,10 @@ export const createServer = (config: Options): express.Express => {
originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim() originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim()
? path.basename(req.body.originalFilename.trim()) ? path.basename(req.body.originalFilename.trim())
: metadata.originalFilename || filename, : metadata.originalFilename || filename,
userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '', userId: nextUserId,
mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'), host: nextHost,
participants: nextParticipants,
mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext),
size: fs.statSync(targetPath).size, size: fs.statSync(targetPath).size,
uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(), uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()

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
} }
} }