From bbe7e71274d4b3b05d5c3e47197e626760d2eb4e Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.com> Date: Mon, 25 May 2026 17:39:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A7=86=E9=A2=91=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + client/public/css/style.css | 505 ++++++++++++++++++ client/public/recordings/index.html | 205 +++++++ client/public/recordings/recordings-admin.js | 400 ++++++++++++++ ...r_1d006f98-8352-47d5-bf35-c65a98dfc377.png | Bin 0 -> 19368 bytes ...r_be8f2ecc-726c-4b99-bcd0-818aa962e311.png | Bin 0 -> 13257 bytes src/server.ts | 304 +++++++++++ 7 files changed, 1416 insertions(+) create mode 100644 client/public/recordings/index.html create mode 100644 client/public/recordings/recordings-admin.js create mode 100644 client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png create mode 100644 client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png diff --git a/.gitignore b/.gitignore index bfa5d2f..49f709d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ node_modules/ # Coverage coverage/ recordings/ +!client/public/recordings/ +!client/public/recordings/** *.lcov .nyc_output diff --git a/client/public/css/style.css b/client/public/css/style.css index 7277e68..3654a8c 100644 --- a/client/public/css/style.css +++ b/client/public/css/style.css @@ -253,3 +253,508 @@ body { from { opacity: 0; transform: translateX(-50%) translateY(8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } + +.recordings-page { + overflow: hidden; +} + +.recordings-shell { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.recordings-toolbar { + min-height: 76px; + border-radius: 16px; + padding: 14px; + display: grid; + grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px; + align-items: center; + gap: 12px; +} + +.recordings-stat { + min-width: 112px; + padding: 6px 10px; +} + +.recordings-stat-value { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 700; + color: #ffffff; +} + +.recordings-stat-label { + display: block; + margin-top: 4px; + font-size: 12px; + color: #94a3b8; +} + +.recordings-search, +.recordings-select, +.recordings-input { + height: 42px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(15, 23, 42, 0.58); + color: #ffffff; +} + +.recordings-search { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; +} + +.recordings-search input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #ffffff; + font-size: 14px; +} + +.recordings-search input::placeholder, +.recordings-input::placeholder { + color: #64748b; +} + +.recordings-select { + padding: 0 12px; + outline: 0; +} + +.recordings-select option { + color: #0f172a; +} + +.recordings-content { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 280px minmax(420px, 1fr) 360px; + gap: 16px; +} + +.recordings-upload, +.recordings-list, +.recordings-preview { + min-height: 0; + border-radius: 16px; +} + +.recordings-upload { + padding: 18px; +} + +.recordings-list { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.recordings-list-head { + min-height: 72px; + padding: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-preview { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.recordings-dropzone { + min-height: 132px; + border-radius: 14px; + border: 1px dashed rgba(129, 140, 248, 0.55); + background: rgba(79, 70, 229, 0.08); + color: #c7d2fe; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + text-align: center; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.recordings-dropzone:hover { + background: rgba(79, 70, 229, 0.16); + border-color: rgba(165, 180, 252, 0.8); +} + +.recordings-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + color: #94a3b8; +} + +.recordings-input { + width: 100%; + padding: 0 12px; + outline: 0; +} + +.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); +} + +.recordings-primary-btn, +.recordings-secondary-btn, +.recordings-text-btn, +.recordings-icon-btn { + border: 0; + outline: 0; + cursor: pointer; + transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s; +} + +.recordings-primary-btn, +.recordings-secondary-btn { + min-height: 42px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + font-weight: 600; +} + +.recordings-primary-btn { + width: 100%; + background: #4f46e5; + color: #ffffff; +} + +.recordings-primary-btn:hover { + background: #4338ca; +} + +.recordings-primary-btn:disabled { + opacity: 0.55; + cursor: wait; +} + +.recordings-secondary-btn { + width: 100%; + background: rgba(255, 255, 255, 0.08); + color: #e2e8f0; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-secondary-btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.recordings-text-btn { + display: inline-flex; + align-items: center; + gap: 8px; + color: #c7d2fe; + background: transparent; + font-size: 13px; +} + +.recordings-icon-btn { + width: 38px; + height: 38px; + border-radius: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #cbd5e1; + background: rgba(255, 255, 255, 0.06); +} + +.recordings-icon-btn:hover { + transform: translateY(-1px); + color: #ffffff; + background: rgba(255, 255, 255, 0.12); +} + +.recordings-danger { + color: #fca5a5; +} + +.recordings-danger:hover { + color: #ffffff; + background: rgba(239, 68, 68, 0.75); +} + +.recordings-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.recordings-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.recordings-table th, +.recordings-table td { + padding: 13px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; + vertical-align: middle; +} + +.recordings-table th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(15, 23, 42, 0.95); + color: #94a3b8; + font-size: 12px; + 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 tbody tr { + transition: background 0.2s; +} + +.recordings-table tbody tr:hover, +.recordings-row-active { + background: rgba(99, 102, 241, 0.14); +} + +.recordings-file-cell { + max-width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.recordings-file-icon { + flex: 0 0 auto; + width: 46px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(16, 185, 129, 0.16); + color: #6ee7b7; + font-size: 11px; + font-weight: 700; +} + +.recordings-file-name, +.recordings-file-sub { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recordings-file-name { + color: #ffffff; + font-size: 14px; + font-weight: 600; +} + +.recordings-file-sub { + margin-top: 3px; + color: #64748b; + font-size: 12px; +} + +.recordings-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.recordings-empty { + flex: 1; + min-height: 260px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: #94a3b8; +} + +.recordings-preview-video { + position: relative; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + background: #020617; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-preview-video video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.recordings-preview-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: #94a3b8; + background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45)); +} + +.recordings-preview-meta { + min-height: 0; + overflow: auto; +} + +.recordings-preview-meta h2 { + margin-bottom: 14px; + font-size: 17px; + font-weight: 700; + word-break: break-word; +} + +.recordings-detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.recordings-detail-row span { + color: #94a3b8; +} + +.recordings-detail-row strong { + color: #e2e8f0; + font-weight: 600; + text-align: right; + word-break: break-all; +} + +.recordings-preview-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 16px; +} + +.recordings-dialog { + width: min(440px, calc(100vw - 32px)); + border-radius: 18px; + padding: 22px; +} + +.recordings-notification { + position: fixed; + top: 82px; + left: 50%; + transform: translate(-50%, -16px); + z-index: 60; + min-height: 44px; + padding: 0 18px; + border-radius: 999px; + display: flex; + align-items: center; + gap: 10px; + color: #ffffff; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; +} + +.recordings-notification-visible { + opacity: 1; + transform: translate(-50%, 0); +} + +.recordings-notification-error i { + color: #f87171; +} + +@media (max-width: 1180px) { + .recordings-content { + grid-template-columns: 260px minmax(420px, 1fr); + } + + .recordings-preview { + grid-column: 1 / -1; + min-height: 360px; + display: grid; + grid-template-columns: minmax(360px, 0.8fr) 1fr; + } +} + +@media (max-width: 860px) { + .recordings-page { + overflow: auto; + } + + .recordings-shell { + padding: 14px; + overflow: visible; + } + + .recordings-toolbar { + grid-template-columns: 1fr 1fr; + } + + .recordings-search, + .recordings-select { + grid-column: 1 / -1; + } + + .recordings-content, + .recordings-preview { + display: flex; + flex-direction: column; + } + + .recordings-list, + .recordings-upload, + .recordings-preview { + flex: none; + } + + .recordings-table { + min-width: 760px; + } +} diff --git a/client/public/recordings/index.html b/client/public/recordings/index.html new file mode 100644 index 0000000..d66d5ec --- /dev/null +++ b/client/public/recordings/index.html @@ -0,0 +1,205 @@ + + + + + + + VideoCall - 录制管理 + + + + + + +
+
+
+
+ +
+
+

