录制文件
+等待加载
+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 @@ + + + +
+ + +等待加载
+从左侧列表选择文件后,可播放、下载、编辑或删除。
'; + 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 = ` +!6~Bj9MXT0rUZ~_S6qAP&idgSZ>dTZ4cdW0t|*hPSxta8I40ju&-@z6HGXHHZTu7bZcU zu(>W+IZZf-6jMq#2+6{PgIuqiWt+HX7uc4Oq5-9<1*|465D_Lp7`qix<>hi3If%2g zh*+{9!x}`hNJr!78wCvo&H)ah4-1FCEOY0ZZX&8)ov3zgqI&g+YSkjDT$v~_kx27K zWH=BCV;QlrL=`F!Rjo=?r_S}dSF1*plthGZf6+1PYjs}WBH$)Y8xtsr;*@4<5UI2f z2-l(<8_grDS)#fv(16(RLqlm&gu#=LKvc6PQRBu$si~w82Z#iN0z{FVOoTxKVrkNZ z2sQ?h!x0ALLQ_O>u4T#;*0*6pqDGAh38P9CQV7LecT5hLED&D5J`vhR{~3$o3~e}6 z96%ErDqs)^Y)yc*$7Qkx5i*svP7&M?DWL33IEb1i2t W|L?EV0mBNK>aLWNV0MVoF81VWJ8(Ecx$pqXL1XZI3`NdV-{cctU z!ayX@beV B2N|aYPs-AhJS8>G%gRfD3{%pe 0@ z5BWt*5Qu|kfBEu3a&GyA3g$mg`IEX+!SsizVBT-pT(4gN*VVapOko|ZDu77>>lJVn zt2kD0^ew`y`qw>qT8$4*Yqm(2T}JIGM-9m%1(K_9nQ;&mfq2T?TjP03YZV632x1ZB zZi_eyVJsU(1xwS(wSNK8weuD+iChQ2CFgf +}Xv)+Uy*BOn0vX5w)EJf#rl ztyv_W+HQ%M%-qtoXHH=qnOKy`z$!MxG)z2J;hx~=YfnUieJ~3mF%_|>l!#bdZ(jXs zTSlYB(gtaLXfPNp)PEm12E+=h9Ijm?*M`hO!Z^IjDwZi+KC_FQ%U>hs-YG;7J4|Cm z0I+5ia<2HJQcvRg`hpRN1(N~liTawR#rmXKGt(fiKprfXaWJQXNK7 p{&xJ+po;1m|3l&*nzuvMs}W@N0gjwCwf@p z$dzKLVAUU(>n#naT-Tu$N?U<@T4v-gC$Y?s1KdOcu}igHHCuy}8Z8!23I-0MS%YxB zi=SX}i{eb#0kOi9d<^1P`6sfR6*&uE9uSG^+zPU-5%VQ;f8&-F ;^6s?T stf;*beHa7FHFt uCpPc_Ax%SQ0 7i0f;b2iASi z8kz>$0AgH#NIXO};~=a(jr=u;BQ}4yS-oo3sAC2WJ`DRC9%eA8I9&%82V5=&TTHjH z SD`|Z9vm8fa Oz=Aphp1 H<%35c$kKJ(! lUuA)dYZJXQ+W?zX{2^76G9jAV^anW zba>C8D2AVw?*t;r1_G&Gt%je)`;p9xTG4gF!z|}xaiT&!ZHgEp2O-%>a^5f6cwz9# zXH5pHO1uIJ!4$EOpM1h7(gCg*^oxgyONry>U#*%Z1R_(N%c(UCl8m24SSIs{Kt6cx zxmeEWK{lGFMvdUkRGQ579-J=K!_@u@N7TN6+;YKXZThRHwf-WXEE!)5EeZ;mB^O*Y z=FL0c8X(WU8h;Qxa^5-XT)g#-bxMyRwQAKh6|)4%cY912gg~DNBt9v=8@IV;0U(5+ zI< MBO}U4|Q33 9wZiFK7;if1Ow14lae@~$upH1 zD)Q-{iopgBSR4|7ql5^=Ow3|+Z{tr#zNFop3ZQCcNqr;`Vee>(SxLcyo%6urxeQUh zCjSTNDY$w)BWTAio1edu;;LPb7bcgeWPY4Pb0{-kCn5#|w^*a9TgkSdfauaC#Tkk} zh*@RJ8q0P`a+gq)=g!j(fhci59{CLPc#RZ~>hGpXl~uklIP>y|&Rf6aBq{|_-K-j5 ziuhtuJpom)E8Zj|v>+)V)wBTl%--v875>+Ofw@GPisl)dIb#kFBD7M)>w7%zY-Kq@ z2t<+++PINY+c#CJqV|TzottY}j;PwwGR)Ti*Ws1LNnAfoDt4 #wGVR4Jt z+9aynYADNfd;`f>PAPEt)HcNq)}OekDNz~iX#yqhi>xp>Y0h1h@F4wFGMkKr2wsPq z;AxwK2oeSY!Tv{Vh*WzfxLyZ=l^-=fN+LD5m!!G|P4xA&KDd;(>^8nfIE#cAi3~}Z zdpN3uT#;fZRspa`Bh|W3accgq@CM@UStK9dL@u^9VF7xiTLzlvaD+977HcB&1pYxQ zRWfoAl4Zk@C;xIcPi31x$VCPMxyg*8&C3Zs6kG%yW0JGf>`5ZCJqwe#-axLOz91Z% zH;d=EnajBrV-^$^m?Y|v)ELL&w8h~hGv1@O5(~Al?fEe2NGUzFk)fQ}w~vU4RT~f* zpP7ioC6eT44F14D!KNVfA4CInKs1jH*f!e`GC?b<~I7O#SfN{z_TW|#^Re5a%B zt0cLu9}EnB2{v-%S=&*m4@vd!u@Tw-C?F0;mp^MWfU@K#9BjFA000mGNkl {KgxI7lH-4yNUtW*RByAI6!plm}Vx!@pMOr zQ2}IZo>U|^AV-J4kW`@CWjBu=dgzra aJwBPFlzS 6=P0JlA4sa%k%WYkLB8cCYIa}_`6@Z?%_gZvk3!D8XAjX{ zRv(`|>sw|ai0z0m)`=xe&FPL{7Hf+@oH1ogIY@k5d@ykdUS8Z{%DUwE@xqQ}f)R3E zax{5?V(* >0?^GdMRDG za@kIHp5ri~2xo0_R2|HOGL9TAN0HQU5Z9>>NwqtZRJ~21ozj!x_wyou07*@sV50aV z#rB;_F)f%w#xx_b=V0L^1I~O^wPEVH^pJ 6y zH2G6zQJTHXC8p1uL_hD?Rs=D(cJ0|g(`HPf9Xq$1NAL%lFG!*kawK-=Iz2#+%1@G` z`fm&E H9bqvQlM0J$C33$RdH Cx6G7__vr0q&Z1d*zw1T+=IEWo|3V7|b_PZ6dZvARI(Qn8&F(&8!csIR-If#%A z93(zIN+6y_=+R?G=!Y#EJVmh#+y2v5I(&ph9LpFtZh)DHMWTc`h+N T(&c2I4hH5EgIdEn`c>5{_8I;itU3$XU3VSFL2BHEOU- z9{>tftA_uCZ~*9rEvebK6GXgt3JnUj2qgBB(?}o@q}d2f(*%|)Zh7h1<6gR>(_K_Q zr6E -pUt>jwD_D4xs(kDsi a0D}auMIc3G8IZ`kmRA%Mwd}S4281TAQKZ%cmv&Dc zOB(0TpQV!^f&+VL>())QYV}fOz<)6%ho(;bRMFaD)ATfNnxejSYS*JXJNKZwd*82^ z{trA#gP(d{F-aAY$nX=gVQd=W_zCcVK#WM_UA;nQ&*mzu4=nJ~rHg)|apNSraN)eB zvKKFE{hGj1;K7;zrvdgK*rN>o)oYf~{DrgV%V`sp+5d~FIW&FdWLmXmDedOHo@+d* zR;gT-TCynF@4f-_#N*FW-#!mg_q*< {hN6D0p*YX{P}aVpE&?d04!a$kY>;Mia!716Q!?l8eq+u<#gcCJ_3iV zQl%Q*+M+ex-RpiD@X!;=%-_G?Bh;gNA8OO41L2C=>OA-p5`5p^%gZxLEe?m!HYx@0 zXPbk_Bn)~-aE<$U7Jv-h=k5m-85{Pag9g)s{U4>4EpMlrZmL3(B+ ojyTvadA|yUMkP{cTwMaAEL(|8A^{mI+X5X_g#1P zqSmZnfRdtm^_odp@oa*(pS?zS45(j zv!>EAZuforb`#ng6I`c`chQ5q4tV_0XB4ggQK&`pR)qeo%o898CLD;L$vtIO%>JQo zzi&Md%~JRPvrQmQNo>^Td)gdfeKdnVDJhxi)JY*UGSr~pKp>hy1D`Hp$AjqhHXW%Z z? 07(exQ#5JWd{9 N+!S1@K|3b9Qf&%BknopaE {cwMLH!9<{y4L7 zbYO>eovAJpm_takd-u 3b(S0MZJ^NC-+dEO?#?7ck^;%ReAu&|N2b}L2GZ9Os z$RH4 Y@2x62mLj$PZ8#i#{}2JmXu?0{h10a(N~ZF(y& zWUt?Cb(uH7=6o|<(F(fbd9$%_c8jKPdy5ZI2yK_B+pP4KE>gmCO$bCt^14vsXPbj4 zNXTTk8QQJ8rV{>@=T29|wid0Z6YpL_Y Y>=DZ0IFCuJ8KD#GnQ|Hx{{uW8Dc zpDM-|v{$bFmJYH=w|4Cc!dhuDZ)pAW^H$QUZryp}z#_!KUCia&Fbq=Y_~CEGi&!9Xzz3U?Y!Tt2x684oEDz z`duq4Cqxc`@N&SY$vSzAWS2{acjS2S-bz*PvAap+KZ~jI}NUAAV9 1{vTJfeae`{zX|9)ATr)A+L|~Yw+V7)gutuSi@# MDgzwZDlU%rCJ_y}xrOABSM0^%q3EMPxy|3GEI z-m6DnYLME9psjMrE;@bcByHRF1L6GB)UQ4xXuhzPQ}(SkuctkGid65xcug3{c`)F@ z$Br2}2#cU|JR}QBkWU1H5(PPp1ajyALB2 T1)zARpR4%k@IFSo+t~-lUAgB)QyHGs7jOC>; zY^?R@b`L!|Xow<;;ou_ja+|ii3w;-F#6b&%;~CAHwPeCb@{+5)U|a&& d? zM?hqNsPGl*_dommV}h8lY{epioPOlU0m6B) JJF1qOE0CM^g`1H|z)e-KmMIw^{l4tcpvGv2Gf4z_v?<7?jj#IIDT zGH>dpDigsS9lI)R2hKBy)x_8{fw~xqk<|JPDW0{6n55AD3l_~*HXpy7Hc{D;hc*v{ z1u+z&qg`AM9X5(ryy^`(_W*uA69^Isav8ZukdI41V*?`C$#O8x|6q24ybL1vvZ%qg z@ho=sY%V1ySEA<4TPjCg9^uW#Cm;U}VV3Q4Pk*|rYj0}HB9VKLVX ?U_(nVUoajn92(4R|}FJuke_k>F; zuy(`ly&4-FZ_i~7mKp}PEGTjb#F_5`foOK&z(IW76TxA}BdoUkiB+%P^G-Pyh%;t> zq0BmSznP&dCQq=s6BlIHPiaUUJKRb4-#36B88ldtn;`;V;oFu)kH$AQRaCiU%9KU> zMNI^?K!pm)1WtPki*W63zk~Oz9#r}dN)K>KAtXA+8@uaRJX3c1Cw)Tb|COu0W#Tbv zab=?U&lGN4hrE`4agb;J`Til2< eGE)d)wQBkD#+cLk%i&n*D1lgiq= zmbbN4loVK3J@MGH^bji^+O%p *OI2h|^000mGNkl W z2qSCCMSkW*cTlm6W_Clhfb4Q98<^T^D2FR-4URHqeC&Aw9CM zE-vaWr7eIlg0W&iUzXn*`gZ0D13XNCpb-f0-B+gK>o{czx7$EfXDU;=BSh_J3vdfy zj9{#=v-lB2n-UUSe=7!*fFq~`^5&aw7RXNdb*{%%HpLIF?ZHI~#ek?I+5)N;z}UbT zQGpbvOUQ2x8vVZJ%%2hv#=oEv2=E^A_V`sY2@naq#RlK#GkfW!I6~BML$(0M1m3|n zR$k5;I%fP-BRss2pc4qtADS`lpUfUVfm?09T{d62j7$xSx^LVTz|r<~8&;ExHwM+M zz+-29UP1gwHLRrE{t<``YzJp#KFbwdt&WOI1C}fa+d)iq(NT8 M-#J9()?uQ+aO=h{*u0${<(6UCtl=V{Ovc&bv;|QZlpbXcPW>b?b*AN zu+zGF%~HZQMyAi4Ot`=Sx0v9nnZt( kop#EXG+! zA0Ju(Z}EXkV*;8yIfuX jFH3xPlo9N zhU>-JcZH2Waz+mCmYtr4jh{YfBN+Q=+K-8MQ9v-5#UKJi3B`Nc6%^zvUzD0Xce=u* zan%kui1(Df#lWP CJrDPasrv~>PzjJFmhxeFW~|QunF4(5g (4uWBoGGf9=mkuf``Z<2y)38JT?|(jZAwe1i1$#%a9Ssl+iCFW~UGTC-0K3WlW!D zW9x~Di6z`Kiz|NW)=8npjha!*TiYlfe#EUOxGDzUy~B48ZDq)j+=Q}j!y3b`EeGF_ z57un6=T4*VzF$S>&e{KvC~h3V1#q=$)g^r32p7fS%2He!SFLJw?z<#HANtU!Jy`7e ziV0 HOp?N)gw zB_$K?vwfi7AcDc*Pffj Iz1uxX$KBhfKQ(F6g5u)h&7vzX+zUH@ z!ECw_FP6YpuI4YCrK~rU#<2JUQFP+L4`Kje;PPz1ETWr&@j)Y81lZ+{ZuH;-j}Y$i zMW3VZO%oIXE3@@}PWteP*{`HEV0Vc>K_iflN50f}LSF6?l9l7`rWHdYqv8AAH;{0N zG`NRpL_dL5Dp#X+Z97rF`v)ilXw@0mKlyImD#G51pGB3rWZ0Ai_dId(xFzlolW@B{ zh~nmkw~!sKY?+xZ0QV=O&(Zf70~iasD8*ixB3 zY*uLCL}Hd}$-_Jm2i6Gq7!MZNC5b7<2F3`+iWMFuNhARgh`3t*Q{D`}$E;$gG#1ci zVI>8-jn&1#%dGrg8 ;<1uTCl7L~XSoIC?0g-%?KKuc=!)g2`5Z>JR zJtI1Rjp0E>J+Vzgf-nMr7w|JH5=657r>$l|r3!|3ZhqXh*%W6GN7t^s2)1d25(Ikc z8=xi0a{82k108UO%lJhgS+Bg%k~eqWFhdf94;3{Fixe2}1D rkd82}QcqICPC-Bwd zhS!2OZw>jkA@#)u&5ut895}GokY8*}EOqJJBO*D69$EAazCNXg!!HX;&w1^oTj36e z@tHu1L@T^XtHJM<#?6$^2y1{5?rFfgyue$J=D~yeC}Kn{J#rrWxUzFAa}Tp9x8Bx< zDpk74EGQyj4I4D3)Rcxs++?aiPC0B`#3uro@bb&$ShRXck9FnBRe58sy&haD{H 7H;Q)a*$`0I p@}l<3#G^E0+T< z)2tp?i(t_naOPpiV8^bXOiMAVmubWZ1;)h05Qy9iy@G;*=gr{3gxMw#A (_Z$Jz6LA?wCMF3wc zT*$C{_s_m030mBGDdq=zg%H0DS|Ju0*(MMo`8ItXDfLqIps@P)@7tx6hts(yPn}fU zf&7`($4qPqq>NCcwc58atB*hBFJG~ka3RC0HOtJu9q3P{KBU1>p&G1cFiBD~=hYWO zs+#kGK&Yl3!Q|vhdXQH?URs(oIa|5H$7^H{5tzNwx%1b*JA%cwhv>F~63bUG#5;5L zv;h`rfGZXvra%2-RySPt `)n71aj%>Sp${gWg48s^aZi=JmcqGy;__^mi^ynxO7ST*@(nM z o0%$#|k<1I~!_lVin&L9B*{G!}sjj;dXkp6U&*O z2 QrURlvR8Ea>vJl zQF>2$?%Y}0y>}?8R|d{zz@%P9?a&+c%Cv3D2C$FwG$_g(Qcj Hf`;b6HGiFXE5E8U+xTz4tg|IKCWYcsOJwR|vmn~2(R)9Ei?D#L_k64$Rdy0JR zpW@2s9QySgxS1}BZEsT2bx}A!|C;UFFfUs5jZEil7w$(yTLj{~Mn}!^K5^oh8Q2nb zjb}-BV8An+_c7}(%C@a0;NnDrE|N^YArc=G(Ka@IJ>U``qI2iZ5(o+VHxO5XAf91S zu*VE3U;D=_55R=b4ii@ZqH$uPcGK_5WqW&A=UJ5vT=Yf==P!C#F#Xz^TjV_Sop-=N z4Ezu>u|*P7uzJ>aNP!*ekB={-DMP)s(FFM5xuD{3?qoc$w+$IP{^KDT<9;(FBeQwF zOm&2iA7!E$&+Pp>HmBTiLvM= $e;)SHF}X# zv0|9}D-J*YaH?3H3?iG_Z{@zg>sQk-mvqDw9weYG0x2v@94fT0|L-f>hqw;$>{1g& zpzJ?hCzDGCgEuU$Lqbh)l^dXG uUnUaciL- zZsm_JbA8s=YoV;tSizHYdoX8w)T`_BMLkd-J)8$T`m`6ryRqrQrXw31yTK(DcrT&p z(cIkH-)`C}O &au@zt=yW_>aF& z`TG5j<}Ui=y_w&9_}0X)#*O@N!q{ite(%q}`TP4Bzx&q*@x3b=000XuNkl { zQU&chR_cb=$Mxn xK{?pE%~IoE;S&M=UDLRM9F!kZ zMBE!(xZJQIF|xw&+lbW{9A2Dez5FKXgA)_#hr&jZKYr(ohFId1n%;JKS=Ss*?Nj9T z;b-IlA JhZand>e?Gj(ovfYy|vzbaR|C8ECG$SUvWzP79M-2Ma8 zbSe$$7ykLJ(_&uL3kwTwe;0;^gTk5$Sym1oJ^DNQ>?tlIZYdmx8pg(}b1#B! +p#HheIwSl7@(&_I zb0RhcqKy$(+R}8B<7=TX5Sh)BlrPT`UDjBcW(6KMUdb+ARe89zqCW7> imsI1;(~3?Q0Fnbtp&< zOCLG~JpaHv0>;9UeWc!CpV$+N!s+YHxy&>9W}^9I7y8HkhoHB}c2P{EeujR-;RjOQ z0{mO8n`kE4&3S8js$hKO%@g+1)>OvQ&bRe@Trqn;bs)AYDU`2$n&vKoCkAj!w_f zL7TETkQsjKmzN18I08sjr?PT?Q{j#c=@C&;TEDlJ4n`dDNwDxLphjB;!|GRkTgAO9 z3DrscwO%(U4$Xv>h)&y#cQOx`Jn~$LkGIZ}&5?JQ{$mtB+3Ab$(jmQCZEs8B>%PB7 zDjbcEsppJztaiR4)sa*O&+j@6=HkM`n<9_#nsCbqbiM`+fekuoKmT M&^aTS zI1L7IvD*Xzp;$Dh1 (=W~c9Q!=kco5}9+3~Es)>xXc|*3Sx{$jHT2u$bfc2X$ z0EiE*@O#*$lK+e99FT?fN;nw*J_i)7?)DJ*^4^>9y`2?v;ng7J*rL_k6NKY>1@S>rP8GcsO#-eFIW}cR7V(2~TwY>*TZa6&? z;1JFUSl0IT=+Z%JOYcmz);hF68Rk9?0@}Ba%N{>*TrF?c#9X-pHt|yHflz&ERd$cN zob11zdq-9LewmKY?16ep>yvmGPM{axJlG2c3ze?#2d`DomV*iF=d0z|S1o~jcndbR z!T;LQ0e^sd#mY#f(p|e#iZ`qcl4g^ki)w}U$NUS1E5_=oPu#slmTRq3i95;z_a*s( zUMCRA5g7Z*ANgF)!uM)t_F{ElmS2JEP*xTS3|SHgx~+(F#b{LNb}0q=56qD?Cdfb1 z{f=o!PZrMz1tkbByjAT*)1JI+vNd>-5a=QzvL S`X6w!FyOAf;ck1MaaS_z3?M)$t@!}0*JXHj zskEYZ#IxCf^ @R& -n>z;VWM* z_2pF%XN-&`H_o!*9%MqO{KV9^s0u^QMR03ICE#`RYA )g_{2 {Mpf3G&$Z0^rlFc2JW)&1xzE0rxJGyN zdBM{^B(ycQB_(B~|L`0tAyxn_9=T6WCNH)Z?alSz0p6W#Vdp*!fcd(gw!@h_Rp8#B zrau$k+!T1gM>I-W266~1`BtV##cgaB0`o*JzSgo>ap!`N<8&`V)Hy~-P3tav9WC~# z>|>g%=jcVti56sX@?JM`IP3yFrb%W8j+;yIl~*eG{}RQAld(rFr>CX0177=eXVFM= zN9W!vIB zv@sthoJ)%Op#8Bk3yeZ2Si_Fl!*s>?wwM4W-s|i z&QqahL$h6(tV=8DfT91flJW(ml!Y53&N`l5VD}-o-3;fMxM<;s1@7u}c^iU|y? b ztU3U |0H41}h{FA-c21;8hFOSF#@`cFee}!NzRKJ{C5>cRc1 ^V<`P8W>HD{r$o6w?%^7op* z5QU&tKz7~#Y>r$}tqOOAgTtQZbJ&v_K v#^ooN-{cOn7hhcFz#xq_gNfY z;c9N jG40KT^)^%%eGC5kzZ>`eVq**f8cTC!fkmRVpu}rK_aL#2V z7#CwyB>3W=^_s56)jX<|ij$11l}d=2{B*`zL})ICnSb|(#fL*h0Gl=__>4i7qx@z0 z)bEDZeqa8N(A$eob()$Y1=~3TKKZnJ<&{dz$x+^;D%DnA2>tI6ym?J~9A_%JN8-KN l;&MCG32wYW;NRlRf1r?4EQG>+6y+rc%r9AD-Wj<>{ttK<{Ko(Q 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 zm mUVl8kix20Y^#*^C6XCYufYB (3*vJ8X{KD~1|m6{ z6a;2W03PWzS6l(N8Y;H`V9e1Q4BG3t9M)9Tnhz6OfRobHeT1bIxLX_F9hn?AT2mhW zY%2bH$>xY39%*fg+$n`B5vqI-0b~p`@g J%FQJSlh*CHbY_GSw|<=5ev6 zrvKf6f!ltC3v HpD5r}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 zQF o|Di@O42g?Zo_7 z8gVd gLkP4v%5c=)XjtFpD~i z17638r&j9*a*qGZV6xoq{F-U(LNHGX|5n~M@~}V2M@@xl)+F2bZ^iTP7DM0rOU>I2 z3gXbFG8wIlT*ycq{-lcNKWbv$X5DpDTKQDzPgqTJR?P~brG_KL_^*3W PY-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|{4BA gmBb+o;$B+5&V%r7uk^B zobIkrrsGU;AuRKHyI&yJ#d0MYHZ~Om3^HbTczDaXHhY~Y{>TX!Iq20ti)djaHE h4UQJ*EvDf@FV2_x&_ zkFanU^PI XwX)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~oNi Mb%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&k5js nt(%Nrh79ctp-X%?jUt6e33Y4xJugUs zKWwSWa3S7vx2GG&^;=)ksiS;KYL4EJm5z@ii(#tnj&QX`#SCk<&oaTdcfSvd+-G z$IA}u7Ev;ecfz1p%b@YK(@w6n%S91C$6e0%g~|b^1@n*EPvOp}*fz-OMYv$zuU)ZH z1OhQ^eOl*?=sN)n!v~+K+9nCN99KZ&H*6TZ1S5_{wx6Bt3!P*`FEU0IyfrjSBjv(M zdM22ZS#R-c6L5&G3hmmY&n_-eQVqQR?kV%^@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%&iE ug2v67v2%K_Yz>@8Xi2&0;u>Tj ztI%=%l6FheJVxnC#)n#6K-Z9X@05@w$y`faLb}?7ox-P0IE=TN;z~ VP9aINrv zI2>}*+pQF4 AK|u zCF6+b$Mfv4oDG{bC}xpeK0%mf;f6N*{XpO6cZLpjmigWU(<6G?-l`F0-bUtUF B{GJeI#LY9%rg-<*319|=Qb#^%imqZgl3^hu=zdT8YdQ$j=GGdJD2 z l^qv?>v{n>JtR2vbLd+vSE!}P3VL+>zFTIq26JR!F2W3*h!qIB&{tGR@%lKn^N zKyXPqR^5sy)HQSau^nE@wgbBnMRAinJ Z`k9Al=1 z8@w0~f1^)%Mw#PAUN#4(RO&tTTRz8mskO4oT7Sj!1fp?{K({>-%dEE>)uN6YTpcIH zaTd$f)HUDizo*K@iR oS-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`RKKv}g3x}J}|B?!0sjoP$(F=NJ zk~AIQeyS{*gZ70mrU^c@qZC=PlvN`?rQY`!dj>|v)H;K9K 4 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(FC3 p!LBaq_2 zGwZ=UAX%Vhq(Ysw1FR7RsV*vvP-xPVIqddso5b_(cn>u;m8hGrl&lVydbjDDov_Qt zDImDJl7>nHEL6C52@mftiXyl_>(qa-MO9GidB4f(BbdZ*tk+XC idHcVSyZ9*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;V 9e+)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)LLRQ7o YEaWqN>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{2nv f|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+@3bi8HA 09Vp#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>Y dyfKX5nkr%1(7<|7O Zj 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?at673v1MgY xk}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 z F7vLidjaA)_ 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+8 AxsDi(+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+f4b qZ 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`ZlmuSVdb8io bg}pZBx6Gm z#j hlR8{t^%$AH|*CpH=0Q0^iUh#l o*XY7_Me$+}h6Li+UH7i7{L|$JL^Awpy#F(JCbFfBRJ%DKS5u zaf|}9g?jinB8vk(A$nj-B 4sun0xcyVgK4lNrmm1tK!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{H W1GF|aE7ddMb(xO3U?Api1F&=5>$jH z;hy>ke=+|_D#UB{sX}iXl|;^z1}|8Y55@uH0`L5%ilzeol(;Hu7Me@qN==8ac R 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, '');