Compare commits
2 Commits
40fd7f7e08
...
e6dfb28ef2
| Author | SHA1 | Date | |
|---|---|---|---|
| e6dfb28ef2 | |||
| ad93ef342b |
@@ -91,7 +91,16 @@ export function buildSocketUserInfoPayload(userInfo, localUser) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +164,14 @@ class CallStateManager {
|
||||
}
|
||||
async uploadRecording({ blob, filename }) {
|
||||
const formData = new FormData();
|
||||
const people = this.buildRecordingPeopleMetadata();
|
||||
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
|
||||
formData.append('userId', this.state.session.localUser.id || '');
|
||||
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);
|
||||
|
||||
const response = await fetch('/api/recordings', {
|
||||
@@ -181,6 +186,46 @@ class CallStateManager {
|
||||
|
||||
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) {
|
||||
if (mediaType === 'video' && value) {
|
||||
await this._enableLocalVideo();
|
||||
|
||||
@@ -56,11 +56,31 @@
|
||||
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<select id="typeFilter" class="recordings-select">
|
||||
<option value="all">全部格式</option>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</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="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</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 class="recordings-content">
|
||||
@@ -127,6 +147,8 @@
|
||||
<tr>
|
||||
<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>
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ const elements = {
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
searchInput: document.getElementById('searchInput'),
|
||||
typeFilter: document.getElementById('typeFilter'),
|
||||
typeFilterControl: document.getElementById('typeFilterControl'),
|
||||
typeFilterButton: document.getElementById('typeFilterButton'),
|
||||
typeFilterText: document.getElementById('typeFilterText'),
|
||||
typeFilterMenu: document.getElementById('typeFilterMenu'),
|
||||
clearSearchBtn: document.getElementById('clearSearchBtn'),
|
||||
uploadForm: document.getElementById('uploadForm'),
|
||||
uploadBtn: document.getElementById('uploadBtn'),
|
||||
@@ -41,6 +45,12 @@ const elements = {
|
||||
notificationText: document.getElementById('notificationText')
|
||||
};
|
||||
|
||||
const typeFilterLabels = {
|
||||
all: '全部格式',
|
||||
mp4: 'MP4',
|
||||
webm: 'WebM'
|
||||
};
|
||||
|
||||
function recordingKey(recording) {
|
||||
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) {
|
||||
elements.notificationText.textContent = message;
|
||||
elements.notification.classList.toggle('recordings-notification-error', isError);
|
||||
@@ -143,7 +234,8 @@ function applyFilters() {
|
||||
recording.meetingId,
|
||||
recording.filename,
|
||||
recording.originalFilename,
|
||||
recording.userId
|
||||
recording.userId,
|
||||
getPeopleSearchText(recording)
|
||||
].join(' ').toLowerCase();
|
||||
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
|
||||
});
|
||||
@@ -169,6 +261,8 @@ function renderTable() {
|
||||
const key = recordingKey(recording);
|
||||
const active = key === state.selectedKey ? 'recordings-row-active' : '';
|
||||
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
|
||||
const host = getRecordingHost(recording);
|
||||
const participants = getRecordingParticipants(recording);
|
||||
|
||||
return `
|
||||
<tr class="${active}" data-key="${escapeHtml(key)}">
|
||||
@@ -182,6 +276,8 @@ function renderTable() {
|
||||
</button>
|
||||
</td>
|
||||
<td>${escapeHtml(recording.meetingId)}</td>
|
||||
<td>${escapeHtml(renderPersonSummary(host))}</td>
|
||||
<td>${participants.length}</td>
|
||||
<td>${formatBytes(recording.size)}</td>
|
||||
<td>${formatDate(recording.uploadedAt)}</td>
|
||||
<td>
|
||||
@@ -222,6 +318,8 @@ function selectRecording(recording) {
|
||||
}
|
||||
|
||||
state.selectedKey = recordingKey(recording);
|
||||
const host = getRecordingHost(recording);
|
||||
const participants = getRecordingParticipants(recording);
|
||||
elements.previewVideo.src = recording.streamUrl;
|
||||
elements.previewPlaceholder.classList.add('hidden');
|
||||
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>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</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">
|
||||
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
|
||||
<i class="fas fa-download"></i>
|
||||
@@ -349,9 +455,32 @@ function bindEvents() {
|
||||
elements.refreshBtn.addEventListener('click', loadRecordings);
|
||||
elements.searchInput.addEventListener('input', 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.searchInput.value = '';
|
||||
elements.typeFilter.value = 'all';
|
||||
setTypeFilter('all');
|
||||
applyFilters();
|
||||
});
|
||||
elements.recordingFile.addEventListener('change', () => {
|
||||
@@ -397,4 +526,5 @@ function bindEvents() {
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
setTypeFilter(elements.typeFilter.value);
|
||||
loadRecordings();
|
||||
|
||||
@@ -266,11 +266,13 @@ body {
|
||||
}
|
||||
|
||||
.recordings-toolbar {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
min-height: 76px;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
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;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -296,7 +298,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-search,
|
||||
.recordings-select,
|
||||
.recordings-input {
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
@@ -327,13 +328,118 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.recordings-select {
|
||||
padding: 0 12px;
|
||||
outline: 0;
|
||||
.recordings-format-filter {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
min-width: 152px;
|
||||
}
|
||||
|
||||
.recordings-select option {
|
||||
color: #0f172a;
|
||||
.recordings-select-native {
|
||||
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 {
|
||||
@@ -415,7 +521,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-input:focus,
|
||||
.recordings-select:focus,
|
||||
.recordings-search:focus-within {
|
||||
border-color: rgba(129, 140, 248, 0.9);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
|
||||
@@ -534,11 +639,13 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recordings-table th:nth-child(1) { width: 38%; }
|
||||
.recordings-table th:nth-child(2) { width: 18%; }
|
||||
.recordings-table th:nth-child(3) { width: 12%; }
|
||||
.recordings-table th:nth-child(4) { width: 18%; }
|
||||
.recordings-table th:nth-child(5) { width: 14%; }
|
||||
.recordings-table th:nth-child(1) { width: 28%; }
|
||||
.recordings-table th:nth-child(2) { width: 12%; }
|
||||
.recordings-table th:nth-child(3) { width: 15%; }
|
||||
.recordings-table th:nth-child(4) { width: 8%; }
|
||||
.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 {
|
||||
transition: background 0.2s;
|
||||
@@ -670,6 +777,53 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -738,7 +892,7 @@ body {
|
||||
}
|
||||
|
||||
.recordings-search,
|
||||
.recordings-select {
|
||||
.recordings-format-filter {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -755,6 +909,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-table {
|
||||
min-width: 760px;
|
||||
min-width: 920px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,12 @@ export class WebSocketSignaling extends EventTarget {
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendUserInfo(payload) {
|
||||
const sendJson = JSON.stringify({ type: 'host-userInfo', data: payload });
|
||||
Logger.log(sendJson);
|
||||
this.websocket.send(sendJson);
|
||||
}
|
||||
|
||||
sendInviteCall(payload) {
|
||||
const sendJson = JSON.stringify({ type: 'invite-call', data: payload });
|
||||
Logger.log(sendJson);
|
||||
|
||||
132
src/server.ts
132
src/server.ts
@@ -27,10 +27,23 @@ type RecordingMetadata = {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
userId?: string;
|
||||
host?: RecordingPerson;
|
||||
participants?: RecordingPerson[];
|
||||
uploadedAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type RecordingPerson = {
|
||||
participantId?: string;
|
||||
userId?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
mediaState?: any;
|
||||
};
|
||||
|
||||
function safeAvatarExtension(file: any): string {
|
||||
const originalExt = path.extname(file.originalname || '').toLowerCase();
|
||||
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
|
||||
@@ -101,6 +114,88 @@ function isAllowedRecording(file: any): boolean {
|
||||
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 {
|
||||
try {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
@@ -115,11 +210,13 @@ function readRecordingMetadata(metadataPath: string): RecordingMetadata {
|
||||
}
|
||||
|
||||
function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
|
||||
if (metadata.mimetype) {
|
||||
return metadata.mimetype;
|
||||
const ext = path.extname(filename);
|
||||
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) {
|
||||
@@ -129,6 +226,7 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
|
||||
const metadata = readRecordingMetadata(metadataPath);
|
||||
const resolvedMeetingId = metadata.meetingId || meetingId;
|
||||
const resolvedFilename = metadata.filename || filename;
|
||||
const participants = Array.isArray(metadata.participants) ? metadata.participants : [];
|
||||
|
||||
return {
|
||||
id: metadata.id || path.basename(filename, path.extname(filename)),
|
||||
@@ -138,6 +236,9 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
|
||||
mimetype: getRecordingMimeType(filename, metadata),
|
||||
size: stat.size,
|
||||
userId: metadata.userId || '',
|
||||
host: metadata.host || buildFallbackRecordingHost(metadata.userId),
|
||||
participants,
|
||||
participantCount: participants.length,
|
||||
uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
|
||||
updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
@@ -394,6 +495,9 @@ export const createServer = (config: Options): express.Express => {
|
||||
const recordingId = uuid();
|
||||
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
|
||||
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`);
|
||||
const userId = sanitizeMetadataString(request.body.userId, 120);
|
||||
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
|
||||
const participants = sanitizeRecordingParticipants(request.body.participants);
|
||||
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
|
||||
const meetingDir = path.join(recordingRoot, meetingId);
|
||||
const finalPath = path.join(meetingDir, finalFilename);
|
||||
@@ -425,9 +529,11 @@ export const createServer = (config: Options): express.Express => {
|
||||
meetingId,
|
||||
filename: finalFilename,
|
||||
originalFilename,
|
||||
mimetype: normalizeMimeType(request.file.mimetype),
|
||||
mimetype: getRecordingMimeTypeFromExtension(ext),
|
||||
size: request.file.size,
|
||||
userId: request.body.userId || '',
|
||||
userId,
|
||||
host,
|
||||
participants,
|
||||
uploadedAt: new Date().toISOString()
|
||||
};
|
||||
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 nextUserId = typeof req.body.userId === 'string' ? sanitizeMetadataString(req.body.userId, 120) : metadata.userId || '';
|
||||
const shouldSyncHostFromUserId = !metadata.host
|
||||
|| metadata.host.userId === metadata.userId
|
||||
|| metadata.host.id === metadata.userId;
|
||||
const nextHost = req.body.host !== undefined
|
||||
? sanitizeRecordingPerson(req.body.host, 'host') || buildFallbackRecordingHost(nextUserId)
|
||||
: shouldSyncHostFromUserId ? buildFallbackRecordingHost(nextUserId) : metadata.host;
|
||||
const nextParticipants = req.body.participants !== undefined
|
||||
? sanitizeRecordingParticipants(req.body.participants)
|
||||
: Array.isArray(metadata.participants) ? metadata.participants : [];
|
||||
const nextMetadata = {
|
||||
...metadata,
|
||||
meetingId: nextMeetingId,
|
||||
@@ -523,8 +639,10 @@ export const createServer = (config: Options): express.Express => {
|
||||
originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim()
|
||||
? path.basename(req.body.originalFilename.trim())
|
||||
: metadata.originalFilename || filename,
|
||||
userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '',
|
||||
mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'),
|
||||
userId: nextUserId,
|
||||
host: nextHost,
|
||||
participants: nextParticipants,
|
||||
mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext),
|
||||
size: fs.statSync(targetPath).size,
|
||||
uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
|
||||
Reference in New Issue
Block a user