录制管理后台

+
+ + recordings +
+
+
+ +
+ + + + +
+
+ +
+
+
+ 0 + 总录制 +
+
+ 0 + 会议数 +
+
+ 0 MB + 占用空间 +
+ + + + +
+ +
+ + +
+
+
+

录制文件

+

等待加载

+
+ +
+ +
+
+ 正在读取 recordings 目录... +
+ + + + +
+ + +
+
+
+ + + +
+ + +
+ + + + + diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js new file mode 100644 index 0000000..4e636ab --- /dev/null +++ b/client/public/recordings/recordings-admin.js @@ -0,0 +1,400 @@ +const state = { + recordings: [], + filtered: [], + selectedKey: '', + editing: null +}; + +const elements = { + refreshBtn: document.getElementById('refreshBtn'), + searchInput: document.getElementById('searchInput'), + typeFilter: document.getElementById('typeFilter'), + clearSearchBtn: document.getElementById('clearSearchBtn'), + uploadForm: document.getElementById('uploadForm'), + uploadBtn: document.getElementById('uploadBtn'), + recordingFile: document.getElementById('recordingFile'), + fileNameText: document.getElementById('fileNameText'), + uploadMeetingId: document.getElementById('uploadMeetingId'), + uploadUserId: document.getElementById('uploadUserId'), + totalCount: document.getElementById('totalCount'), + meetingCount: document.getElementById('meetingCount'), + storageSize: document.getElementById('storageSize'), + recordingRootText: document.getElementById('recordingRootText'), + listSummary: document.getElementById('listSummary'), + loadingState: document.getElementById('loadingState'), + emptyState: document.getElementById('emptyState'), + recordingsTableWrap: document.getElementById('recordingsTableWrap'), + recordingsTableBody: document.getElementById('recordingsTableBody'), + previewVideo: document.getElementById('previewVideo'), + previewPlaceholder: document.getElementById('previewPlaceholder'), + previewTitle: document.getElementById('previewTitle'), + previewDetails: document.getElementById('previewDetails'), + editDialog: document.getElementById('editDialog'), + editForm: document.getElementById('editForm'), + editFilenameText: document.getElementById('editFilenameText'), + editMeetingId: document.getElementById('editMeetingId'), + editOriginalFilename: document.getElementById('editOriginalFilename'), + editUserId: document.getElementById('editUserId'), + closeEditBtn: document.getElementById('closeEditBtn'), + cancelEditBtn: document.getElementById('cancelEditBtn'), + notification: document.getElementById('notification'), + notificationText: document.getElementById('notificationText') +}; + +function recordingKey(recording) { + return `${recording.meetingId}/${recording.filename}`; +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatBytes(bytes) { + const value = Number(bytes) || 0; + if (value < 1024) { + return `${value} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB']; + let size = value / 1024; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; +} + +function formatDate(value) { + if (!value) { + return '-'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '-'; + } + + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +function showNotification(message, isError = false) { + elements.notificationText.textContent = message; + elements.notification.classList.toggle('recordings-notification-error', isError); + elements.notification.classList.add('recordings-notification-visible'); + window.clearTimeout(showNotification.timer); + showNotification.timer = window.setTimeout(() => { + elements.notification.classList.remove('recordings-notification-visible'); + }, 2600); +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.message || `请求失败: ${response.status}`); + } + return payload; +} + +function setLoading(isLoading) { + elements.loadingState.classList.toggle('hidden', !isLoading); + elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0); + elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0); +} + +async function loadRecordings() { + setLoading(true); + try { + const payload = await requestJson('/api/recordings'); + state.recordings = payload.recordings || []; + elements.recordingRootText.textContent = payload.root || 'recordings'; + applyFilters(); + const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0]; + selectRecording(selected || null); + } catch (error) { + state.recordings = []; + applyFilters(); + selectRecording(null); + showNotification(error.message, true); + } finally { + setLoading(false); + } +} + +function applyFilters() { + const query = elements.searchInput.value.trim().toLowerCase(); + const type = elements.typeFilter.value; + + state.filtered = state.recordings.filter((recording) => { + const extension = (recording.filename || '').split('.').pop().toLowerCase(); + const haystack = [ + recording.meetingId, + recording.filename, + recording.originalFilename, + recording.userId + ].join(' ').toLowerCase(); + return (type === 'all' || extension === type) && (!query || haystack.includes(query)); + }); + + elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all'); + renderSummary(); + renderTable(); + setLoading(false); +} + +function renderSummary() { + const meetings = new Set(state.recordings.map(recording => recording.meetingId)); + const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0); + + elements.totalCount.textContent = state.recordings.length; + elements.meetingCount.textContent = meetings.size; + elements.storageSize.textContent = formatBytes(totalSize); + elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length} 条`; +} + +function renderTable() { + elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => { + const key = recordingKey(recording); + const active = key === state.selectedKey ? 'recordings-row-active' : ''; + const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase()); + + return ` + + + + + ${escapeHtml(recording.meetingId)} + ${formatBytes(recording.size)} + ${formatDate(recording.uploadedAt)} + +
+ + + + + + +
+ + + `; + }).join(''); +} + +function findRecording(key) { + return state.recordings.find(recording => recordingKey(recording) === key); +} + +function selectRecording(recording) { + if (!recording) { + state.selectedKey = ''; + elements.previewVideo.removeAttribute('src'); + elements.previewVideo.load(); + elements.previewPlaceholder.classList.remove('hidden'); + elements.previewTitle.textContent = '未选择录制'; + elements.previewDetails.innerHTML = '

从左侧列表选择文件后,可播放、下载、编辑或删除。

'; + renderTable(); + return; + } + + state.selectedKey = recordingKey(recording); + elements.previewVideo.src = recording.streamUrl; + elements.previewPlaceholder.classList.add('hidden'); + elements.previewTitle.textContent = recording.originalFilename || recording.filename; + elements.previewDetails.innerHTML = ` +
会议 ID${escapeHtml(recording.meetingId)}
+
文件名${escapeHtml(recording.filename)}
+
格式${escapeHtml(recording.mimetype)}
+
大小${formatBytes(recording.size)}
+
用户 ID${escapeHtml(recording.userId || '-')}
+
上传时间${formatDate(recording.uploadedAt)}
+
+ + + 下载 + + +
+ `; + renderTable(); +} + +function openEdit(recording) { + if (!recording) { + return; + } + + state.editing = recording; + elements.editFilenameText.textContent = recording.filename; + elements.editMeetingId.value = recording.meetingId || ''; + elements.editOriginalFilename.value = recording.originalFilename || recording.filename || ''; + elements.editUserId.value = recording.userId || ''; + elements.editDialog.classList.remove('hidden'); + elements.editDialog.classList.add('flex'); + elements.editMeetingId.focus(); +} + +function closeEdit() { + state.editing = null; + elements.editDialog.classList.add('hidden'); + elements.editDialog.classList.remove('flex'); +} + +async function handleUpload(event) { + event.preventDefault(); + const file = elements.recordingFile.files[0]; + if (!file) { + showNotification('请选择录制文件', true); + return; + } + + const formData = new FormData(); + formData.append('recording', file, file.name); + formData.append('filename', file.name); + formData.append('meetingId', elements.uploadMeetingId.value.trim()); + formData.append('userId', elements.uploadUserId.value.trim()); + + elements.uploadBtn.disabled = true; + try { + await requestJson('/api/recordings', { + method: 'POST', + body: formData + }); + elements.uploadForm.reset(); + elements.fileNameText.textContent = '选择 MP4 或 WebM 文件'; + showNotification('录制已上传'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } finally { + elements.uploadBtn.disabled = false; + } +} + +async function handleEdit(event) { + event.preventDefault(); + if (!state.editing) { + return; + } + + const recording = state.editing; + try { + const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + meetingId: elements.editMeetingId.value.trim(), + originalFilename: elements.editOriginalFilename.value.trim(), + userId: elements.editUserId.value.trim() + }) + }); + state.selectedKey = recordingKey(payload.recording); + closeEdit(); + showNotification('录制信息已更新'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } +} + +async function deleteRecording(recording) { + if (!recording) { + return; + } + + const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`); + if (!confirmed) { + return; + } + + try { + await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { + method: 'DELETE' + }); + if (state.selectedKey === recordingKey(recording)) { + state.selectedKey = ''; + } + showNotification('录制已删除'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } +} + +function bindEvents() { + elements.refreshBtn.addEventListener('click', loadRecordings); + elements.searchInput.addEventListener('input', applyFilters); + elements.typeFilter.addEventListener('change', applyFilters); + elements.clearSearchBtn.addEventListener('click', () => { + elements.searchInput.value = ''; + elements.typeFilter.value = 'all'; + applyFilters(); + }); + elements.recordingFile.addEventListener('change', () => { + const file = elements.recordingFile.files[0]; + elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件'; + }); + elements.uploadForm.addEventListener('submit', handleUpload); + elements.editForm.addEventListener('submit', handleEdit); + elements.closeEditBtn.addEventListener('click', closeEdit); + elements.cancelEditBtn.addEventListener('click', closeEdit); + elements.editDialog.addEventListener('click', (event) => { + if (event.target === elements.editDialog) { + closeEdit(); + } + }); + + elements.recordingsTableBody.addEventListener('click', (event) => { + const control = event.target.closest('[data-action]'); + if (!control) { + return; + } + + const recording = findRecording(control.dataset.key); + const action = control.dataset.action; + if (action === 'select' || action === 'preview') { + selectRecording(recording); + if (action === 'preview') { + elements.previewVideo.play().catch(() => undefined); + } + } else if (action === 'edit') { + openEdit(recording); + } else if (action === 'delete') { + deleteRecording(recording); + } + }); + + elements.previewDetails.addEventListener('click', (event) => { + const control = event.target.closest('[data-preview-action]'); + if (control && control.dataset.previewAction === 'edit') { + openEdit(findRecording(state.selectedKey)); + } + }); +} + +bindEvents(); +loadRecordings(); diff --git a/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png b/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png new file mode 100644 index 0000000000000000000000000000000000000000..d7e88ac75bf37b50746889257dfeba233f0051c2 GIT binary patch literal 19368 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 literal 0 HcmV?d00001 diff --git a/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png b/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png new file mode 100644 index 0000000000000000000000000000000000000000..613eea93a05d6934c0233a5588d731430a242385 GIT binary patch literal 13257 zcmZv@Wmp_d)4z?&;;^^}_r={Ef;$9v*Wm8%vJl+e3BlchyF-xR3GM{`d)>$Te0x9a z?lCpevpwC@^*gI7MpaoB4L}5df`USmmy=S5{Eq+kK|+9h*Xh>hLqTCe$xDfAdP862 zA?5<5SNeB@Da1`;u;K7xtT6X9fvZ=igyOT|uG7Wz;S~5GGK! zhtymC|846N6nYX{n9~;(kn5~}pbrr}$%TC8ki)54B?JU%^GS#J-AmxBQ}6-Dfnm92 z)K3a~+!21bJKDPCR~>@^fB;-KpoGp>`Z>*-4TC+#h%wyJj6t6>>nY1nag#)y)=^Si zvu!LQkklSpgfZ|6v1(U~(jy}YhO|{-m7%+$QPZ@0&>Wz{c$hJ0b3evtAvGk&8&;?= zK%&2!j*c8k-C=C5to8KSv{9ly)R}c^%?r@Fi2b=7T(XM6kXxBNl~;|itFtCcS;xAK zDlz-3HB{Da5DOqm-(46?y`l7P&Jq!x!1)teuZ~9@C_<*@SLZqDM!4^(`>6$HDWY)5 z=Em{@>I(x!j0xT-DC#ueBvdxUDR#jH9vpg2dw{%CfWYtO(Flqy8CJ$FOA}{x4>gJ9 zI%(7$Ah-&ucE#HEt0M25QiECsd;O~~21)Pnz_V$UD026;c6hKOonajyi!yn(S|y&y zR14LtRxpLL1_1eF;(Vo`ZLh;$=op^%W|lu2FqV###fh^jD>^Cw)+KvTaE)}pMJ_cG zmmUVl8kix20Y^#*^C6XCYufYB(3*vJ8X{KD~1|m6{ z6a;2W03PWzS6l(N8Y;H`V9e1Q4BG3t9M)9Tnhz6OfRobHeT1bIxLX_F9hn?AT2mhW zY%2bH$>xY39%*fg+$n`B5vqI-0b~p`@gJ%FQJSlh*CHbY_GSw|<=5ev6 zrvKf6f!ltC3vHpD5r}gwc?0|E&)1BO16;BcBMAk<7`R(^Jq+9 z{@(eSzQ`>D&Qg#}2z#8bW#Z_r;pL3lQyk10iaP?zop02jOoSGokLj(t-|aH{jH*ai zWmE`F#LJ88+k|R18s%MSRB6~|zj&Ra0&`$o z3Z8qiJ*@rEuYcrvmp|((b*X?s!=WA2Eo#r_CxXAlTD3`Zfc91lAOIkQ7MZjL*$~*5 zl-FRCaO})Yn(zq>%;X6w9*o*Fb?pOVq-v`-dlj%C&5=2-=Ez1AT9@0s@$>UXav`r# zh*aZ+<}69W?)TXSBwIx(L{@voWlnBX!Y*UPmmHYqh7k2^I!(0SQlAN#Gp4nnhB}dI zWW~1QAyZ(ciVN+pEvYdb0e386;gj*=xe45L2eKXpx>T^*4PuY$$*eU)muhro@oq4| zP4Xo&%qOiGT=BW$023E+!|$5|S`9{qa~$f0M*K8L84Vg0%CVi7FW!&aURj=hHVCau zQFo|Di@O42g?Zo_7 z8gVdgLkP4v%5c=)XjtFpD~i z17638r&j9*a*qGZV6xoq{F-U(LNHGX|5n~M@~}V2M@@xl)+F2bZ^iTP7DM0rOU>I2 z3gXbFG8wIlT*ycq{-lcNKWbv$X5DpDTKQDzPgqTJR?P~brG_KL_^*3WPY-VZpD| z>QRZlKb0^W{uE(z=4fk-6||?8Q@{VobR?Ee#l2E*cs@;x&@k4t);SQGM@$@@2?XKFB$99?Kfhc52KnRe1j46yhwh& zJtj`;)E-OG6%7G$PeKlhiH$hLLmAIxF;JhQ_Rp;YnyBO3nrZ!RrjUkV6IWNp)~_rz zEi=1eGcfj#ZvP4Z!OjjB;||3|xQ_4WH&-0ksc>o>rdI2`Z1D{uijtZ9F$tXjG?^_# zwd;L`-Vr=Mo+C=t8d5l}J{8XRWW)*SZ-o>U?;}<-PssBKRq4j8CWD_NGD(->ZXc(R zpOuhH_bPU2Af!GeVor|Mkw@5*L-)nr4_E!l5d0u!5G@NIpPkKW4_fcfkOiZH8ApX zwB^cqjPq6|gUp)3T{O{p;-CF%D>yF0&ZEGVu`r;qscg=Od#_)rmIQEZv3Wt70^!Lp z6ZBB-Ae}*f0-X4X{Y;TbC*;AOGpcor0U1TsF)+1#<_lm;Mm+2_^;?bK$M*!(G>`hV z7L46P$6E0)pqLTH3WqSfTlQj~X^MQeh7i*yeD!`Zco@jmC+j3uJpw_gbqv6vO*uDE z$kj+8IhkL`hsKnaOtqg=iPi!WV@C!o{fqvWp7kG^Fz?Z}5Gt=|)$eK)$*B8;>gx|Y z68^BV(6bxGS=hszr7*I8nWZXYOD?i`4rsz!EBD!GY6D&VzmrLDNM6Fhnyvu2MDaEG zwYu28^v0KgBC%ft7KIDt-z82Y0B|8N`Bkq*mAd|+k1rIeG{clE!0|{4BAgmBb+o;$B+5&V%r7uk^B zobIkrrsGU;AuRKHyI&yJ#d0MYHZ~Om3^HbTczDaXHhY~Y{>TX!Iq20ti)djaHEh4UQJ*EvDf@FV2_x&_ zkFanU^PIXwX)nd=%MV;Vo}a--H>qE?&Ly+& zXVh+f8RA#q&U)Zy78I}<2i#{||4GC*@)ob}zEL>c@#zAx`AF$I5waq#5`E&$=MgXO zTUYG;*k>i=`c>>va#G&ClCzcpbp1);xQ$nPy10)ne5d++HH7gS@>4q{lA@IxFYN$}ou|oQPC+H*Kx$on( z&rK+JKWw5E5>h@H zX!u8!d4FrQ2kCjv^t4htUVZ4nEDtf{DAOPvU{qL^(`-{lk$gJvh@USM?*QlveaJBE zfQHnbh25;>_rU(LwqfZ=RqQr%g>LC4i9R}2TzOx}tbW_we$nH61~RdSVIIR($nlvb zN9yYqGA!2Wac1+m78mRNjm1yE)H7zUeXDA#*?FBxgMJpSlSVhG4SG8u|E&)DW|G)} zA9t}emFHO5{;L0cHNxajF)0>$Fk)JyOaPZM;w46cck0Z6&KG!ARl$hYe8z%RXjWO$ z6@u^On$Efy0{{RHGYy;=WJ|nj#zRAI{GxQmNUzeqT+xjY#Sf`F(g~_-95SwXDiefX zNBs$1$Ow*fxtXBL!f!Xv1G4$`zU>?>za$7Af)%ag+Wv{Av6C~WJt)N;4sy0A#@?MR zD@LM|>Ng!t=BMmW_i3WGxbogd-8Z}5DpQTr{lfsg1%L1__~wGf3B_rKMP4tcRt+n{ zu592|{pp(>Z9Ssv&gENH&ahCHv+NP8AdWEz)xkJQNI<-Se-`WT+Ai^6E5b`$I$ouGW|t+d+TfN8Oi$$E3V*pj*P*ZWz8}N=yS>Hy6c8tL z|AKv*^V;QMqp_-_ZL8?FAWt{Je>|)!pwmO8JVr_(@{*T|USYFm)o~oNiMb%OY{Ye6T9ov;*R2E_Jm|(3V;1mO7@g|l9E?x- zi4-%l=Jr+S{+#>lkXer(i5Tc@m}1_w_t)=mW~HoE_e6|$X(f$ch2Z*);?}e)PVY-M zALsk&k5jsnt(%Nrh79ctp-X%?jUt6e33Y4xJugUs zKWwSWa3S7vx2GG&^;=)ksiS;KYL4EJm5z@ii(#tnj&QX`#SCk<&oaTdcfSvd%^@Xaz*Q&ZcT`gE^b zf%llw)o=IJa_7zR@68qe{p0Rf0d)eGiFo*9M8g-H3wAx*1jjaWK~!}P4i**~o1LfY zgM$)HW5VGivX+^^X0wslCrbqCyROULxFmVlJk|Pn=66o~BGHdb+?Lbt8%?ALO(Q<6 zCl~a#rlzJN9%^1GkrmS0KJFb>_Dt(PTuXSfrGGEfDazVz%&iEug2v67v2%K_Yz>@8Xi2&0;u>Tj ztI%=%l6FheJVxnC#)n#6K-Z9X@05@w$y`faLb}?7ox-P0IE=TN;z~VP9aINrv zI2>}*+pQF4AK|u zCF6+b$Mfv4oDG{bC}xpeK0%mf;f6N*{XpO6cZLpjmigWU(<6G?-l`F0-bUtUFB{GJeI#LY9%rg-<*319|=Qb#^%imqZgl3^hu=zdT8YdQ$j=GGdJD2 zl^qv?>v{n>JtR2vbLd+vSE!}P3VL+>zFTIq26JR!F2W3*h!qIB&{tGR@%lKn^N zKyXPqR^5sy)HQSau^nE@wgbBnMRAinJZ`k9Al=1 z8@w0~f1^)%Mw#PAUN#4(RO&tTTRz8mskO4oT7Sj!1fp?{K({>-%dEE>)uN6YTpcIH zaTd$f)HUDizo*K@iRoS-6r)$-!MN z_;`QpyIJk)6qK5YRmea+%~T>*tg%u`la6`4_|v|NhaUHOT2Zhsl7t?=^oMrgmp2x* zRNMfzt$UW7ea4SzL;}biq-^BHQ|lP{l=TOXDm}bG?ja*p+*{>8dF8^hVxA;S3(-xV z#@QX#8pZ#1p0gh4DvxES(~{iI^Z7iDtXlkSKv9sa^@~U=|JgW}pBbT;hZfoQcB9Du zux1|7;<>$Gak^0E_ONbky}33-?Kw`NY6elC3w=RBryFfGMMVD~o)Vlvq+W~F1T2OS z1&OimrenLFau(zM{i6sy>1!@s*CU?{(Y=VCe!&&p>B1w->CUCwX}~EI|3z`l@BWtC zvZ1PDg}8$kg@07<^j%+P(@Gi$=;4#j`RKKvsjoP$(F=NJ zk~AIQeyS{*gZ70mrU^c@qZC=PlvN`?rQY`!dj>|v)H;K9K4 zxpAOtphdFIR~InGAMk?%Lx&b_+!V7rX-XL24t$p@OCHcV3=4*(m2XX)IQF%qe&u&R zLYf+=0$Jz#l#>KL?Jd+B3YrEXns>yp_39=Q75Lx8T(3uerkDr`c8EQnD+((TAotpG zg{jXn4(!8LwEo@k z8#@*c{hkeaZ^`|-qUSb@C96BaXMtIh4mc>wf@!l`PK8D16H^xb z=OH;QZPx^hY}$ip(|G4}f5>QXn-zXUqPx+7@E*S4+-vfM{3Z)0r~1RgL%9SZ?r=`9 zpx0mKpr;}7(!#=iJ4=7_t^z1Gwb-#1em-R%*SK8Ni z$h_Y-1WlYb^s%l@HL=zi_2^Tq2wrxZM=PLfM`mMYK;WpW+6Sm=7YN$@L;mq3+wpQ% z6Y*Qve+6AWSe~Td@h7M9w<1&bW+7UuzxJ#3js)W1td94Wb9hC{T_#f>k<)5^zFxYTRNc&iE*w{zQI>y`&T62DL2OvcywgKLoD?K2e@10FZ zf7Tbb7)oVgF8Yt7sM+CKsrAho1!f$vk1Qi!dELv=VkvO2E0m(2x7YtD&+G079y+>p zaFXxv>(MU(g!v@V_aFW*rxnc*AmbP9O)f})np$my+HLbzB9p)|OuqnYh^;6+6p4nW z2oDSL(3IkI$6~&HI{M}ecM4nd+)A{9>swutC}6jmhO0q|CBjQ@q5uQ_HG*%>B}1#<(t5evzvv_+5UTL zsiU-29p_ZUTCX-)UpK|%XyZnw3|Z}evRo*Y9~4V+Ho7}sRRLd^xkLpjUdpgVFY&)W z9iY7m<3vE7VFoe|?KtPG5ETKYF6}%-1jl%NNnYhc6c)9aZ?)AJ(p0b>_D*Rt0BeRN zb}79%$Yb2h2p~Y8N(FC3p!LBaq_2 zGwZ=UAX%Vhq(Ysw1FR7RsV*vvP-xPVIqddso5b_(cn>u;m8hGrl&lVydbjDDov_Qt zDImDJl7>nHEL6C52@mftiXyl_>(qa-MO9GidB4f(BbdZ*tk+XCidHcVSyZ9*O8c;z{u^;N7NA9Au_#Y%+-$B2QujeN9sb zkGnzmqk-T(^1vt4A-TaP4Ed>W<2F0bZ|WP!Poi`*0##aNX`e6?f1|8jc6~jkotnqg z)(W;f2QM}J*aw&=Ax;V9e+)awe64GQ!qR$(=bT9nLl=-cTdv7UWNaCEAfjq-@?i z_?T%rAww5#VnCnysGyJ+1)w(O%?3WrUX&R8{EbIRZZaQ-8cSQIlt8`0u**|sS@@+y z!}yq*jSf|UKk<$qTOa}wKM}{CD~v)Tre*dkY_~Q;S!7|7Jk0<{SkEG|1=FyCRHk#d zBeLN4noI_urd~uo-fjnTC^I!%7yz8?XJHy7Lmpk~P^VLT2+Q%@Iv@1R4E|6y=;^!PpUER{e!Px;;60Qk@W)LLRQ7oYEaWqN>ECB&Oxu=8t+J%h*x+J{;3>ll%+BilW6rcKij{M(lLUD`rPoeCa>`c`=189Q~4p615_aTkerQSQ5i* z45)Q>|3k>2Ulopx$=HirOcdC2du4EYnW1?cNZE82n6%(0E~pF>2eF1Emb~nXw>N+D zK2ARvT}kO#kpGj*K_k>C_DQV;nS>lP20cjcR%}zv8Q+?rA1!QZ4tt+Wb&{3cLq5|L zRA|P5d=B^WDHXP>qa4jp4!Vyaxe=#bzmXWBC_Wqsh=&I_1Exqo0@u)hMz+-qU-%g( z!f?I&Dhr^5Tu885@XRZ3J70$-=1;HbG7D@-b4d(PVLxUTP!I^qbS?g{`^Ea5K-O2I zMMfVEo#S}%);s!k%?5H!BtyB7Le>73hZFcPF(776zcdmhJjdsdAp7S6Chz@jGtA@= z`723C!wi6{2nvf|G=b0EDVP2Kvf@RX-cgC8(}zyIHo9s%$}A*#qU+! z6~Y*CBKb+@Q+=~E`TwzibPmlAj=;doeay~kN$8_} zf?Pi%b5R0|6f>Wi|C%WcgCY5}Z{T+uCQk9w;Dz_gf4K9l9BNAKP=_**9qDPOJ}A-4 z&31!htO$xEb&3-Dg5+S>n@6e1INZ?Yg4EQQ|5c7ps*u^#druIauPxQNxAE%(*gO&I z`$%)$_rgN4wbQXZbcd$js)#&4ef*csd-Ml%aQ)o_IgsRrwwZo3!d?)W+p~OoTh|#x zAVgO@qCj}yapyo#Jm!xMF~48e8x2{$pxe8$dq}_P0eI#$@s_n-%-u%A^!VOmdp=)_ ztnp0lxF$MV`f&+Dt-}s?Oym}f@K59p&OSpsq?;cQM{!*#Va*_!d~1_z)m4yqbpvnd zbMz60XY^(=4uqgO>{vHE{}+OgEMHF;pOLcHsv?N{cS@o*1U+@3bi8HA09Vp#tb(=uB@s`&=6OaQGgH$dmmO?!M`qRe1SIQkG_F{uxnslE|KxCZ zZd!^&1uz~s+%s{$wLJyG+m;kWtzI+qNAikr4(zxPu@^}F`V#!qG%1+^?B)bl330vS zR?PML=N?9<@5RF?7silxF?$3X1=Rd6a;_Rf`7#DjyjLJ0H{3hK(xm#nml>YdyfKX5nkr%1(7<|7OZj z=cW!{WCXF~%9MJ?+<~O$qh+uKVC#BnhRNt&{;m`rO|px#nZ~&y1^~^;cu3OkJM2sq zdFL#Y8~ku7a*f@G$Nyr|O_4ZrNDbutW-|f52;S4$K7PuxT`VWGzWgNq*|#4vS0qeo z&)z7PV@UTflGq6(GB{B|y=JE*#F=oqC_Vs$xMtV_eBd&t?at673v1MgYxk}P}L5>$mznMIbRjF57FzA9P>+d+DFV|8x=@;<);N^% zQEuJKf$qyDkB5dG^#7Tz!9{XfWc2c7gFuQ!@ng=&yfx>g`rESPrIfxHT&w5U_J%ES=OC(L)}>&WP1%E%w=t7PT)_LQdJ`bQ5~@yL(O^(3OM<4T zF7vLidjaA)_mH6|{&e6Kp zpjF5kQ(A!4eJY2WsoH6vr?g4ainb_4S}}3$1>tcz+Vm5`2R(`N+`~hArQa-@ur=n$ z6?z0mTkL2i3uM3&vwoik4b4(#UW4L3ULO81t^XvUUC)+{F-_gfkblXU?RgNk#FV~+ zk-j2<-#c$YeP~YL?_%5D|dF(VVq56TM_EZ4*%cFU`SN^4we z0)Et=wWMhYJLg%W7TXMvMA}7={+pL2UoO1x0tw*p*`?O0mpbqEhCXKsKrJCJGOVtn zf}YEj{HsSRM=1W3z7pw>z55JO#IC)^E=~hNC(`lzzgj|BrBA;!TE)S8t!uMcRVw8^ zvr;h;Ouafh=!sbLep~Q;D_eS;x(ycxehUOdn%5=rb2`FlhKk}XLS*aDN@T^BggEAB zbM?&MLWPsZ^kziB0X;NkEC0)$$0>okxtR?OhW#9IU{Pmm-T=h~>j71qukk6#d21zb zQ}*`e=EoGkgmyzJeHlg^Ov!tF{d4RWW=7ULUddlW!%izq7SiyIDz@5FQ(Ml}3^d*A zSBM)$v}{(;=8ARIr{89We1L(Yw8V7V$?@#J!FFa|Z*5-@kgRim8!A4-ol;f1FjQN> zAg$W+^i)Ymrb&Kea*O@cq?3!L`lSMTK_6}kI!y{!p@Q`5`P%|+7<~oLLUEE7W-YQD zW<3*AKZU_}hZIPp0p(RL)JTOVCYw)N`_y!WO=enP1J&!i%M6pHyCZbZYBrzc0>I_O z@iaCXl&QE_((1Imq48N4XINR9!*RWt#;B0e7ul~ftFGc3$e}2ju|;dCtwymObzzV> zZ&7mU{PPQj@Lh7F8Nq$1drSA|$ksIim2Y1A>T^^3*3{y86J^CH=$8&l@gUzAGuyVv zB~5i=9k3zC=+`g8upirulM8PBj%9rPQ&T01S*%B8*1Zhke9Y4=p999DhaTMA-4k*L z*5g>3Wvt4A956t))`%vyE;7RI674`_6{&6mg!LLAp^hLdKYv)+Z>Tg^t9;J@*P%P&TcMxYT%F!Rhz?-Ca6+fkd>nE7`0%#K^#S@i(W6nLF3bGvS|HOnJC?Q)duc_pJ!)5VF0;5>CW_b$*@U&apv& z&ag=c^?_}v5WR+8AxsDi(+v&>vZ$$LNsGrFKJwa) z7MSuv=w82cKi7w$){4FFMWr_EHFc$3Rz5{E;3sS`IK>A&D}T0qNjS@Dlkf5T{e?4H z0Sg`b9gbOd*z2Sy$}Jnqgi!7-F4V<{a!>?Q`N^QoF2iEd3Uo3s@l1=>o!b!IrvJRn zpZzuZSe6|ziF}!dKMjSgpF1r5liFxg?vk~2KacZH_X4wm5PAk{huZ_Wqv1Y1v2};$ z=aJQvO8g@8(fDjZ>zQ0aU&q%Wu^ieze<8Tgu#$F{1vq6JGsW%t2QN|Jtg@MTZ7^;_ z56|TG)5_0LXix1H6DC(+*jxQ6ncCG9zh&}9tT<-`IslPrtUd>18hY&mJIq((+az<+ zlAcH!?QqCW)oGc0!YvDugM{>_Ye}Ew(9XiJ%tqr0TN=9lcH(oD@B+QD9;-xU_E6x# z<#GYFbb?DN6=BeswjrWSSM$Z!XH@H#>Kh{LZinMdzRrBwOx^?35OpRK_%@>7iqrx- zAIB(<&q<}M`X!hjY{EsJNWgAZ`+da7Hz*a7OS%@YSv^awBo(m9N*K1Zy0v!f8vQ~- z>YM+v$^fKq?0F!%=N~!by1&(I%i(w0`XTSj!o?^OvI1c(P8t$-d*~3=r%WiS7Y>p2 zq8ZpVqtC|$1(@84jjbk*9gTJ8kQeWZ3i=1vLc6A}yyShFYBOW`o?=DsLJ8C{==8h3DDZB_7LCGwY zHE^b|xSMQEMcW-Qp{PGg2Sqh{U+0*=%#TN%3X42m+f4bqZ z3qS(Yof@&|?QYY$k&XjaTbfbwR~a=8eee3MtX9}+F_p{24}}QbHDa>&%`bCVJU_9@ zgi3}a&Ty2YBJMMkt1Wo6aE9F{2&j2Pgz3=2QM1k@t>%xbMHNGwc?o(9x;+1^k;7EX zL|u_2PQ{!~P{V%n;Y#8dQ*7C+oWwqdxA$G*Bw-8g!JKC@NI*n>XYIa{2x)rGS%&01 z#_a|?Za1j#|4yIBp;Ip1K~}evZTwz**%qi`XXtG`+KWd-gqHZCuNJ2@!bP2|XU-LI zfnhzEQnQ=BLXVL*) zAjWv9prIc{+)={q%1pID+wRE@o|=P^Ff}4EGHXOPBGI}+WVgOiE5v%3lGRpDkxUNd zhT8dOA@k2t2cjVorMT8&*M6woQ{e>oZn|^sgk$1A%toi{EysoP5TI(<>(76WMMQ#~ zHlgw-fe92`&gAazeOnYYN1+(1xee^CQFpWcC}2UJ-+-AaI*4wL2kU6~!w;c|Y)GF< zp7~^dZxI22WNaHB^Vv{ga$=v%;71ujKMjd^;+)YXOxj?Or-}tiKx;KR4f!r01Bg(R zVRD^6cUO?O_~*XHZV9e>ip=-fhGYwcJau3FM;KMFpYS5i2D!vW(dgQpci#!nB)O=* z@2<2~$~4s6F#RTmf`ZlmuSVdb8iobg}pZBx6Gm z#jhlR8{t^%$AH|*CpH=0Q0^iUh#lo*XY7_Me$+}h6Li+UH7i7{L|$JL^Awpy#F(JCbFfBRJ%DKS5u zaf|}9g?jinB8vk(A$nj-B4sun0xcyVgK4lNrmm1tK!zHf|#f z9isQ0`~GR0u=@Yi>#1f8YvRpE=9n6*wnyO{hlBxfmV2bXYMpL%aGoKk@9VQ<^-N9! zD|!JXz&S1MA7}h^uO1PCKt58OTY7CpY9e0|yD#v?Ag_>&q(xd303cr6Jw%$ks=wNB z6c3jJcK{HW1GF|aE7ddMb(xO3U?Api1F&=5>$jH z;hy>ke=+|_D#UB{sX}iXl|;^z1}|8Y55@uH0`L5%ilzeol(;Hu7Me@qN==8acR literal 0 HcmV?d00001 diff --git a/src/server.ts b/src/server.ts index e28eb34..af2c466 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,18 @@ const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024; const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']); const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']); +type RecordingMetadata = { + id?: string; + meetingId?: string; + filename?: string; + originalFilename?: string; + mimetype?: string; + size?: number; + userId?: string; + uploadedAt?: string; + updatedAt?: string; +}; + function safeAvatarExtension(file: any): string { const originalExt = path.extname(file.originalname || '').toLowerCase(); if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { @@ -89,6 +101,89 @@ function isAllowedRecording(file: any): boolean { return ext.length > 0 && isCompatibleMime; } +function readRecordingMetadata(metadataPath: string): RecordingMetadata { + try { + if (!fs.existsSync(metadataPath)) { + return {}; + } + + return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } catch (error) { + log(LogLevel.warn, 'Failed to read recording metadata:', error); + return {}; + } +} + +function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string { + if (metadata.mimetype) { + return metadata.mimetype; + } + + return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; +} + +function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) { + const filePath = path.join(recordingRoot, meetingId, filename); + const metadataPath = path.join(recordingRoot, meetingId, `${filename}.json`); + const stat = fs.statSync(filePath); + const metadata = readRecordingMetadata(metadataPath); + const resolvedMeetingId = metadata.meetingId || meetingId; + const resolvedFilename = metadata.filename || filename; + + return { + id: metadata.id || path.basename(filename, path.extname(filename)), + meetingId: resolvedMeetingId, + filename: resolvedFilename, + originalFilename: metadata.originalFilename || filename, + mimetype: getRecordingMimeType(filename, metadata), + size: stat.size, + userId: metadata.userId || '', + uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(), + updatedAt: metadata.updatedAt || stat.mtime.toISOString(), + modifiedAt: stat.mtime.toISOString(), + downloadUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/download`, + streamUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/stream` + }; +} + +function listRecordings(recordingRoot: string) { + if (!fs.existsSync(recordingRoot)) { + return []; + } + + const recordings = []; + const meetingIds = fs.readdirSync(recordingRoot).filter((name) => { + const fullPath = path.join(recordingRoot, name); + return name !== '.tmp' && fs.statSync(fullPath).isDirectory(); + }); + + meetingIds.forEach((meetingId) => { + const meetingDir = path.join(recordingRoot, meetingId); + fs.readdirSync(meetingDir).forEach((filename) => { + const ext = path.extname(filename).toLowerCase(); + const filePath = path.join(meetingDir, filename); + if (!ALLOWED_RECORDING_EXTENSIONS.has(ext) || !fs.statSync(filePath).isFile()) { + return; + } + + recordings.push(buildRecordingInfo(recordingRoot, meetingId, filename)); + }); + }); + + recordings.sort((a, b) => Date.parse(b.uploadedAt) - Date.parse(a.uploadedAt)); + return recordings; +} + +function removeEmptyDirectory(directory: string): void { + try { + if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) { + fs.rmdirSync(directory); + } + } catch (error) { + log(LogLevel.warn, 'Failed to remove empty recording directory:', error); + } +} + export const createServer = (config: Options): express.Express => { const app: express.Express = express(); resetHandler(config.mode); @@ -110,6 +205,20 @@ export const createServer = (config: Options): express.Express => { })); app.use('/signaling', signaling); + + app.get(['/recordings', '/recordings/'], (_req, res) => { + const recordingsPagePath = path.join(__dirname, '../client/public/recordings/index.html'); + fs.access(recordingsPagePath, (err) => { + if (err) { + log(LogLevel.warn, `Can't find file '${recordingsPagePath}'`); + res.status(404).send(`Can't find file ${recordingsPagePath}`); + return; + } + + res.sendFile(recordingsPagePath); + }); + }); + app.use(express.static(path.join(__dirname, '../client/public'))); app.use('/module', express.static(path.join(__dirname, '../client/src'))); @@ -232,6 +341,21 @@ export const createServer = (config: Options): express.Express => { } }); + app.get('/api/recordings', (_req: express.Request, res: express.Response) => { + try { + const recordings = listRecordings(recordingRoot); + res.json({ + success: true, + recordings, + totalCount: recordings.length, + root: recordingRoot + }); + } catch (error) { + log(LogLevel.error, 'Error listing recordings:', error); + res.status(500).json({ success: false, message: 'Failed to list recordings' }); + } + }); + app.post('/api/recordings', (req: express.Request, res: express.Response) => { recordingUpload.single('recording')(req, res, (error: Error) => { if (error) { @@ -312,6 +436,186 @@ export const createServer = (config: Options): express.Express => { }); }); + app.get('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const filePath = path.join(recordingRoot, meetingId, filename); + if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + try { + res.json({ success: true, recording: buildRecordingInfo(recordingRoot, meetingId, filename) }); + } catch (error) { + log(LogLevel.error, 'Error reading recording:', error); + res.status(500).json({ success: false, message: 'Failed to read recording' }); + } + }); + + app.patch('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const nextMeetingId = sanitizePathSegment(req.body.meetingId, meetingId); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const sourceDir = path.join(recordingRoot, meetingId); + const sourcePath = path.join(sourceDir, filename); + const sourceMetadataPath = path.join(sourceDir, `${filename}.json`); + const targetDir = path.join(recordingRoot, nextMeetingId); + const targetPath = path.join(targetDir, filename); + const targetMetadataPath = path.join(targetDir, `${filename}.json`); + + if (!isPathInside(recordingRoot, sourcePath) || !isPathInside(recordingRoot, targetPath)) { + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + if (!fs.existsSync(sourcePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + if (sourcePath !== targetPath && fs.existsSync(targetPath)) { + res.status(409).json({ success: false, message: 'Recording already exists in target meeting' }); + return; + } + + try { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + if (sourcePath !== targetPath) { + fs.renameSync(sourcePath, targetPath); + if (fs.existsSync(sourceMetadataPath)) { + fs.renameSync(sourceMetadataPath, targetMetadataPath); + } + } + + const metadata = readRecordingMetadata(targetMetadataPath); + const nextMetadata = { + ...metadata, + meetingId: nextMeetingId, + filename, + 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'), + size: fs.statSync(targetPath).size, + uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(), + updatedAt: new Date().toISOString() + }; + + fs.writeFileSync(targetMetadataPath, JSON.stringify(nextMetadata, null, 2)); + if (sourcePath !== targetPath) { + removeEmptyDirectory(sourceDir); + } + + res.json({ success: true, recording: buildRecordingInfo(recordingRoot, nextMeetingId, filename) }); + } catch (error) { + log(LogLevel.error, 'Error updating recording:', error); + res.status(500).json({ success: false, message: 'Failed to update recording' }); + } + }); + + app.delete('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const meetingDir = path.join(recordingRoot, meetingId); + const filePath = path.join(meetingDir, filename); + const metadataPath = path.join(meetingDir, `${filename}.json`); + if (!isPathInside(recordingRoot, filePath)) { + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + if (!fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + try { + fs.unlinkSync(filePath); + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + removeEmptyDirectory(meetingDir); + res.json({ success: true }); + } catch (error) { + log(LogLevel.error, 'Error deleting recording:', error); + res.status(500).json({ success: false, message: 'Failed to delete recording' }); + } + }); + + app.get('/api/recordings/:meetingId/:filename/stream', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const filePath = path.join(recordingRoot, meetingId, filename); + if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + const stat = fs.statSync(filePath); + const range = req.headers.range; + const contentType = ext === '.mp4' ? 'video/mp4' : 'video/webm'; + + if (!range) { + res.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes' + }); + fs.createReadStream(filePath).pipe(res); + return; + } + + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; + + if (Number.isNaN(start) || Number.isNaN(end) || start >= stat.size || end >= stat.size || start > end) { + res.status(416).set('Content-Range', `bytes */${stat.size}`).end(); + return; + } + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': contentType + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + }); + app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => { const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); const filename = sanitizePathSegment(req.params.filename, '');