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:
noelorin 2026-08-23 23:55:23 +08:00
parent 03c3d4d5bd
commit c7f70970ac
30 changed files with 244 additions and 120 deletions

View File

@ -374,6 +374,7 @@ func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
{"user:manage", "user", "manage"},
{"system:manage", "system", "manage"},
{"config:read", "config", "read"},
{"config:write", "config", "write"},
{"srs:streams", "srs", "streams"},
} {
ok, _ := s.auth.Check(claims.Username, claims.Role, c.obj, c.act)

View File

@ -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))
// 对标 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/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/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/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/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("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("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("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
mux.Handle("POST /api/room/{room}/chat", s.authWrap(http.HandlerFunc(s.handleRoomChatPost), "room", "chat", false))
// 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host

View File

@ -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};

View File

@ -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};

View File

@ -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};

View File

@ -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};

View File

@ -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};

View File

@ -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

View File

@ -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};

View File

@ -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};

View File

@ -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};

View File

@ -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};

View File

@ -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

View File

@ -19,8 +19,8 @@
}
})()
</script>
<script type="module" crossorigin src="/static/assets/index-BdWedm3z.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-IRgYFSdb.js">
<script type="module" crossorigin src="/static/assets/index-LUhOsKYd.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-CTx2ZrLp.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-DwOmVRa5.css">
</head>
<body>

View File

@ -102,12 +102,12 @@ export const Rooms: Component = () => {
}
createEffect(() => {
if (permissions.isAuthed()) load()
if (permissions.can('room:list')) load()
})
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 debounce: ReturnType<typeof setTimeout> | null = null
const attach = () => {
@ -132,7 +132,7 @@ export const Rooms: Component = () => {
}
// attach when authed, detach when not
createEffect(() => {
if (permissions.isAuthed()) attach()
if (permissions.can('room:list')) attach()
else detach()
})
attach()
@ -195,6 +195,7 @@ export const Rooms: Component = () => {
<Alert tone="error">{err()}</Alert>
</Show>
<Show when={permissions.can('room:list')}>
<Reveal delay={1}>
<div class="relative max-w-3xl border border-line bg-card/60 p-3 sm:p-4">
<Corners />
@ -240,6 +241,7 @@ export const Rooms: Component = () => {
</div>
</div>
</Reveal>
</Show>
<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">

View File

@ -51,16 +51,33 @@ function RoomHub(props: { room: string }) {
let pc: RTCPeerConnection | undefined
let stopHls: (() => void) | undefined
onMount(() => {
const unsubRoom = subscribeRoom(room(), (ev) => {
let unsubRoom: (() => void) | undefined
let unsubChat: (() => void) | undefined
createEffect(() => {
// room:watch 订阅房间事件(SSE 需要 room:watch 权限)
if (permissions.can('room:watch') && !unsubRoom) {
unsubRoom = subscribeRoom(room(), (ev) => {
setTargets(ev.targets || [])
setDists(ev.distributions || [])
})
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
}
if (!permissions.can('room:watch') && 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 = undefined
}
})
onCleanup(() => {
unsubRoom?.()
unsubChat?.()
})
function attach(p: RTCPeerConnection) {
@ -190,7 +207,7 @@ function RoomHub(props: { room: string }) {
</div>
<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 />
开始观看
</Button>

View File

@ -12,6 +12,10 @@ import type {
AuthResponse,
} 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 = ''
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
const ct = res.headers.get('content-type') || ''
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
}
@ -88,7 +94,7 @@ export const api = {
password,
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: () =>
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'),

View File

@ -1,4 +1,5 @@
import type { Role } from './types'
import { subscribeChatSSE } from './sse'
export interface ChatMessage {
id: string
@ -11,35 +12,29 @@ export interface ChatMessage {
ts: number
}
// 订阅房间弹幕(SSE,事件名 chat)
// 订阅房间弹幕(POST SSE + JWT,满足前端SSE必须是POST+JWT的要求)
export function subscribeChat(room: string, onMsg: (m: ChatMessage) => void): () => void {
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/chat`, { withCredentials: true })
es.addEventListener('chat', (e) => {
try {
onMsg(JSON.parse((e as MessageEvent).data) as ChatMessage)
} catch {
/* ignore */
}
})
return () => es.close()
return subscribeChatSSE(room, onMsg as any)
}
// 主播独立外部推送:以主播身份推送弹幕(需 room:publish 权限,可带 Bearer token 供外部工具调用)
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`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
body: JSON.stringify({ message, color }),
})
}
// 发送一条弹幕(需登录)
// 发送一条弹幕(需登录,POST+JWT)
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`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) },
body: JSON.stringify({ message, color }),
})
}

View File

@ -35,6 +35,7 @@ export const permissions = {
isRoot: () => permissions.role() === 'root',
isAdmin: () => { 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',
canManage: (targetRole: string) => {
const r = permissions.role()

View File

@ -1,18 +1,98 @@
import type { RoomEvent } from './types'
export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): () => void {
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/events`, {
withCredentials: true,
})
es.addEventListener('room', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data)
onEvent(data as RoomEvent)
} catch {
/* ignore malformed */
function getToken(): string {
// Token is stored in HttpOnly cookie (token), but also returned in login response.
// 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.
try { return localStorage.getItem('sync_live_token') || '' } catch { return '' }
}
function authHeaders(): Record<string, string> {
const t = getToken()
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 {
@ -26,18 +106,37 @@ export function subscribeRooms(
onEvent: (ev: RoomsEvent) => void,
onState?: (live: boolean) => void,
): () => void {
const es = new EventSource('/api/rooms/events', { withCredentials: true })
es.addEventListener('rooms', (e) => {
const ac = new AbortController()
postSSE(
'/api/rooms/events',
(event, data) => {
if (event !== 'rooms') return
try {
const data = JSON.parse((e as MessageEvent).data)
onEvent(data as RoomsEvent)
} catch {
/* ignore */
onEvent(JSON.parse(data) as RoomsEvent)
} catch {}
},
onState,
ac.signal,
)
return () => ac.abort()
}
})
es.onopen = () => onState?.(true)
es.onerror = () => onState?.(false)
// initial state hint: if already open
if (es.readyState === EventSource.OPEN) onState?.(true)
return () => es.close()
export function subscribeChatSSE(
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()
}