feat: POST+JWT SSE + permission visibility
Backend:
- SSE endpoints changed from GET to POST for JWT auth compliance
- Chat subscription moved to /api/room/{room}/chat/subscribe (avoids route conflict)
- handleAuthCheck now returns config:write permission (was missing from response)
Frontend:
- EventSource replaced with fetch POST + Authorization Bearer header
- Token persisted in localStorage, cleared on logout
- postSSE helper with exponential backoff reconnection
- rooms.tsx: SSE/loading gated on can('room:list'), not just isAuthed
- watch.tsx: room events SSE gated on can('room:watch'), chat SSE on can('room:chat')
- watch.tsx: subscribe button disabled when lacking room:subscribe
- permissions.ts: added isPublisher() helper
This commit is contained in:
parent
03c3d4d5bd
commit
c7f70970ac
|
|
@ -374,6 +374,7 @@ func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
{"user:manage", "user", "manage"},
|
{"user:manage", "user", "manage"},
|
||||||
{"system:manage", "system", "manage"},
|
{"system:manage", "system", "manage"},
|
||||||
{"config:read", "config", "read"},
|
{"config:read", "config", "read"},
|
||||||
|
{"config:write", "config", "write"},
|
||||||
{"srs:streams", "srs", "streams"},
|
{"srs:streams", "srs", "streams"},
|
||||||
} {
|
} {
|
||||||
ok, _ := s.auth.Check(claims.Username, claims.Role, c.obj, c.act)
|
ok, _ := s.auth.Check(claims.Username, claims.Role, c.obj, c.act)
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
|
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
|
||||||
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
|
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
|
||||||
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
|
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
|
||||||
mux.Handle("GET /api/rooms/events", s.authWrap(http.HandlerFunc(s.handleRoomsEvents), "room", "list", true))
|
mux.Handle("POST /api/rooms/events", s.authWrap(http.HandlerFunc(s.handleRoomsEvents), "room", "list", true))
|
||||||
|
|
||||||
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
|
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
|
||||||
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
|
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
|
||||||
|
|
@ -204,13 +204,13 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("POST /api/subscribe", s.authWrap(http.HandlerFunc(s.handleSubscribe), "room", "subscribe", true))
|
mux.Handle("POST /api/subscribe", s.authWrap(http.HandlerFunc(s.handleSubscribe), "room", "subscribe", true))
|
||||||
mux.Handle("POST /api/stop", s.authWrap(http.HandlerFunc(s.handleStop), "room", "stop", true))
|
mux.Handle("POST /api/stop", s.authWrap(http.HandlerFunc(s.handleStop), "room", "stop", true))
|
||||||
mux.Handle("GET /api/srs/streams", s.authWrap(http.HandlerFunc(s.handleSRSStreams), "srs", "streams", true))
|
mux.Handle("GET /api/srs/streams", s.authWrap(http.HandlerFunc(s.handleSRSStreams), "srs", "streams", true))
|
||||||
mux.Handle("GET /api/room/{room}/events", s.authWrap(http.HandlerFunc(s.handleRoomEvents), "room", "watch", true))
|
mux.Handle("POST /api/room/{room}/events", s.authWrap(http.HandlerFunc(s.handleRoomEvents), "room", "watch", true))
|
||||||
mux.Handle("GET /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
mux.Handle("GET /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
||||||
mux.Handle("POST /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
mux.Handle("POST /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
||||||
mux.Handle("DELETE /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
mux.Handle("DELETE /api/room/{room}/playlist", s.authWrap(http.HandlerFunc(s.handleRoomPlaylist), "room", "watch", true))
|
||||||
mux.Handle("GET /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
|
mux.Handle("GET /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
|
||||||
mux.Handle("POST /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
|
mux.Handle("POST /api/room/{room}/playback", s.authWrap(http.HandlerFunc(s.handleRoomPlayback), "room", "watch", true))
|
||||||
mux.Handle("GET /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChat), "room", "chat", false))
|
mux.Handle("POST /api/room/{room}/chat/subscribe", s.authWrap(http.HandlerFunc(s.handleRoomChat), "room", "chat", false))
|
||||||
// 发送与 SSE 订阅一致:guest 亦可发弹幕(RBAC 已授权 room:chat),needAuth=false
|
// 发送与 SSE 订阅一致:guest 亦可发弹幕(RBAC 已授权 room:chat),needAuth=false
|
||||||
mux.Handle("POST /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChatPost), "room", "chat", false))
|
mux.Handle("POST /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChatPost), "room", "chat", false))
|
||||||
// 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host
|
// 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio-tower`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio-tower`}));export{i as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
function e(){try{return localStorage.getItem(`sync_live_token`)||``}catch{return``}}function t(){let t=e();return t?{Authorization:`Bearer ${t}`}:{}}function n(e){let t=[],n=e;for(;;){let e=n.indexOf(`
|
||||||
|
|
||||||
|
`);if(e===-1)break;let r=n.slice(0,e);if(n=n.slice(e+2),!r.trim()||r.startsWith(`:`))continue;let i=`message`,a=``;for(let e of r.split(`
|
||||||
|
`))e.startsWith(`event:`)?i=e.slice(6).trim():e.startsWith(`data:`)&&(a+=e.slice(5).trim());a&&t.push({event:i,data:a})}return{events:t,rest:n}}async function r(e,r,i,a){i?.(!1);let o=0;for(;!a?.aborted;){try{let s=await fetch(e,{method:`POST`,headers:{Accept:`text/event-stream`,...t()},credentials:`same-origin`,signal:a});if(!s.ok){let e=await s.text().catch(()=>``);throw Error(e||`SSE POST ${s.status}`)}if(!s.body)throw Error(`no body`);i?.(!0),o=0;let c=s.body.getReader(),l=new TextDecoder,u=``;for(;!a?.aborted;){let{value:e,done:t}=await c.read();if(t)break;u+=l.decode(e,{stream:!0});let{events:i,rest:a}=n(u);u=a;for(let e of i)r(e.event,e.data)}i?.(!1)}catch{if(a?.aborted)break;i?.(!1),o+=1;let e=Math.min(15e3,500*2**Math.min(o,5));await new Promise(t=>setTimeout(t,e));continue}if(a?.aborted)break;await new Promise(e=>setTimeout(e,800))}}function i(e,t){let n=new AbortController;return r(`/api/room/${encodeURIComponent(e)}/events`,(e,n)=>{if(e===`room`)try{t(JSON.parse(n))}catch{}},void 0,n.signal),()=>n.abort()}function a(e,t){let n=new AbortController;return r(`/api/rooms/events`,(t,n)=>{if(t===`rooms`)try{e(JSON.parse(n))}catch{}},t,n.signal),()=>n.abort()}function o(e,t,n){let i=new AbortController;return r(`/api/room/${encodeURIComponent(e)}/chat/subscribe`,(e,n)=>{if(e===`chat`)try{t(JSON.parse(n))}catch{}},n,i.signal),()=>i.abort()}export{i as n,a as r,o as t};
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
function e(e,t){let n=new EventSource(`/api/room/${encodeURIComponent(e)}/events`,{withCredentials:!0});return n.addEventListener(`room`,e=>{try{t(JSON.parse(e.data))}catch{}}),()=>n.close()}function t(e,t){let n=new EventSource(`/api/rooms/events`,{withCredentials:!0});return n.addEventListener(`rooms`,t=>{try{e(JSON.parse(t.data))}catch{}}),n.onopen=()=>t?.(!0),n.onerror=()=>t?.(!1),n.readyState===EventSource.OPEN&&t?.(!0),()=>n.close()}export{t as n,e as t};
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};
|
import{V as e,Y as t,b as n}from"./ui-CTx2ZrLp.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/static/assets/index-BdWedm3z.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-LUhOsKYd.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-IRgYFSdb.js">
|
<link rel="modulepreload" crossorigin href="/static/assets/ui-CTx2ZrLp.js">
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DwOmVRa5.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-DwOmVRa5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,12 @@ export const Rooms: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (permissions.isAuthed()) load()
|
if (permissions.can('room:list')) load()
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
page(); search(); permissions.isAuthed() && load()
|
page(); search(); permissions.can('room:list') && load()
|
||||||
})
|
})
|
||||||
onMount(() => permissions.isAuthed() && load())
|
onMount(() => permissions.can('room:list') && load())
|
||||||
let unsubRooms: (() => void) | null = null
|
let unsubRooms: (() => void) | null = null
|
||||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
|
|
@ -132,7 +132,7 @@ export const Rooms: Component = () => {
|
||||||
}
|
}
|
||||||
// attach when authed, detach when not
|
// attach when authed, detach when not
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (permissions.isAuthed()) attach()
|
if (permissions.can('room:list')) attach()
|
||||||
else detach()
|
else detach()
|
||||||
})
|
})
|
||||||
attach()
|
attach()
|
||||||
|
|
@ -195,6 +195,7 @@ export const Rooms: Component = () => {
|
||||||
<Alert tone="error">{err()}</Alert>
|
<Alert tone="error">{err()}</Alert>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={permissions.can('room:list')}>
|
||||||
<Reveal delay={1}>
|
<Reveal delay={1}>
|
||||||
<div class="relative max-w-3xl border border-line bg-card/60 p-3 sm:p-4">
|
<div class="relative max-w-3xl border border-line bg-card/60 p-3 sm:p-4">
|
||||||
<Corners />
|
<Corners />
|
||||||
|
|
@ -240,6 +241,7 @@ export const Rooms: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex flex-1 items-center gap-2">
|
<div class="flex flex-1 items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,33 @@ function RoomHub(props: { room: string }) {
|
||||||
let pc: RTCPeerConnection | undefined
|
let pc: RTCPeerConnection | undefined
|
||||||
let stopHls: (() => void) | undefined
|
let stopHls: (() => void) | undefined
|
||||||
|
|
||||||
onMount(() => {
|
let unsubRoom: (() => void) | undefined
|
||||||
const unsubRoom = subscribeRoom(room(), (ev) => {
|
let unsubChat: (() => void) | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// room:watch 订阅房间事件(SSE 需要 room:watch 权限)
|
||||||
|
if (permissions.can('room:watch') && !unsubRoom) {
|
||||||
|
unsubRoom = subscribeRoom(room(), (ev) => {
|
||||||
setTargets(ev.targets || [])
|
setTargets(ev.targets || [])
|
||||||
setDists(ev.distributions || [])
|
setDists(ev.distributions || [])
|
||||||
})
|
})
|
||||||
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
|
}
|
||||||
onCleanup(() => {
|
if (!permissions.can('room:watch') && unsubRoom) {
|
||||||
unsubRoom()
|
unsubRoom()
|
||||||
|
unsubRoom = undefined
|
||||||
|
}
|
||||||
|
// room:chat 订阅弹幕(guest 也有 room:chat 权限)
|
||||||
|
if (permissions.can('room:chat') && !unsubChat) {
|
||||||
|
unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
|
||||||
|
}
|
||||||
|
if (!permissions.can('room:chat') && unsubChat) {
|
||||||
unsubChat()
|
unsubChat()
|
||||||
|
unsubChat = undefined
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
unsubRoom?.()
|
||||||
|
unsubChat?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
function attach(p: RTCPeerConnection) {
|
function attach(p: RTCPeerConnection) {
|
||||||
|
|
@ -190,7 +207,7 @@ function RoomHub(props: { room: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<Button class="h-10" onClick={start} disabled={running()}>
|
<Button class="h-10" onClick={start} disabled={running() || !permissions.can('room:subscribe')}>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
开始观看
|
开始观看
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
|
export function persistToken(token: string) { try { localStorage.setItem('sync_live_token', token) } catch {} }
|
||||||
|
export function clearToken() { try { localStorage.removeItem('sync_live_token') } catch {} }
|
||||||
|
export function currentToken(): string { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } }
|
||||||
|
|
||||||
const BASE = ''
|
const BASE = ''
|
||||||
|
|
||||||
let refreshing: Promise<void> | null = null
|
let refreshing: Promise<void> | null = null
|
||||||
|
|
@ -30,7 +34,9 @@ async function rawReq<T>(method: string, path: string, body?: unknown): Promise<
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
const ct = res.headers.get('content-type') || ''
|
const ct = res.headers.get('content-type') || ''
|
||||||
if (ct.includes('application/json')) {
|
if (ct.includes('application/json')) {
|
||||||
return (await res.json()) as T
|
const j = (await res.json()) as any
|
||||||
|
if (j && typeof j.token === 'string' && j.token) persistToken(j.token)
|
||||||
|
return j as T
|
||||||
}
|
}
|
||||||
return (await res.json().catch(() => undefined)) as T
|
return (await res.json().catch(() => undefined)) as T
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +94,7 @@ export const api = {
|
||||||
password,
|
password,
|
||||||
role,
|
role,
|
||||||
}),
|
}),
|
||||||
logout: () => req<{ status: string }>('POST', '/api/auth/logout'),
|
logout: async () => { try { const r = await req<{ status: string }>('POST', '/api/auth/logout'); return r } finally { clearToken() } },
|
||||||
me: () =>
|
me: () =>
|
||||||
req<{ username: string; role: Role; status?: string; expires_at: number; issued_at: number }>('GET', '/api/auth/me'),
|
req<{ username: string; role: Role; status?: string; expires_at: number; issued_at: number }>('GET', '/api/auth/me'),
|
||||||
srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'),
|
srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Role } from './types'
|
import type { Role } from './types'
|
||||||
|
import { subscribeChatSSE } from './sse'
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -11,35 +12,29 @@ export interface ChatMessage {
|
||||||
ts: number
|
ts: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅房间弹幕(SSE,事件名 chat)
|
// 订阅房间弹幕(POST SSE + JWT,满足前端SSE必须是POST+JWT的要求)
|
||||||
export function subscribeChat(room: string, onMsg: (m: ChatMessage) => void): () => void {
|
export function subscribeChat(room: string, onMsg: (m: ChatMessage) => void): () => void {
|
||||||
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/chat`, { withCredentials: true })
|
return subscribeChatSSE(room, onMsg as any)
|
||||||
es.addEventListener('chat', (e) => {
|
|
||||||
try {
|
|
||||||
onMsg(JSON.parse((e as MessageEvent).data) as ChatMessage)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => es.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主播独立外部推送:以主播身份推送弹幕(需 room:publish 权限,可带 Bearer token 供外部工具调用)
|
// 主播独立外部推送:以主播身份推送弹幕(需 room:publish 权限,可带 Bearer token 供外部工具调用)
|
||||||
export async function broadcastChat(room: string, message: string, color?: string): Promise<void> {
|
export async function broadcastChat(room: string, message: string, color?: string): Promise<void> {
|
||||||
|
const t = (() => { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } })()
|
||||||
await fetch(`/api/room/${encodeURIComponent(room)}/broadcast`, {
|
await fetch(`/api/room/${encodeURIComponent(room)}/broadcast`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
|
||||||
body: JSON.stringify({ message, color }),
|
body: JSON.stringify({ message, color }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送一条弹幕(需登录)
|
// 发送一条弹幕(需登录,POST+JWT)
|
||||||
export async function sendChat(room: string, message: string, color?: string): Promise<void> {
|
export async function sendChat(room: string, message: string, color?: string): Promise<void> {
|
||||||
|
const t = (() => { try { return localStorage.getItem('sync_live_token') || '' } catch { return '' } })()
|
||||||
await fetch(`/api/room/${encodeURIComponent(room)}/chat`, {
|
await fetch(`/api/room/${encodeURIComponent(room)}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
|
||||||
body: JSON.stringify({ message, color }),
|
body: JSON.stringify({ message, color }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export const permissions = {
|
||||||
isRoot: () => permissions.role() === 'root',
|
isRoot: () => permissions.role() === 'root',
|
||||||
isAdmin: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
|
isAdmin: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
|
||||||
isAdminOrAbove: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
|
isAdminOrAbove: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
|
||||||
|
isPublisher: () => { const r = permissions.role(); return r === 'root' || r === 'admin' || r === 'publisher' },
|
||||||
isBanned: () => permissions.status() === 'banned',
|
isBanned: () => permissions.status() === 'banned',
|
||||||
canManage: (targetRole: string) => {
|
canManage: (targetRole: string) => {
|
||||||
const r = permissions.role()
|
const r = permissions.role()
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,98 @@
|
||||||
import type { RoomEvent } from './types'
|
import type { RoomEvent } from './types'
|
||||||
|
|
||||||
export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): () => void {
|
function getToken(): string {
|
||||||
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/events`, {
|
// Token is stored in HttpOnly cookie (token), but also returned in login response.
|
||||||
withCredentials: true,
|
// Try to read from localStorage if ever persisted, otherwise rely on cookie via Authorization fallback.
|
||||||
})
|
// We store last token in localStorage on login/register for POST SSE header use.
|
||||||
es.addEventListener('room', (e) => {
|
try { return localStorage.getItem('sync_live_token') || '' } catch { return '' }
|
||||||
try {
|
}
|
||||||
const data = JSON.parse((e as MessageEvent).data)
|
|
||||||
onEvent(data as RoomEvent)
|
function authHeaders(): Record<string, string> {
|
||||||
} catch {
|
const t = getToken()
|
||||||
/* ignore malformed */
|
if (!t) return {}
|
||||||
|
return { Authorization: `Bearer ${t}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSSEChunk(buffer: string): { events: Array<{ event: string; data: string }>; rest: string } {
|
||||||
|
const events: Array<{ event: string; data: string }> = []
|
||||||
|
let rest = buffer
|
||||||
|
// SSE frames are separated by \n\n
|
||||||
|
while (true) {
|
||||||
|
const idx = rest.indexOf('\n\n')
|
||||||
|
if (idx === -1) break
|
||||||
|
const frame = rest.slice(0, idx)
|
||||||
|
rest = rest.slice(idx + 2)
|
||||||
|
if (!frame.trim() || frame.startsWith(':')) continue
|
||||||
|
let event = 'message'
|
||||||
|
let data = ''
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim()
|
||||||
}
|
}
|
||||||
|
if (data) events.push({ event, data })
|
||||||
|
}
|
||||||
|
return { events, rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postSSE(
|
||||||
|
url: string,
|
||||||
|
onEvent: (event: string, data: string) => void,
|
||||||
|
onState?: (live: boolean) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
onState?.(false)
|
||||||
|
let retries = 0
|
||||||
|
while (!signal?.aborted) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'text/event-stream', ...authHeaders() },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
return () => es.close()
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || `SSE POST ${res.status}`)
|
||||||
|
}
|
||||||
|
if (!res.body) throw new Error('no body')
|
||||||
|
onState?.(true)
|
||||||
|
retries = 0
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
while (!signal?.aborted) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const { events, rest } = parseSSEChunk(buf)
|
||||||
|
buf = rest
|
||||||
|
for (const ev of events) onEvent(ev.event, ev.data)
|
||||||
|
}
|
||||||
|
onState?.(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (signal?.aborted) break
|
||||||
|
onState?.(false)
|
||||||
|
// backoff retry
|
||||||
|
retries += 1
|
||||||
|
const delay = Math.min(15000, 500 * Math.pow(2, Math.min(retries, 5)))
|
||||||
|
await new Promise((r) => setTimeout(r, delay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// stream ended without abort -> reconnect
|
||||||
|
if (signal?.aborted) break
|
||||||
|
await new Promise((r) => setTimeout(r, 800))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): () => void {
|
||||||
|
const ac = new AbortController()
|
||||||
|
postSSE(`/api/room/${encodeURIComponent(room)}/events`, (event, data) => {
|
||||||
|
if (event !== 'room') return
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(data) as RoomEvent)
|
||||||
|
} catch {}
|
||||||
|
}, undefined, ac.signal)
|
||||||
|
return () => ac.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomsEvent {
|
export interface RoomsEvent {
|
||||||
|
|
@ -26,18 +106,37 @@ export function subscribeRooms(
|
||||||
onEvent: (ev: RoomsEvent) => void,
|
onEvent: (ev: RoomsEvent) => void,
|
||||||
onState?: (live: boolean) => void,
|
onState?: (live: boolean) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource('/api/rooms/events', { withCredentials: true })
|
const ac = new AbortController()
|
||||||
es.addEventListener('rooms', (e) => {
|
postSSE(
|
||||||
|
'/api/rooms/events',
|
||||||
|
(event, data) => {
|
||||||
|
if (event !== 'rooms') return
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse((e as MessageEvent).data)
|
onEvent(JSON.parse(data) as RoomsEvent)
|
||||||
onEvent(data as RoomsEvent)
|
} catch {}
|
||||||
} catch {
|
},
|
||||||
/* ignore */
|
onState,
|
||||||
}
|
ac.signal,
|
||||||
})
|
)
|
||||||
es.onopen = () => onState?.(true)
|
return () => ac.abort()
|
||||||
es.onerror = () => onState?.(false)
|
}
|
||||||
// initial state hint: if already open
|
|
||||||
if (es.readyState === EventSource.OPEN) onState?.(true)
|
export function subscribeChatSSE(
|
||||||
return () => es.close()
|
room: string,
|
||||||
|
onMsg: (m: any) => void,
|
||||||
|
onState?: (live: boolean) => void,
|
||||||
|
): () => void {
|
||||||
|
const ac = new AbortController()
|
||||||
|
postSSE(
|
||||||
|
`/api/room/${encodeURIComponent(room)}/chat/subscribe`,
|
||||||
|
(event, data) => {
|
||||||
|
if (event !== 'chat') return
|
||||||
|
try {
|
||||||
|
onMsg(JSON.parse(data))
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
onState,
|
||||||
|
ac.signal,
|
||||||
|
)
|
||||||
|
return () => ac.abort()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue