Compare commits
3 Commits
40fd7f7e08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d74a0c8121 | |||
| e6dfb28ef2 | |||
| ad93ef342b |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&')
|
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
|
||||||
.replace(/</g, '<')
|
}
|
||||||
.replace(/>/g, '>')
|
if (role === 'participant') {
|
||||||
.replace(/"/g, '"')
|
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
|
||||||
.replace(/'/g, ''');
|
}
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
18
client/public/shared/dom.js
Normal file
18
client/public/shared/dom.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
88
client/test/unit/rendering-safety.test.js
Normal file
88
client/test/unit/rendering-safety.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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用户列表
|
||||||
};
|
};
|
||||||
|
|||||||
132
src/server.ts
132
src/server.ts
@@ -27,10 +27,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 +114,88 @@ 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).replace(/[\u0000-\u001f\u007f]/g, '').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 +210,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 +226,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 +236,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(),
|
||||||
@@ -394,6 +495,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 +529,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 +622,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 +639,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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user