feat(auth): 基于 casbin 的登录与 RBAC 权限系统

- 引入 github.com/casbin/casbin/v2 实现 RBAC (model.conf + policy.csv)
- 角色: admin/publisher/viewer/guest,资源: config/room/user/srs,动作: read/list/publish/subscribe/stop/watch/manage
- JWT 登录 (golang-jwt/jwt/v5,bcrypt 存储,data/users.json 持久化,种子用户 admin/publisher/viewer)
- 中间件: HTTP AuthorizeMiddleware + gRPC Unary/Stream 拦截器,支持 Authorization Bearer/Cookie/Query/X-Token
- 新增 API: POST /api/auth/login|register|logout, GET /api/auth/me|users|check, POST /api/auth/users/role (Casbin 鉴权)
- 保护业务路由: /api/publish/subscribe/stop/rooms/room/*/events/srs/streams 及媒体代理 /rtc/v1/* /api/cf/*
- gRPC 契约新增 Login/Register/GetMe/ListUsers/UpdateUserRole (api/live_sfu.proto + gen)
- 前端 login.html + app.js 自动附加 Authorization,SSE 通过 ?token 传递,未登录自动跳转
- 配置: JWT_SECRET/JWT_TTL/CASBIN_MODEL/CASBIN_POLICY/AUTH_USER_FILE/ALLOW_REGISTER
- 集成 TursoDB 持久化与测试覆盖 (auth_test.go)
This commit is contained in:
xuhongyuan 2026-08-19 16:42:17 +08:00
parent e46db4d365
commit 15a7546a59
33 changed files with 3351 additions and 131 deletions

View File

@ -7,6 +7,7 @@ SFU_PROVIDER=cloudflare,srs
# SRS(本地对照后端:docker compose up -d srs)
SRS_API_BASE=http://localhost:1985
SRS_HTTP_BASE=http://localhost:8080
SRS_APP=live
SRS_SECRET=
SRS_CANDIDATE=127.0.0.1
@ -20,3 +21,9 @@ CF_STUN_URL=stun:stun.cloudflare.com:3478
# 推流 JWT(SRS 入口鉴权,可选;设为 1 后 WHIP 必须带有效 token)
DEMO_TOKEN_SECRET=
SFU_TOKEN_REQUIRED=0
# Turso 嵌入式(默认 DB,房间分发拓扑持久化)
# 仅支持嵌入 file: 模式,开箱即用,无需远端凭证
TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL
# 兼容 DATABASE_URL
# DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.DS_Store
.env
*.local.env
data/*
!data/.gitkeep

View File

@ -6,6 +6,7 @@
- 一路推流 → SFU 扇出 → 多路拉流。
- 同一路发布可同时落到 **Cloudflare Realtime** 与 **SRS** 两条分发链路(分流)。
- 浏览器经服务端反向代理直连 SFU,凭证不出服务端。
- **默认 DB 使用 Turso 嵌入式 (libSQL file)**:房间分发拓扑持久化到本地文件。
## 目录结构
@ -15,6 +16,7 @@ app/live-sfu-demo/
gen/ # buf generate 产出(Go)
internal/
config/ # 运行参数(对齐 GOSpeak 的 env 布局)
db/ # Turso 嵌入式 (libSQL file) 持久化
sfu/cloudflare/ # Cloudflare Realtime REST 客户端 + Provider
sfu/srs/ # SRS Provider
server/ # gRPC 服务 + JSON 网关 + SRS/CF 媒体反代 + 房间扇出状态
@ -26,12 +28,12 @@ app/live-sfu-demo/
## 运行(SRS 链路,开箱即跑)
```bash
# 1) 起本地 SRS(WHIP/WHEP 后端)
# 1) 起本地 SRS(WHIP/WHEP/HLS 三协议后端,见 docs/streaming-pipeline.md)
cd app/live-sfu-demo/deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs
# 2) 起 Demo 控制面
# 2) 起 Demo 控制面(默认 Turso 嵌入式,无需额外配置)
cd app/live-sfu-demo
cp .env.example .env # 可选;SRS 链路无需任何凭证
cp .env.example .env
go run ./cmd/server
# 3) 打开浏览器
@ -41,6 +43,28 @@ go run ./cmd/server
两个标签页用同一房间名即可配对。发布页勾选的分发后端会在「分发状态」中实时显示。
## 默认 DB:Turso 嵌入式
房间的“分发目标”拓扑(`stream_targets`)默认持久化到 **Turso 嵌入式**(`github.com/tursodatabase/go-libsql` 的 SQLite 兼容 file 模式),而非纯内存。
- **DSN**:`TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL`(默认)
- 兼容 `DATABASE_URL` 覆盖
- 仅支持嵌入 file: / :memory:,拒绝 libsql:// 远程(嵌入适配专注单机持久化)
- 自动建表 `(stream_targets, rooms)`,`SetMaxOpenConns(1)` 适配 SQLite 单写模型
- 重启后自动 `LoadAll` 恢复房间
```bash
# 默认即嵌入文件
TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL
# 内存(测试)
TURSO_DATABASE_URL=file::memory:?cache=shared
# 验证持久化:发布后重启,/api/rooms 仍在
curl http://localhost:8088/api/rooms | jq
sqlite3 data/live-sfu.db "select room, backend, stream from stream_targets;"
```
## 运行(Cloudflare Realtime 链路,主 SFU)
在 `.env` 填入 Cloudflare Realtime 凭证后,`go run ./cmd/server` 即启用主 SFU:
@ -67,10 +91,18 @@ buf generate api
`WatchRoom`(服务端流式推送房间分发拓扑)。gRPC 监听 `GRPC_PORT`(默认 9090),浏览器走同端口
的 JSON 网关(`protojson`)。
## 文档
* 推流链路详解(PC → WHIP → SRS → HLS/WHEP/FLV):[`docs/streaming-pipeline.md`](docs/streaming-pipeline.md)
## 分流拓扑
```
publisher ──WHIP/tracks.new──▶ Cloudflare Realtime SFU ──▶ viewer(s)
└────WHIP────────────▶ SRS SFU (WHEP) ──▶ viewer(s)
房间分发目标由 WatchRoom 实时广播,观众任选后端拉流
└────WHIP────────────▶ SRS SFU ─┬─▶ WHEP viewer(s) (低延时 0.2-0.5s)
├─▶ HLS viewer(s) (PC→WHIP→SRS→HLS 延时 5-10s,全端兼容)
└─▶ FLV viewer(s)
一路 WHIP 推流,SRS 自动 remux 三协议同出;房间分发目标由 WatchRoom 实时广播,观众任选后端拉流
```
> 详见 [`docs/streaming-pipeline.md`](docs/streaming-pipeline.md) — 默认已开启 HLS,`Go` 网关反代 `/live/*.m3u8`,前端 iOS 原生 / hls.js 双兼容。

View File

@ -16,6 +16,12 @@ service LiveSFU {
rpc Subscribe(SubscribeRequest) returns (SubscribeResponse);
rpc StopStream(StopStreamRequest) returns (StopStreamResponse);
rpc WatchRoom(WatchRoomRequest) returns (stream RoomEvent);
// ---- Auth & Casbin ----
rpc Login(LoginRequest) returns (LoginResponse);
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc GetMe(GetMeRequest) returns (GetMeResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc UpdateUserRole(UpdateUserRoleRequest) returns (UpdateUserRoleResponse);
}
enum BackendKind {
@ -45,13 +51,12 @@ message GetConfigResponse {
bool token_required = 3;
}
// StreamTarget 表示一个房间在某后端的一条分发目标(SFU 扇出出口)。
message StreamTarget {
BackendKind backend = 1;
string session_id = 2; // Cloudflare: 发布者 sessionId
string stream = 3; // SRS: stream 名
string publish_token = 4;// SRS: 推流 JWT(可选)
string url = 5; // 拉流播放地址(SRS WHEP,可选)
string session_id = 2;
string stream = 3;
string publish_token = 4;
string url = 5;
int64 published_at = 6;
}
@ -105,3 +110,52 @@ message RoomEvent {
string room = 1;
repeated StreamTarget targets = 2;
}
// ===== Auth & RBAC (Casbin) =====
message UserInfo {
string username = 1;
string role = 2;
int64 created_at = 3;
}
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
string token = 1;
UserInfo user = 2;
int64 expires_at = 3;
}
message RegisterRequest {
string username = 1;
string password = 2;
string role = 3;
}
message RegisterResponse {
string token = 1;
UserInfo user = 2;
int64 expires_at = 3;
}
message GetMeRequest {}
message GetMeResponse {
UserInfo user = 1;
int64 expires_at = 2;
int64 issued_at = 3;
}
message ListUsersRequest {}
message ListUsersResponse {
repeated UserInfo users = 1;
}
message UpdateUserRoleRequest {
string username = 1;
string role = 2;
}
message UpdateUserRoleResponse {
bool ok = 1;
UserInfo user = 2;
}

View File

@ -11,6 +11,11 @@ import (
func main() {
cfg := config.Load()
srv := server.New(cfg)
defer func() {
if err := srv.Close(); err != nil {
log.Printf("[warn] db close: %v", err)
}
}()
if err := srv.StartGRPC(); err != nil {
log.Printf("[warn] grpc control plane failed to start: %v", err)
}
@ -18,6 +23,7 @@ func main() {
log.Printf("live-sfu demo ready: http://localhost:%s (grpc :%s)", cfg.HTTPPort, cfg.GRPCPort)
log.Printf("backends (primary first): %v", cfg.ProviderList())
log.Printf("cloudflare configured: %v", cfg.CFAppID != "" && cfg.CFAppSecret != "")
log.Printf("turso embedded db: %s", cfg.DatabaseURL)
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
log.Fatalf("http server error: %v", err)
}

0
data/.gitkeep Normal file
View File

View File

@ -35,4 +35,10 @@ vhost __defaultVhost__ {
enabled on;
mount [vhost]/[app]/[stream].flv;
}
hls {
enabled on;
hls_path ./objs/nginx/html;
hls_fragment 10;
hls_window 60;
}
}

222
docs/streaming-pipeline.md Normal file
View File

@ -0,0 +1,222 @@
# 推流链路文档 · PC → WHIP → SRS → HLS / WHEP / FLV
> 默认推流链路已升级为 **一路 WHIP 推流,SRS 三协议同出**。本文档沉淀该链路的拓扑、配置与验证方法,便于后续维护与排查。
## 1. 拓扑总览
```
┌─→ WHEP (WebRTC 0.2-0.5s) → <video> RTCPeerConnection (watch.html: SRS WebRTC)
PC (getUserMedia) ─WHIP─→ SRS ─┼─→ HLS (5-10s 切片) → <video> hls.js / Safari原生 (watch.html: SRS HLS)
└─→ FLV (http-flv) → flv.js / 直接下载 (publish.html 展示链接)
│
└─→ 同时经 Go 网关反代,无需浏览器直连 8080
└──── 同一房间经 WatchRoom (SSE / gRPC stream) 广播分发目标 ────┘
另一条分流链路(可选,未配置不影响):
PC ─WHIP/tracks.new─→ Cloudflare Realtime SFU ─→ viewer (tracks.new location=remote)
```
**核心结论**:浏览器推流始终只走一次 WHIP(`/rtc/v1/whip/?app=live&stream=<room>`),SRS 在 `vhost __defaultVhost__` 内自动 remux 为 HLS / FLV / WHEP,无需二次推流或转码。
| 协议 | 播放地址(经 Go 网关 8088) | SRS 源地址(8080) | 延时 | 兼容性 |
|------|-----------------------------|-------------------|------|--------|
| WHEP | `POST /rtc/v1/whep/?app=live&stream=xxx` | 同上(经网关) | 0.2–0.5s | 需 WebRTC |
| HLS | `GET /live/xxx.m3u8` + `.ts` | `http://srs:8080/live/xxx.m3u8` 经 `GET/HEAD /live/` 反代 | 5–10s(3×10s 切片) | 全端,iOS 原生 |
| FLV | `GET /live/xxx.flv` | 同上 | 1–2s | 需 flv.js |
> 当前发布页(`/publish`)与观看页(`/watch`)已同时支持三种观看方式,发布页推流后自动显示 HLS/FLV 链接(见 `internal/server/static/app.js:hlsUrl`)。
---
## 2. 配置清单
### 2.1 SRS 服务端 · `deploy/srs.conf`
`http_server:8080` 产出静态切片,`http_api:1985` 负责 WHIP/WHEP 信令,`rtc_server:8000` 负责媒体。
```nginx
listen 1935;
max_connections 1000;
daemon off;
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html; # HLS 切片落盘目录,Go 网关反代至此
}
http_api {
enabled on;
listen 1985;
crossdomain on;
}
rtc_server {
enabled on;
listen 8000;
tcp { enabled on; listen 8000; }
protocol all;
candidate $CANDIDATE; # 由 SRS_CANDIDATE 注入,公网部署填公网 IP
}
vhost __defaultVhost__ {
rtc { enabled on; nack on; twcc on; }
http_remux {
enabled on;
mount [vhost]/[app]/[stream].flv;
}
hls {
enabled on;
hls_path ./objs/nginx/html; # 与 http_server.dir 一致
hls_fragment 10; # 单切片 10s,首屏约 10–20s
hls_window 60; # 窗口 60s,保留 6 片
}
}
```
调整建议:
* 降低 `hls_fragment 5` 可将延时压至 5–7s,但会增加切片数与 I/O。
* 若需更低 HLS 延时,可启用 LL-HLS(SRS 6 支持 `hls_ll`),但前端需 ll-hls 客户端,当前未启用以保持兼容。
### 2.2 Docker · `deploy/docker-compose.yml`
```yaml
services:
srs:
image: ossrs/srs:6
ports:
- "1935:1935"
- "1985:1985"
- "8080:8080"
- "8000:8000/udp"
- "8000:8000/tcp"
environment:
CANDIDATE: "${SRS_CANDIDATE:-127.0.0.1}"
volumes:
- ./srs.conf:/usr/local/srs/conf/srs.conf:ro
command: ./objs/srs -c conf/srs.conf
```
`8080` 仅需宿主机验证时直连;生产经 Go 网关反代后可不暴露公网 8080,仅保留 1985/8000 供信令与媒体。
### 2.3 Go 控制面 · `internal/config/config.go` + `.env.example`
| 变量 | 默认 | 说明 |
|------|------|------|
| `SRS_API_BASE` | `http://localhost:1985` | WHIP/WHEP 信令反代目标(`srsProxyHandler`) |
| `SRS_HTTP_BASE` | `http://localhost:8080` | HLS/FLV 静态资源反代目标(`srsHlsProxyHandler`,本次新增) |
| `SRS_APP` | `live` | 推流 app,决定 URL 路径 `/live/<stream>` |
| `SRS_CANDIDATE` | `127.0.0.1` | ICE candidate,容器部署填宿主机/公网 IP |
| `SFU_PROVIDER` | `cloudflare,srs` | 后端顺序,(`GetConfig` 返回) |
| `TURSO_DATABASE_URL` | `file:./data/live-sfu.db?cache=shared&_journal_mode=WAL` | 房间拓扑持久化(本次未改) |
### 2.4 反向代理 · `internal/server/proxy.go` + `internal/server/server.go`
`srsProxyHandler`:透传 `/rtc/v1/*` 至 `SRS_API_BASE`,可选校验 `?token=`(`SFU_TOKEN_REQUIRED=1` 时)。
`srsHlsProxyHandler`(新增):
```go
target, _ := url.Parse(cfg.SRSHttpURL) // 默认 :8080
rp := httputil.NewSingleHostReverseProxy(target)
mux.Handle("GET /live/", s.srsHlsProxy)
mux.Handle("HEAD /live/", s.srsHlsProxy)
```
* 统一经 `http://localhost:8088/live/*.m3u8/.ts/.flv` 访问,避免前端直连 8080 的跨域与端口暴露。
* 自动补 `Access-Control-Allow-Origin: *`,iOS/桌面端 `<video>` 可直接播放。
> 拉流 HLS 无需 `Subscribe` 创建 WHEP PeerConnection;观看页 `srs-hls` 模式仅用 `stream` 拼出 hlsUrl 并交由 `watchHLS()` 播放(见下)。
---
## 3. 前端链路 · `internal/server/static/app.js`
### 3.1 推流(WHIP)
```js
POST /rtc/v1/whip/?app=live&stream=live-demo&token=<publishToken>
Content-Type: application/sdp
Body: offer.sdp → 200 answer.sdp → pc.setRemoteDescription(answer)
```
* `publishSRS()`:创建 `RTCPeerConnection` → `createOffer` → `fetch WHIP` → `setRemoteDescription`。
* 成功后 `hlsLink` 立即显示 `hlsUrl(stream) = /live/<stream>.m3u8` 与 `flvUrl`,日志提示“三协议同出”。
### 3.2 观看
* **WHEP**(`watchSRS`):`POST /rtc/v1/whep/?app=live&stream=xxx` 同 WHIP 流程,`pc.ontrack` 挂 `<video>`。
* **HLS**(`watchHLS`,新增):
```js
hlsUrl = `/live/${stream}.m3u8`
if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = hlsUrl; // iOS 原生
else if (Hls.isSupported()) { hls.loadSource(hlsUrl); hls.attachMedia(video); } // hls.js 1.5.7
```
* **切换**:观看页提供三档单选 `cloudflare / srs(whep) / srs-hls`,`srs-hls` 复用 `BACKEND_KIND_SRS` 枚举,仅前端分流。
### 3.3 UI
* `publish.html`:新增 `#hlsLink`,推流后展示 HLS/FLV 超链接;引入 `hls.js` CDN。
* `watch.html`:新增 `srs-hls` 选项 + `#hlsInfo` + `controls`;首屏文案强调“三协议同出”。
---
## 4. 控制面与房间拓扑
* `Publish(room, BACKEND_KIND_SRS)` → 分配 `stream = live-<room>` + JWT `publishToken` → `hub.setTarget(room, "srs", {stream, publishToken, url})` → 广播 SSE。
* `Subscribe(room, BACKEND_KIND_SRS)` → 取 `hub` 中 `stream` 返回,WHEP 与 HLS 共用同一 `stream`。
* `WatchRoom / GET /api/room/{room}/events`(SSE)推送 `RoomEvent{targets}`,观看页据此自动 `subscribe()`。
房间拓扑持久化至 Turso 嵌入式(`internal/db/db.go`),重启后恢复,不影响 HLS 切片(切片为临时文件,SRS 重启清空)。
---
## 5. 快速验证
```bash
# 1) 起 SRS(含 HLS)
cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs && docker logs -f live-sfu-srs
# 2) 起控制面
cd .. && go run ./cmd/server
# 日志:live-sfu demo ready: http://localhost:8088
# 3) 浏览器
# 发布: http://localhost:8088/publish?room=demo → 勾选 SRS → 开始推流
# 观看 WHEP: http://localhost:8088/watch?room=demo → 选 SRS WebRTC → 开始观看(<1s)
# 观看 HLS : 同页切 SRS HLS → 停止后重新开始观看(约 5–10s 后出画面)
# 裸 HLS: http://localhost:8088/live/live-demo.m3u8 (VLC / ffplay / curl 均可)
curl -i http://localhost:8088/live/live-demo.m3u8
curl -I http://localhost:8088/live/live-demo.flv
ffplay http://localhost:8088/live/live-demo.m3u8
```
排障:
* `m3u8 404`:推流后等待 10–20s 再试;检查 `docker exec live-sfu-srs ls /usr/local/srs/objs/nginx/html/live/` 是否有切片。
* `candidate` 不通:容器内 `ip` 与浏览器不在同一网段时,`SRS_CANDIDATE` 改为宿主机公网/局域网 IP 后 `docker compose up -d --force-recreate`。
* HLS 跨域:已由 `srsHlsProxyHandler` 统一加 CORS,若直连 8080 需自行在 `srs.conf` 加 `crossdomain`(http_server 无此指令,建议走网关)。
---
## 6. 延时与选型建议
| 场景 | 推荐 | 理由 |
|------|------|------|
| 连麦/互动直播 | WHEP / Cloudflare Realtime | 200–500ms,可双向 |
| 单向大并发/回看友好 | HLS | 全端兼容,CDN 友好,成本最低 |
| 演示/对比 | WHEP + HLS 双出(默认) | 一路推流兼顾低延时与兼容性 |
> 本 Demos 默认即为双出:无需额外推流或配置,开箱获得两条观看链路。
---
## 7. 文件索引
* 服务端配置:`deploy/srs.conf`、`deploy/docker-compose.yml`、`internal/config/config.go`、`.env.example`
* 网关:`internal/server/proxy.go`(`srsProxyHandler` / `srsHlsProxyHandler`)、`internal/server/server.go`(`Handler` 路由)、`internal/server/gateway.go`、`internal/server/service.go`
* 前端:`internal/server/static/app.js`(`publishSRS`/`watchSRS`/`watchHLS`)、`internal/server/static/publish.html`、`internal/server/static/watch.html`
* 协议:`api/live_sfu.proto`(`BackendKind` / `StreamTarget`)
---
*更新:2026-08 — 默认链路 PC→WHIP→SRS→HLS/WHEP/FLV 已启用并经网关反代,文档与代码同源。*

View File

@ -294,14 +294,13 @@ func (x *GetConfigResponse) GetTokenRequired() bool {
return false
}
// StreamTarget 表示一个房间在某后端的一条分发目标(SFU 扇出出口)。
type StreamTarget struct {
state protoimpl.MessageState `protogen:"open.v1"`
Backend BackendKind `protobuf:"varint,1,opt,name=backend,proto3,enum=gospeak.livedemo.v1.BackendKind" json:"backend,omitempty"`
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Cloudflare: 发布者 sessionId
Stream string `protobuf:"bytes,3,opt,name=stream,proto3" json:"stream,omitempty"` // SRS: stream 名
PublishToken string `protobuf:"bytes,4,opt,name=publish_token,json=publishToken,proto3" json:"publish_token,omitempty"` // SRS: 推流 JWT(可选)
Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` // 拉流播放地址(SRS WHEP,可选)
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
Stream string `protobuf:"bytes,3,opt,name=stream,proto3" json:"stream,omitempty"`
PublishToken string `protobuf:"bytes,4,opt,name=publish_token,json=publishToken,proto3" json:"publish_token,omitempty"`
Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"`
PublishedAt int64 `protobuf:"varint,6,opt,name=published_at,json=publishedAt,proto3" json:"published_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -967,6 +966,579 @@ func (x *RoomEvent) GetTargets() []*StreamTarget {
return nil
}
// ===== Auth & RBAC (Casbin) =====
type UserInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
CreatedAt int64 `protobuf:"varint,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UserInfo) Reset() {
*x = UserInfo{}
mi := &file_live_sfu_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UserInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UserInfo) ProtoMessage() {}
func (x *UserInfo) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UserInfo.ProtoReflect.Descriptor instead.
func (*UserInfo) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{16}
}
func (x *UserInfo) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *UserInfo) GetRole() string {
if x != nil {
return x.Role
}
return ""
}
func (x *UserInfo) GetCreatedAt() int64 {
if x != nil {
return x.CreatedAt
}
return 0
}
type LoginRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginRequest) Reset() {
*x = LoginRequest{}
mi := &file_live_sfu_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginRequest) ProtoMessage() {}
func (x *LoginRequest) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead.
func (*LoginRequest) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{17}
}
func (x *LoginRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *LoginRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
User *UserInfo `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
ExpiresAt int64 `protobuf:"varint,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoginResponse) Reset() {
*x = LoginResponse{}
mi := &file_live_sfu_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoginResponse) ProtoMessage() {}
func (x *LoginResponse) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead.
func (*LoginResponse) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{18}
}
func (x *LoginResponse) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
func (x *LoginResponse) GetUser() *UserInfo {
if x != nil {
return x.User
}
return nil
}
func (x *LoginResponse) GetExpiresAt() int64 {
if x != nil {
return x.ExpiresAt
}
return 0
}
type RegisterRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RegisterRequest) Reset() {
*x = RegisterRequest{}
mi := &file_live_sfu_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RegisterRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RegisterRequest) ProtoMessage() {}
func (x *RegisterRequest) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead.
func (*RegisterRequest) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{19}
}
func (x *RegisterRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *RegisterRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *RegisterRequest) GetRole() string {
if x != nil {
return x.Role
}
return ""
}
type RegisterResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
User *UserInfo `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
ExpiresAt int64 `protobuf:"varint,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RegisterResponse) Reset() {
*x = RegisterResponse{}
mi := &file_live_sfu_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RegisterResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RegisterResponse) ProtoMessage() {}
func (x *RegisterResponse) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead.
func (*RegisterResponse) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{20}
}
func (x *RegisterResponse) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
func (x *RegisterResponse) GetUser() *UserInfo {
if x != nil {
return x.User
}
return nil
}
func (x *RegisterResponse) GetExpiresAt() int64 {
if x != nil {
return x.ExpiresAt
}
return 0
}
type GetMeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetMeRequest) Reset() {
*x = GetMeRequest{}
mi := &file_live_sfu_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetMeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetMeRequest) ProtoMessage() {}
func (x *GetMeRequest) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetMeRequest.ProtoReflect.Descriptor instead.
func (*GetMeRequest) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{21}
}
type GetMeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
User *UserInfo `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
ExpiresAt int64 `protobuf:"varint,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
IssuedAt int64 `protobuf:"varint,3,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetMeResponse) Reset() {
*x = GetMeResponse{}
mi := &file_live_sfu_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetMeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetMeResponse) ProtoMessage() {}
func (x *GetMeResponse) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetMeResponse.ProtoReflect.Descriptor instead.
func (*GetMeResponse) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{22}
}
func (x *GetMeResponse) GetUser() *UserInfo {
if x != nil {
return x.User
}
return nil
}
func (x *GetMeResponse) GetExpiresAt() int64 {
if x != nil {
return x.ExpiresAt
}
return 0
}
func (x *GetMeResponse) GetIssuedAt() int64 {
if x != nil {
return x.IssuedAt
}
return 0
}
type ListUsersRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListUsersRequest) Reset() {
*x = ListUsersRequest{}
mi := &file_live_sfu_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListUsersRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListUsersRequest) ProtoMessage() {}
func (x *ListUsersRequest) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListUsersRequest.ProtoReflect.Descriptor instead.
func (*ListUsersRequest) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{23}
}
type ListUsersResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Users []*UserInfo `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListUsersResponse) Reset() {
*x = ListUsersResponse{}
mi := &file_live_sfu_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListUsersResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListUsersResponse) ProtoMessage() {}
func (x *ListUsersResponse) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead.
func (*ListUsersResponse) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{24}
}
func (x *ListUsersResponse) GetUsers() []*UserInfo {
if x != nil {
return x.Users
}
return nil
}
type UpdateUserRoleRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateUserRoleRequest) Reset() {
*x = UpdateUserRoleRequest{}
mi := &file_live_sfu_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateUserRoleRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateUserRoleRequest) ProtoMessage() {}
func (x *UpdateUserRoleRequest) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateUserRoleRequest.ProtoReflect.Descriptor instead.
func (*UpdateUserRoleRequest) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{25}
}
func (x *UpdateUserRoleRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *UpdateUserRoleRequest) GetRole() string {
if x != nil {
return x.Role
}
return ""
}
type UpdateUserRoleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
User *UserInfo `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateUserRoleResponse) Reset() {
*x = UpdateUserRoleResponse{}
mi := &file_live_sfu_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateUserRoleResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateUserRoleResponse) ProtoMessage() {}
func (x *UpdateUserRoleResponse) ProtoReflect() protoreflect.Message {
mi := &file_live_sfu_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateUserRoleResponse.ProtoReflect.Descriptor instead.
func (*UpdateUserRoleResponse) Descriptor() ([]byte, []int) {
return file_live_sfu_proto_rawDescGZIP(), []int{26}
}
func (x *UpdateUserRoleResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *UpdateUserRoleResponse) GetUser() *UserInfo {
if x != nil {
return x.User
}
return nil
}
var File_live_sfu_proto protoreflect.FileDescriptor
const file_live_sfu_proto_rawDesc = "" +
@ -1036,11 +1608,48 @@ const file_live_sfu_proto_rawDesc = "" +
"\x04room\x18\x01 \x01(\tR\x04room\"\\\n" +
"\tRoomEvent\x12\x12\n" +
"\x04room\x18\x01 \x01(\tR\x04room\x12;\n" +
"\atargets\x18\x02 \x03(\v2!.gospeak.livedemo.v1.StreamTargetR\atargets*^\n" +
"\atargets\x18\x02 \x03(\v2!.gospeak.livedemo.v1.StreamTargetR\atargets\"Y\n" +
"\bUserInfo\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\x12\x1d\n" +
"\n" +
"created_at\x18\x03 \x01(\x03R\tcreatedAt\"F\n" +
"\fLoginRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\"w\n" +
"\rLoginResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x121\n" +
"\x04user\x18\x02 \x01(\v2\x1d.gospeak.livedemo.v1.UserInfoR\x04user\x12\x1d\n" +
"\n" +
"expires_at\x18\x03 \x01(\x03R\texpiresAt\"]\n" +
"\x0fRegisterRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x12\n" +
"\x04role\x18\x03 \x01(\tR\x04role\"z\n" +
"\x10RegisterResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x121\n" +
"\x04user\x18\x02 \x01(\v2\x1d.gospeak.livedemo.v1.UserInfoR\x04user\x12\x1d\n" +
"\n" +
"expires_at\x18\x03 \x01(\x03R\texpiresAt\"\x0e\n" +
"\fGetMeRequest\"~\n" +
"\rGetMeResponse\x121\n" +
"\x04user\x18\x01 \x01(\v2\x1d.gospeak.livedemo.v1.UserInfoR\x04user\x12\x1d\n" +
"\n" +
"expires_at\x18\x02 \x01(\x03R\texpiresAt\x12\x1b\n" +
"\tissued_at\x18\x03 \x01(\x03R\bissuedAt\"\x12\n" +
"\x10ListUsersRequest\"H\n" +
"\x11ListUsersResponse\x123\n" +
"\x05users\x18\x01 \x03(\v2\x1d.gospeak.livedemo.v1.UserInfoR\x05users\"G\n" +
"\x15UpdateUserRoleRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x12\n" +
"\x04role\x18\x02 \x01(\tR\x04role\"[\n" +
"\x16UpdateUserRoleResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x121\n" +
"\x04user\x18\x02 \x01(\v2\x1d.gospeak.livedemo.v1.UserInfoR\x04user*^\n" +
"\vBackendKind\x12\x1c\n" +
"\x18BACKEND_KIND_UNSPECIFIED\x10\x00\x12\x1b\n" +
"\x17BACKEND_KIND_CLOUDFLARE\x10\x01\x12\x14\n" +
"\x10BACKEND_KIND_SRS\x10\x022\xa8\x04\n" +
"\x10BACKEND_KIND_SRS\x10\x022\xe8\a\n" +
"\aLiveSFU\x12Z\n" +
"\tGetConfig\x12%.gospeak.livedemo.v1.GetConfigRequest\x1a&.gospeak.livedemo.v1.GetConfigResponse\x12Z\n" +
"\tListRooms\x12%.gospeak.livedemo.v1.ListRoomsRequest\x1a&.gospeak.livedemo.v1.ListRoomsResponse\x12T\n" +
@ -1048,7 +1657,12 @@ const file_live_sfu_proto_rawDesc = "" +
"\tSubscribe\x12%.gospeak.livedemo.v1.SubscribeRequest\x1a&.gospeak.livedemo.v1.SubscribeResponse\x12]\n" +
"\n" +
"StopStream\x12&.gospeak.livedemo.v1.StopStreamRequest\x1a'.gospeak.livedemo.v1.StopStreamResponse\x12T\n" +
"\tWatchRoom\x12%.gospeak.livedemo.v1.WatchRoomRequest\x1a\x1e.gospeak.livedemo.v1.RoomEvent0\x01B\x1bZ\x19gospeak-live-sfu-demo/genb\x06proto3"
"\tWatchRoom\x12%.gospeak.livedemo.v1.WatchRoomRequest\x1a\x1e.gospeak.livedemo.v1.RoomEvent0\x01\x12N\n" +
"\x05Login\x12!.gospeak.livedemo.v1.LoginRequest\x1a\".gospeak.livedemo.v1.LoginResponse\x12W\n" +
"\bRegister\x12$.gospeak.livedemo.v1.RegisterRequest\x1a%.gospeak.livedemo.v1.RegisterResponse\x12N\n" +
"\x05GetMe\x12!.gospeak.livedemo.v1.GetMeRequest\x1a\".gospeak.livedemo.v1.GetMeResponse\x12Z\n" +
"\tListUsers\x12%.gospeak.livedemo.v1.ListUsersRequest\x1a&.gospeak.livedemo.v1.ListUsersResponse\x12i\n" +
"\x0eUpdateUserRole\x12*.gospeak.livedemo.v1.UpdateUserRoleRequest\x1a+.gospeak.livedemo.v1.UpdateUserRoleResponseB\x1bZ\x19gospeak-live-sfu-demo/genb\x06proto3"
var (
file_live_sfu_proto_rawDescOnce sync.Once
@ -1063,7 +1677,7 @@ func file_live_sfu_proto_rawDescGZIP() []byte {
}
var file_live_sfu_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_live_sfu_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_live_sfu_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_live_sfu_proto_goTypes = []any{
(BackendKind)(0), // 0: gospeak.livedemo.v1.BackendKind
(*IceServer)(nil), // 1: gospeak.livedemo.v1.IceServer
@ -1082,6 +1696,17 @@ var file_live_sfu_proto_goTypes = []any{
(*StopStreamResponse)(nil), // 14: gospeak.livedemo.v1.StopStreamResponse
(*WatchRoomRequest)(nil), // 15: gospeak.livedemo.v1.WatchRoomRequest
(*RoomEvent)(nil), // 16: gospeak.livedemo.v1.RoomEvent
(*UserInfo)(nil), // 17: gospeak.livedemo.v1.UserInfo
(*LoginRequest)(nil), // 18: gospeak.livedemo.v1.LoginRequest
(*LoginResponse)(nil), // 19: gospeak.livedemo.v1.LoginResponse
(*RegisterRequest)(nil), // 20: gospeak.livedemo.v1.RegisterRequest
(*RegisterResponse)(nil), // 21: gospeak.livedemo.v1.RegisterResponse
(*GetMeRequest)(nil), // 22: gospeak.livedemo.v1.GetMeRequest
(*GetMeResponse)(nil), // 23: gospeak.livedemo.v1.GetMeResponse
(*ListUsersRequest)(nil), // 24: gospeak.livedemo.v1.ListUsersRequest
(*ListUsersResponse)(nil), // 25: gospeak.livedemo.v1.ListUsersResponse
(*UpdateUserRoleRequest)(nil), // 26: gospeak.livedemo.v1.UpdateUserRoleRequest
(*UpdateUserRoleResponse)(nil), // 27: gospeak.livedemo.v1.UpdateUserRoleResponse
}
var file_live_sfu_proto_depIdxs = []int32{
0, // 0: gospeak.livedemo.v1.BackendInfo.kind:type_name -> gospeak.livedemo.v1.BackendKind
@ -1096,23 +1721,38 @@ var file_live_sfu_proto_depIdxs = []int32{
1, // 9: gospeak.livedemo.v1.SubscribeResponse.ice_servers:type_name -> gospeak.livedemo.v1.IceServer
0, // 10: gospeak.livedemo.v1.StopStreamRequest.backend:type_name -> gospeak.livedemo.v1.BackendKind
5, // 11: gospeak.livedemo.v1.RoomEvent.targets:type_name -> gospeak.livedemo.v1.StreamTarget
2, // 12: gospeak.livedemo.v1.LiveSFU.GetConfig:input_type -> gospeak.livedemo.v1.GetConfigRequest
7, // 13: gospeak.livedemo.v1.LiveSFU.ListRooms:input_type -> gospeak.livedemo.v1.ListRoomsRequest
9, // 14: gospeak.livedemo.v1.LiveSFU.Publish:input_type -> gospeak.livedemo.v1.PublishRequest
11, // 15: gospeak.livedemo.v1.LiveSFU.Subscribe:input_type -> gospeak.livedemo.v1.SubscribeRequest
13, // 16: gospeak.livedemo.v1.LiveSFU.StopStream:input_type -> gospeak.livedemo.v1.StopStreamRequest
15, // 17: gospeak.livedemo.v1.LiveSFU.WatchRoom:input_type -> gospeak.livedemo.v1.WatchRoomRequest
4, // 18: gospeak.livedemo.v1.LiveSFU.GetConfig:output_type -> gospeak.livedemo.v1.GetConfigResponse
8, // 19: gospeak.livedemo.v1.LiveSFU.ListRooms:output_type -> gospeak.livedemo.v1.ListRoomsResponse
10, // 20: gospeak.livedemo.v1.LiveSFU.Publish:output_type -> gospeak.livedemo.v1.PublishResponse
12, // 21: gospeak.livedemo.v1.LiveSFU.Subscribe:output_type -> gospeak.livedemo.v1.SubscribeResponse
14, // 22: gospeak.livedemo.v1.LiveSFU.StopStream:output_type -> gospeak.livedemo.v1.StopStreamResponse
16, // 23: gospeak.livedemo.v1.LiveSFU.WatchRoom:output_type -> gospeak.livedemo.v1.RoomEvent
18, // [18:24] is the sub-list for method output_type
12, // [12:18] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
17, // 12: gospeak.livedemo.v1.LoginResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
17, // 13: gospeak.livedemo.v1.RegisterResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
17, // 14: gospeak.livedemo.v1.GetMeResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
17, // 15: gospeak.livedemo.v1.ListUsersResponse.users:type_name -> gospeak.livedemo.v1.UserInfo
17, // 16: gospeak.livedemo.v1.UpdateUserRoleResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
2, // 17: gospeak.livedemo.v1.LiveSFU.GetConfig:input_type -> gospeak.livedemo.v1.GetConfigRequest
7, // 18: gospeak.livedemo.v1.LiveSFU.ListRooms:input_type -> gospeak.livedemo.v1.ListRoomsRequest
9, // 19: gospeak.livedemo.v1.LiveSFU.Publish:input_type -> gospeak.livedemo.v1.PublishRequest
11, // 20: gospeak.livedemo.v1.LiveSFU.Subscribe:input_type -> gospeak.livedemo.v1.SubscribeRequest
13, // 21: gospeak.livedemo.v1.LiveSFU.StopStream:input_type -> gospeak.livedemo.v1.StopStreamRequest
15, // 22: gospeak.livedemo.v1.LiveSFU.WatchRoom:input_type -> gospeak.livedemo.v1.WatchRoomRequest
18, // 23: gospeak.livedemo.v1.LiveSFU.Login:input_type -> gospeak.livedemo.v1.LoginRequest
20, // 24: gospeak.livedemo.v1.LiveSFU.Register:input_type -> gospeak.livedemo.v1.RegisterRequest
22, // 25: gospeak.livedemo.v1.LiveSFU.GetMe:input_type -> gospeak.livedemo.v1.GetMeRequest
24, // 26: gospeak.livedemo.v1.LiveSFU.ListUsers:input_type -> gospeak.livedemo.v1.ListUsersRequest
26, // 27: gospeak.livedemo.v1.LiveSFU.UpdateUserRole:input_type -> gospeak.livedemo.v1.UpdateUserRoleRequest
4, // 28: gospeak.livedemo.v1.LiveSFU.GetConfig:output_type -> gospeak.livedemo.v1.GetConfigResponse
8, // 29: gospeak.livedemo.v1.LiveSFU.ListRooms:output_type -> gospeak.livedemo.v1.ListRoomsResponse
10, // 30: gospeak.livedemo.v1.LiveSFU.Publish:output_type -> gospeak.livedemo.v1.PublishResponse
12, // 31: gospeak.livedemo.v1.LiveSFU.Subscribe:output_type -> gospeak.livedemo.v1.SubscribeResponse
14, // 32: gospeak.livedemo.v1.LiveSFU.StopStream:output_type -> gospeak.livedemo.v1.StopStreamResponse
16, // 33: gospeak.livedemo.v1.LiveSFU.WatchRoom:output_type -> gospeak.livedemo.v1.RoomEvent
19, // 34: gospeak.livedemo.v1.LiveSFU.Login:output_type -> gospeak.livedemo.v1.LoginResponse
21, // 35: gospeak.livedemo.v1.LiveSFU.Register:output_type -> gospeak.livedemo.v1.RegisterResponse
23, // 36: gospeak.livedemo.v1.LiveSFU.GetMe:output_type -> gospeak.livedemo.v1.GetMeResponse
25, // 37: gospeak.livedemo.v1.LiveSFU.ListUsers:output_type -> gospeak.livedemo.v1.ListUsersResponse
27, // 38: gospeak.livedemo.v1.LiveSFU.UpdateUserRole:output_type -> gospeak.livedemo.v1.UpdateUserRoleResponse
28, // [28:39] is the sub-list for method output_type
17, // [17:28] is the sub-list for method input_type
17, // [17:17] is the sub-list for extension type_name
17, // [17:17] is the sub-list for extension extendee
0, // [0:17] is the sub-list for field type_name
}
func init() { file_live_sfu_proto_init() }
@ -1126,7 +1766,7 @@ func file_live_sfu_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_live_sfu_proto_rawDesc), len(file_live_sfu_proto_rawDesc)),
NumEnums: 1,
NumMessages: 16,
NumMessages: 27,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -25,6 +25,11 @@ const (
LiveSFU_Subscribe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Subscribe"
LiveSFU_StopStream_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/StopStream"
LiveSFU_WatchRoom_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/WatchRoom"
LiveSFU_Login_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Login"
LiveSFU_Register_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Register"
LiveSFU_GetMe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/GetMe"
LiveSFU_ListUsers_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/ListUsers"
LiveSFU_UpdateUserRole_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/UpdateUserRole"
)
// LiveSFUClient is the client API for LiveSFU service.
@ -43,6 +48,12 @@ type LiveSFUClient interface {
Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error)
StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, error)
WatchRoom(ctx context.Context, in *WatchRoomRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[RoomEvent], error)
// ---- Auth & Casbin ----
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
GetMe(ctx context.Context, in *GetMeRequest, opts ...grpc.CallOption) (*GetMeResponse, error)
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
UpdateUserRole(ctx context.Context, in *UpdateUserRoleRequest, opts ...grpc.CallOption) (*UpdateUserRoleResponse, error)
}
type liveSFUClient struct {
@ -122,6 +133,56 @@ func (c *liveSFUClient) WatchRoom(ctx context.Context, in *WatchRoomRequest, opt
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LiveSFU_WatchRoomClient = grpc.ServerStreamingClient[RoomEvent]
func (c *liveSFUClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, LiveSFU_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterResponse)
err := c.cc.Invoke(ctx, LiveSFU_Register_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) GetMe(ctx context.Context, in *GetMeRequest, opts ...grpc.CallOption) (*GetMeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMeResponse)
err := c.cc.Invoke(ctx, LiveSFU_GetMe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUsersResponse)
err := c.cc.Invoke(ctx, LiveSFU_ListUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) UpdateUserRole(ctx context.Context, in *UpdateUserRoleRequest, opts ...grpc.CallOption) (*UpdateUserRoleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserRoleResponse)
err := c.cc.Invoke(ctx, LiveSFU_UpdateUserRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// LiveSFUServer is the server API for LiveSFU service.
// All implementations should embed UnimplementedLiveSFUServer
// for forward compatibility.
@ -138,6 +199,12 @@ type LiveSFUServer interface {
Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error)
StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error)
WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error
// ---- Auth & Casbin ----
Login(context.Context, *LoginRequest) (*LoginResponse, error)
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error)
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error)
}
// UnimplementedLiveSFUServer should be embedded to have
@ -165,6 +232,21 @@ func (UnimplementedLiveSFUServer) StopStream(context.Context, *StopStreamRequest
func (UnimplementedLiveSFUServer) WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error {
return status.Errorf(codes.Unimplemented, "method WatchRoom not implemented")
}
func (UnimplementedLiveSFUServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedLiveSFUServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedLiveSFUServer) GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMe not implemented")
}
func (UnimplementedLiveSFUServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented")
}
func (UnimplementedLiveSFUServer) UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserRole not implemented")
}
func (UnimplementedLiveSFUServer) testEmbeddedByValue() {}
// UnsafeLiveSFUServer may be embedded to opt out of forward compatibility for this service.
@ -286,6 +368,96 @@ func _LiveSFU_WatchRoom_Handler(srv interface{}, stream grpc.ServerStream) error
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LiveSFU_WatchRoomServer = grpc.ServerStreamingServer[RoomEvent]
func _LiveSFU_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Register_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_GetMe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).GetMe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_GetMe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).GetMe(ctx, req.(*GetMeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).ListUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_ListUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).ListUsers(ctx, req.(*ListUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_UpdateUserRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserRoleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).UpdateUserRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_UpdateUserRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).UpdateUserRole(ctx, req.(*UpdateUserRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
// LiveSFU_ServiceDesc is the grpc.ServiceDesc for LiveSFU service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -313,6 +485,26 @@ var LiveSFU_ServiceDesc = grpc.ServiceDesc{
MethodName: "StopStream",
Handler: _LiveSFU_StopStream_Handler,
},
{
MethodName: "Login",
Handler: _LiveSFU_Login_Handler,
},
{
MethodName: "Register",
Handler: _LiveSFU_Register_Handler,
},
{
MethodName: "GetMe",
Handler: _LiveSFU_GetMe_Handler,
},
{
MethodName: "ListUsers",
Handler: _LiveSFU_ListUsers_Handler,
},
{
MethodName: "UpdateUserRole",
Handler: _LiveSFU_UpdateUserRole_Handler,
},
},
Streams: []grpc.StreamDesc{
{

20
go.mod
View File

@ -1,17 +1,25 @@
module gospeak-live-sfu-demo
go 1.24.0
toolchain go1.24.5
go 1.25.0
require (
github.com/casbin/casbin/v2 v2.135.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
golang.org/x/crypto v0.55.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
require (
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)

44
go.sum
View File

@ -1,15 +1,33 @@
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
@ -22,12 +40,24 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
@ -36,3 +66,5 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

99
internal/auth/auth.go Normal file
View File

@ -0,0 +1,99 @@
package auth
import (
"fmt"
"time"
)
type Manager struct {
Store *UserStore
JWT *JWTManager
Enforcer *EnforcerWrapper
}
func NewManager(jwtSecret string, jwtTTL time.Duration, modelPath, policyPath, userFile string) (*Manager, error) {
store, err := NewUserStore(userFile)
if err != nil {
return nil, fmt.Errorf("init user store: %w", err)
}
jwtMgr := NewJWTManager(jwtSecret, jwtTTL)
enc, err := NewEnforcer(modelPath, policyPath, store)
if err != nil {
return nil, fmt.Errorf("init casbin: %w", err)
}
m := &Manager{Store: store, JWT: jwtMgr, Enforcer: enc}
return m, nil
}
func NewManagerWithStore(store *UserStore, jwtSecret string, ttl time.Duration, enforcer *EnforcerWrapper) *Manager {
return &Manager{
Store: store,
JWT: NewJWTManager(jwtSecret, ttl),
Enforcer: enforcer,
}
}
func (m *Manager) Login(username, password string) (string, *User, error) {
u, ok := m.Store.Verify(username, password)
if !ok {
return "", nil, fmt.Errorf("invalid credentials")
}
_ = m.Enforcer.AddUserRole(u.Username, u.Role)
token, err := m.JWT.Sign(u.Username, u.Role)
if err != nil {
return "", nil, err
}
ret := &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt}
return token, ret, nil
}
func (m *Manager) Register(username, password, role string) (string, *User, error) {
if role == "" {
role = RoleViewer
}
u, err := m.Store.Create(username, password, role)
if err != nil {
return "", nil, err
}
_ = m.Enforcer.AddUserRole(u.Username, u.Role)
token, err := m.JWT.Sign(u.Username, u.Role)
if err != nil {
return "", nil, err
}
return token, u, nil
}
func (m *Manager) VerifyToken(token string) (*Claims, *User, error) {
claims, err := m.JWT.Verify(token)
if err != nil {
return nil, nil, err
}
if u, ok := m.Store.Get(claims.Username); ok {
claims.Role = u.Role
return claims, &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt}, nil
}
return claims, &User{Username: claims.Username, Role: claims.Role}, nil
}
func (m *Manager) Check(username, role, obj, act string) (bool, error) {
if role == "" {
if u, ok := m.Store.Get(username); ok {
role = u.Role
} else {
role = RoleGuest
}
}
sub := username
if sub == "" {
sub = role
}
return m.Enforcer.Enforce(sub, obj, act)
}
func (m *Manager) UpdateUserRole(targetUser, newRole string) error {
if err := m.Store.UpdateRole(targetUser, newRole); err != nil {
return err
}
return m.Enforcer.AddUserRole(targetUser, newRole)
}

167
internal/auth/auth_test.go Normal file
View File

@ -0,0 +1,167 @@
package auth
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
)
func TestUserStore(t *testing.T) {
store, _ := NewUserStore("")
if _, ok := store.Get("admin"); !ok {
t.Fatal("admin should exist")
}
_, ok := store.Verify("admin", "Admin123!")
if !ok {
t.Fatal("admin password should verify")
}
if _, ok := store.Verify("admin", "wrong"); ok {
t.Fatal("wrong password should fail")
}
if _, err := store.Create("bob", "secret123", "viewer"); err != nil {
t.Fatalf("create bob: %v", err)
}
if _, err := store.Create("bob", "secret123", "viewer"); err == nil {
t.Fatal("duplicate should fail")
}
}
func TestJWT(t *testing.T) {
mgr := NewJWTManager("test-secret", time.Hour)
tok, err := mgr.Sign("alice", "admin")
if err != nil {
t.Fatalf("sign: %v", err)
}
claims, err := mgr.Verify(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Username != "alice" || claims.Role != "admin" {
t.Fatalf("claims mismatch: %+v", claims)
}
if _, err := mgr.Verify(tok + "x"); err == nil {
t.Fatal("tampered should fail")
}
mgr2 := NewJWTManager("test-secret", -time.Hour)
tok2, _ := mgr2.Sign("alice", "admin")
if _, err := mgr2.Verify(tok2); err == nil {
t.Fatal("expired should fail")
}
}
func TestCasbinEnforcer(t *testing.T) {
tmpPol := os.TempDir() + "/test_policy.csv"
data, _ := os.ReadFile("policy.csv")
_ = os.WriteFile(tmpPol, data, 0644)
defer os.Remove(tmpPol)
store, _ := NewUserStore("")
_, _ = store.Create("alice", "pass12345", "viewer")
_, _ = store.Create("pub", "pass12345", "publisher")
enc, err := NewEnforcer("model.conf", tmpPol, store)
if err != nil {
t.Fatalf("new enforcer: %v", err)
}
ok, _ := enc.Enforce("admin", "room", "publish")
if !ok {
t.Fatal("admin should can publish")
}
ok, _ = enc.Enforce("viewer", "room", "publish")
if ok {
t.Fatal("viewer should not publish")
}
ok, _ = enc.Enforce("viewer", "room", "subscribe")
if !ok {
t.Fatal("viewer should can subscribe")
}
ok, _ = enc.Enforce("publisher", "room", "publish")
if !ok {
t.Fatal("publisher should can publish")
}
ok, _ = enc.Enforce("guest", "room", "watch")
if ok {
t.Fatal("guest should not watch")
}
ok, _ = enc.Enforce("alice", "room", "subscribe")
if !ok {
t.Fatal("alice (viewer) should can subscribe")
}
ok, _ = enc.Enforce("alice", "room", "publish")
if ok {
t.Fatal("alice (viewer) should not publish")
}
}
func TestAuthManagerLogin(t *testing.T) {
tmpPol := os.TempDir() + "/test_policy2.csv"
data, _ := os.ReadFile("policy.csv")
_ = os.WriteFile(tmpPol, data, 0644)
defer os.Remove(tmpPol)
store, _ := NewUserStore("")
enc, _ := NewEnforcer("model.conf", tmpPol, store)
mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc)
tok, user, err := mgr.Login("admin", "Admin123!")
if err != nil {
t.Fatalf("login admin: %v", err)
}
if tok == "" || user.Username != "admin" {
t.Fatalf("login result bad: %v %v", tok, user)
}
if _, _, err := mgr.Login("admin", "wrong"); err == nil {
t.Fatal("wrong pwd should fail")
}
tok2, u2, err := mgr.Register("newuser", "pass12345", "viewer")
if err != nil {
t.Fatalf("register: %v", err)
}
if tok2 == "" || u2.Role != "viewer" {
t.Fatalf("register result bad")
}
claims, _, err := mgr.VerifyToken(tok)
if err != nil || claims.Username != "admin" {
t.Fatalf("verify token: %v %+v", err, claims)
}
}
func TestHTTPMiddleware(t *testing.T) {
tmpPol := os.TempDir() + "/test_policy3.csv"
data, _ := os.ReadFile("policy.csv")
_ = os.WriteFile(tmpPol, data, 0644)
defer os.Remove(tmpPol)
store, _ := NewUserStore("")
enc, _ := NewEnforcer("model.conf", tmpPol, store)
mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc)
protected := mgr.AuthorizeMiddleware("room", "publish", true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
tokViewer, _, _ := mgr.Login("viewer", "Viewer123!")
req2 := httptest.NewRequest("GET", "/", nil)
req2.Header.Set("Authorization", "Bearer "+tokViewer)
rec2 := httptest.NewRecorder()
protected.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusForbidden {
t.Fatalf("viewer should be forbidden, got %d body=%s", rec2.Code, rec2.Body.String())
}
tokPub, _, _ := mgr.Login("publisher", "Publisher123!")
req3 := httptest.NewRequest("GET", "/", nil)
req3.Header.Set("Authorization", "Bearer "+tokPub)
rec3 := httptest.NewRecorder()
protected.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("publisher should ok, got %d body=%s", rec3.Code, rec3.Body.String())
}
}

141
internal/auth/enforcer.go Normal file
View File

@ -0,0 +1,141 @@
package auth
import (
"fmt"
"path/filepath"
"sync"
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
fileadapter "github.com/casbin/casbin/v2/persist/file-adapter"
)
type EnforcerWrapper struct {
mu sync.RWMutex
enforcer *casbin.Enforcer
store *UserStore
modelPath string
policyPath string
}
func NewEnforcer(modelPath, policyPath string, store *UserStore) (*EnforcerWrapper, error) {
m, err := model.NewModelFromFile(modelPath)
if err != nil {
return nil, fmt.Errorf("load casbin model: %w", err)
}
adapter := fileadapter.NewAdapter(policyPath)
enforcer, err := casbin.NewEnforcer(m, adapter)
if err != nil {
return nil, fmt.Errorf("new enforcer: %w", err)
}
if err := enforcer.LoadPolicy(); err != nil {
return nil, fmt.Errorf("load policy: %w", err)
}
w := &EnforcerWrapper{enforcer: enforcer, store: store, modelPath: modelPath, policyPath: policyPath}
w.syncGrouping()
return w, nil
}
func NewEnforcerWithModelText(modelText string, policyPath string, store *UserStore) (*EnforcerWrapper, error) {
m, err := model.NewModelFromString(modelText)
if err != nil {
return nil, err
}
var adapter = fileadapter.NewAdapter(policyPath)
e, err := casbin.NewEnforcer(m, adapter)
if err != nil {
return nil, err
}
_ = e.LoadPolicy()
w := &EnforcerWrapper{enforcer: e, store: store}
w.syncGrouping()
return w, nil
}
func (w *EnforcerWrapper) syncGrouping() {
if w.store == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
for _, u := range w.store.List() {
_, _ = w.enforcer.AddGroupingPolicy(u.Username, u.Role)
}
for _, role := range AllRoles() {
_, _ = w.enforcer.AddGroupingPolicy(role, role)
}
_, _ = w.enforcer.AddGroupingPolicy(RoleGuest, RoleGuest)
}
func (w *EnforcerWrapper) AddUserRole(username, role string) error {
w.mu.Lock()
defer w.mu.Unlock()
roles, _ := w.enforcer.GetRolesForUser(username)
for _, r := range roles {
if r != role {
_, _ = w.enforcer.RemoveGroupingPolicy(username, r)
}
}
added, err := w.enforcer.AddGroupingPolicy(username, role)
if err != nil {
return err
}
if added {
_ = w.enforcer.SavePolicy()
}
return nil
}
func (w *EnforcerWrapper) RemoveUser(username string) error {
w.mu.Lock()
defer w.mu.Unlock()
_, err := w.enforcer.RemoveFilteredGroupingPolicy(0, username)
if err != nil {
return err
}
_ = w.enforcer.SavePolicy()
return nil
}
func (w *EnforcerWrapper) Enforce(sub, obj, act string) (bool, error) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.enforcer.Enforce(sub, obj, act)
}
func (w *EnforcerWrapper) EnforceWithRole(username, role, obj, act string) (bool, error) {
sub := username
if sub == "" {
sub = role
if sub == "" {
sub = RoleGuest
}
}
return w.Enforce(sub, obj, act)
}
func (w *EnforcerWrapper) GetEnforcer() *casbin.Enforcer { return w.enforcer }
func (w *EnforcerWrapper) ReloadPolicy() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.enforcer.LoadPolicy()
}
func (w *EnforcerWrapper) SavePolicy() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.enforcer.SavePolicy()
}
func (w *EnforcerWrapper) ModelPath() string { return w.modelPath }
func (w *EnforcerWrapper) PolicyPath() string { return w.policyPath }
func EnsurePolicyFile(path string) string {
if path == "" {
return ""
}
abs, _ := filepath.Abs(path)
return abs
}

70
internal/auth/jwt.go Normal file
View File

@ -0,0 +1,70 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
ttl time.Duration
issuer string
}
func NewJWTManager(secret string, ttl time.Duration) *JWTManager {
if secret == "" {
secret = "insecure-jwt-secret-change-me"
}
if ttl == 0 {
ttl = 2 * time.Hour
}
return &JWTManager{secret: []byte(secret), ttl: ttl, issuer: "live-sfu-demo"}
}
func (j *JWTManager) Sign(username, role string) (string, error) {
now := time.Now()
claims := Claims{
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: j.issuer,
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)),
NotBefore: jwt.NewNumericDate(now),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString(j.secret)
}
func (j *JWTManager) Verify(tokenStr string) (*Claims, error) {
if tokenStr == "" {
return nil, errors.New("empty token")
}
tok, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return j.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := tok.Claims.(*Claims)
if !ok || !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func (j *JWTManager) TTL() time.Duration { return j.ttl }

210
internal/auth/middleware.go Normal file
View File

@ -0,0 +1,210 @@
package auth
import (
"context"
"net/http"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type contextKey string
const (
ContextUserKey contextKey = "auth_user"
ContextRoleKey contextKey = "auth_role"
ContextClaimsKey contextKey = "auth_claims"
)
type AuthedUser struct {
Username string
Role string
Claims *Claims
Token string
}
func FromContext(ctx context.Context) (*AuthedUser, bool) {
u, ok := ctx.Value(ContextUserKey).(*AuthedUser)
return u, ok
}
func WithContext(ctx context.Context, u *AuthedUser) context.Context {
ctx = context.WithValue(ctx, ContextUserKey, u)
ctx = context.WithValue(ctx, ContextRoleKey, u.Role)
return ctx
}
func (m *Manager) HTTPMiddleware(next http.Handler, requireAuth bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
var authed *AuthedUser
if token != "" {
if claims, user, err := m.VerifyToken(token); err == nil {
authed = &AuthedUser{Username: claims.Username, Role: user.Role, Claims: claims, Token: token}
_ = m.Enforcer.AddUserRole(authed.Username, authed.Role)
}
}
if authed == nil {
authed = &AuthedUser{Username: "", Role: RoleGuest}
if requireAuth {
http.Error(w, "unauthorized: missing or invalid token", http.StatusUnauthorized)
return
}
}
ctx := WithContext(r.Context(), authed)
r = r.WithContext(ctx)
r.Header.Set("X-Auth-User", authed.Username)
r.Header.Set("X-Auth-Role", authed.Role)
next.ServeHTTP(w, r)
})
}
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return m.HTTPMiddleware(next, true)
}
func extractToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
return strings.TrimSpace(h[7:])
}
}
if c, err := r.Cookie("token"); err == nil && c.Value != "" {
return c.Value
}
if q := r.URL.Query().Get("token"); q != "" {
return q
}
if h := r.Header.Get("X-Token"); h != "" {
return h
}
return ""
}
func (m *Manager) Authorize(r *http.Request, obj, act string) (bool, error) {
u, _ := FromContext(r.Context())
username := ""
role := RoleGuest
if u != nil {
username = u.Username
role = u.Role
}
return m.Check(username, role, obj, act)
}
func (m *Manager) AuthorizeMiddleware(obj, act string, needAuth bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
var authed *AuthedUser
if token != "" {
if claims, user, err := m.VerifyToken(token); err == nil {
authed = &AuthedUser{Username: claims.Username, Role: user.Role, Claims: claims, Token: token}
}
}
if authed == nil {
authed = &AuthedUser{Username: "", Role: RoleGuest}
if needAuth {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
ctx := WithContext(r.Context(), authed)
r = r.WithContext(ctx)
ok, err := m.Check(authed.Username, authed.Role, obj, act)
if err != nil {
http.Error(w, "auth error: "+err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, "forbidden: role "+authed.Role+" cannot "+act+" "+obj, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func (m *Manager) UnaryAuthInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
ctx = m.contextWithGRPCAuth(ctx)
return handler(ctx, req)
}
}
func (m *Manager) StreamAuthInterceptor() grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
ctx := m.contextWithGRPCAuth(ss.Context())
wrapped := &grpcWrappedStream{ServerStream: ss, ctx: ctx}
return handler(srv, wrapped)
}
}
func (m *Manager) contextWithGRPCAuth(ctx context.Context) context.Context {
token := extractGRPC_TOKEN(ctx)
var authed *AuthedUser
if token != "" {
if claims, user, err := m.VerifyToken(token); err == nil {
authed = &AuthedUser{Username: claims.Username, Role: user.Role, Claims: claims, Token: token}
}
}
if authed == nil {
authed = &AuthedUser{Username: "", Role: RoleGuest}
}
return WithContext(ctx, authed)
}
func extractGRPC_TOKEN(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
for _, key := range []string{"authorization", "token", "x-token"} {
if vals := md.Get(key); len(vals) > 0 {
v := vals[0]
if strings.HasPrefix(strings.ToLower(v), "bearer ") {
return strings.TrimSpace(v[7:])
}
return strings.TrimSpace(v)
}
}
return ""
}
func RequireGRPCAuth(ctx context.Context) error {
u, ok := FromContext(ctx)
if !ok || u.Username == "" || u.Role == RoleGuest {
return status.Error(codes.Unauthenticated, "unauthorized: missing token")
}
return nil
}
func (m *Manager) CheckGRPC(ctx context.Context, obj, act string) error {
u, _ := FromContext(ctx)
username := ""
role := RoleGuest
if u != nil {
username = u.Username
role = u.Role
}
ok, err := m.Check(username, role, obj, act)
if err != nil {
return status.Errorf(codes.Internal, "auth error: %v", err)
}
if !ok {
return status.Errorf(codes.PermissionDenied, "forbidden: role %s cannot %s %s", role, act, obj)
}
return nil
}
type grpcWrappedStream struct {
grpc.ServerStream
ctx context.Context
}
func (w *grpcWrappedStream) Context() context.Context { return w.ctx }

14
internal/auth/model.conf Normal file
View File

@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

30
internal/auth/policy.csv Normal file
View File

@ -0,0 +1,30 @@
# Casbin RBAC 策略
# p, 角色, 资源, 动作
p, admin, config, read
p, admin, room, list
p, admin, room, publish
p, admin, room, subscribe
p, admin, room, stop
p, admin, room, watch
p, admin, user, list
p, admin, user, manage
p, admin, srs, streams
p, publisher, config, read
p, publisher, room, list
p, publisher, room, publish
p, publisher, room, subscribe
p, publisher, room, stop
p, publisher, room, watch
p, publisher, srs, streams
p, viewer, config, read
p, viewer, room, list
p, viewer, room, subscribe
p, viewer, room, watch
p, viewer, srs, streams
p, guest, config, read
# 默认 g 关系由代码动态维护,亦可在此预设示例用户
# g, alice, admin
1 # Casbin RBAC 策略
2 # p, 角色, 资源, 动作
3 p, admin, config, read
4 p, admin, room, list
5 p, admin, room, publish
6 p, admin, room, subscribe
7 p, admin, room, stop
8 p, admin, room, watch
9 p, admin, user, list
10 p, admin, user, manage
11 p, admin, srs, streams
12 p, publisher, config, read
13 p, publisher, room, list
14 p, publisher, room, publish
15 p, publisher, room, subscribe
16 p, publisher, room, stop
17 p, publisher, room, watch
18 p, publisher, srs, streams
19 p, viewer, config, read
20 p, viewer, room, list
21 p, viewer, room, subscribe
22 p, viewer, room, watch
23 p, viewer, srs, streams
24 p, guest, config, read
25 # 默认 g 关系由代码动态维护,亦可在此预设示例用户
26 # g, alice, admin

222
internal/auth/store.go Normal file
View File

@ -0,0 +1,222 @@
package auth
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// User 表示系统用户
type User struct {
Username string `json:"username"`
Password string `json:"-"` // bcrypt hash,不序列化到前端
Hash string `json:"hash,omitempty"` // 持久化用
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
}
// UserStore 负责用户持久化与校验,内存为主,可选落盘到 JSON 文件
type UserStore struct {
mu sync.RWMutex
users map[string]*User
filePath string
}
// NewUserStore 创建用户存储,若 filePath 非空则尝试从文件加载;若文件不存在则预置种子用户
func NewUserStore(filePath string) (*UserStore, error) {
s := &UserStore{
users: make(map[string]*User),
filePath: filePath,
}
if filePath != "" {
if err := s.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("load users: %w", err)
}
s.seed()
_ = s.save()
} else if len(s.users) == 0 {
s.seed()
_ = s.save()
}
} else {
s.seed()
}
return s, nil
}
func (s *UserStore) seed() {
seeds := []struct {
username string
password string
role string
}{
{"admin", "Admin123!", RoleAdmin},
{"publisher", "Publisher123!", RolePublisher},
{"viewer", "Viewer123!", RoleViewer},
}
for _, u := range seeds {
hash, _ := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
s.users[u.username] = &User{
Username: u.username,
Password: string(hash),
Hash: string(hash),
Role: u.role,
CreatedAt: time.Now().Unix(),
}
}
}
func (s *UserStore) Get(username string) (*User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[username]
if !ok {
return nil, false
}
cp := *u
return &cp, true
}
func (s *UserStore) List() []*User {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*User, 0, len(s.users))
for _, u := range s.users {
out = append(out, &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt})
}
return out
}
func (s *UserStore) Create(username, password, role string) (*User, error) {
if username == "" || password == "" {
return nil, fmt.Errorf("username and password required")
}
if role == "" {
role = RoleViewer
}
if !isValidRole(role) {
return nil, fmt.Errorf("invalid role %q", role)
}
if len(password) < 6 {
return nil, fmt.Errorf("password too short (min 6)")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[username]; exists {
return nil, fmt.Errorf("user %q already exists", username)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &User{
Username: username,
Password: string(hash),
Hash: string(hash),
Role: role,
CreatedAt: time.Now().Unix(),
}
s.users[username] = u
_ = s.saveLocked()
cp := *u
cp.Password = ""
cp.Hash = ""
return &cp, nil
}
func (s *UserStore) Verify(username, password string) (*User, bool) {
s.mu.RLock()
u, ok := s.users[username]
s.mu.RUnlock()
if !ok {
return nil, false
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
return nil, false
}
cp := *u
return &cp, true
}
func (s *UserStore) UpdateRole(username, role string) error {
if !isValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[username]
if !ok {
return fmt.Errorf("user %q not found", username)
}
u.Role = role
return s.saveLocked()
}
func (s *UserStore) load() error {
data, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
var list []*User
if err := json.Unmarshal(data, &list); err != nil {
return err
}
for _, u := range list {
if u.Password == "" && u.Hash != "" {
u.Password = u.Hash
}
if u.Hash == "" && u.Password != "" {
u.Hash = u.Password
}
s.users[u.Username] = u
}
return nil
}
func (s *UserStore) save() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.saveLocked()
}
func (s *UserStore) saveLocked() error {
if s.filePath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.filePath), 0755); err != nil {
return err
}
list := make([]*User, 0, len(s.users))
for _, u := range s.users {
if u.Hash == "" {
u.Hash = u.Password
}
list = append(list, u)
}
data, _ := json.MarshalIndent(list, "", " ")
return os.WriteFile(s.filePath, data, 0644)
}
const (
RoleAdmin = "admin"
RolePublisher = "publisher"
RoleViewer = "viewer"
RoleGuest = "guest"
)
func isValidRole(r string) bool {
switch r {
case RoleAdmin, RolePublisher, RoleViewer:
return true
default:
return false
}
}
func AllRoles() []string { return []string{RoleAdmin, RolePublisher, RoleViewer} }

View File

@ -3,16 +3,19 @@ package config
import (
"os"
"strings"
"time"
)
// Config 承载直播 SFU 分流 Demo 的运行参数。
// 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。
// 默认 DB 使用 Turso 嵌入式 (libSQL file) 持久化房间拓扑;同时支持 JWT/Casbin 登录。
type Config struct {
HTTPPort string
GRPCPort string
ProviderOrder string // 逗号分隔的后端顺序,如 "cloudflare,srs"
SRSBaseURL string
SRSHttpURL string
SRSApp string
SRSSecret string
SRSCandidate string
@ -24,6 +27,16 @@ type Config struct {
TokenSecret string
TokenRequired bool
JWTSecret string
JWTTTL time.Duration
AuthModel string
AuthPolicy string
AuthUserFile string
AllowRegister bool
DatabaseURL string // TURSO_DATABASE_URL 或 DATABASE_URL,默认 file:./data/live-sfu.db
AuthToken string // 兼容远程 Turso,嵌入模式可为空
}
func Load() *Config {
@ -33,6 +46,7 @@ func Load() *Config {
ProviderOrder: getenv("SFU_PROVIDER", "cloudflare,srs"),
SRSBaseURL: getenv("SRS_API_BASE", "http://localhost:1985"),
SRSHttpURL: getenv("SRS_HTTP_BASE", "http://localhost:8080"),
SRSApp: getenv("SRS_APP", "live"),
SRSSecret: getenv("SRS_SECRET", ""),
SRSCandidate: getenv("SRS_CANDIDATE", "127.0.0.1"),
@ -44,10 +58,40 @@ func Load() *Config {
TokenSecret: getenv("DEMO_TOKEN_SECRET", ""),
TokenRequired: getenv("SFU_TOKEN_REQUIRED", "0") == "1",
JWTSecret: getenv("JWT_SECRET", ""),
JWTTTL: parseDuration(getenv("JWT_TTL", "2h"), 2*time.Hour),
AuthModel: getenv("CASBIN_MODEL", "internal/auth/model.conf"),
AuthPolicy: getenv("CASBIN_POLICY", "internal/auth/policy.csv"),
AuthUserFile: getenv("AUTH_USER_FILE", "data/users.json"),
AllowRegister: getenv("ALLOW_REGISTER", "1") == "1",
DatabaseURL: getenv("TURSO_DATABASE_URL", "file:./data/live-sfu.db?cache=shared&_journal_mode=WAL"),
AuthToken: getenv("TURSO_AUTH_TOKEN", ""),
}
if c.TokenSecret == "" {
c.TokenSecret = "insecure-demo-secret-change-me"
}
if c.JWTSecret == "" {
c.JWTSecret = c.TokenSecret
if c.JWTSecret == "" {
c.JWTSecret = "insecure-jwt-secret-change-me"
}
}
if c.DatabaseURL == "file:./data/live-sfu.db?cache=shared&_journal_mode=WAL" {
if v := os.Getenv("DATABASE_URL"); v != "" {
c.DatabaseURL = v
} else if v := os.Getenv("LIBSQL_URL"); v != "" {
c.DatabaseURL = v
}
}
if c.AuthToken == "" {
if v := os.Getenv("TURSO_AUTH_TOKEN"); v != "" {
c.AuthToken = v
} else if v := os.Getenv("LIBSQL_AUTH_TOKEN"); v != "" {
c.AuthToken = v
}
}
return c
}
@ -58,7 +102,16 @@ func getenv(k, def string) string {
return def
}
// ProviderList 返回去重、保序的后端名列表。
func parseDuration(s string, def time.Duration) time.Duration {
if s == "" {
return def
}
if d, err := time.ParseDuration(s); err == nil {
return d
}
return def
}
func (c *Config) ProviderList() []string {
parts := strings.Split(c.ProviderOrder, ",")
out := make([]string, 0, len(parts))
@ -83,3 +136,18 @@ func (c *Config) ProviderList() []string {
}
return out
}
func (c *Config) DSN() string {
if c.AuthToken != "" && strings.HasPrefix(c.DatabaseURL, "libsql://") && !strings.Contains(c.DatabaseURL, "authToken") {
sep := "?"
if strings.Contains(c.DatabaseURL, "?") {
sep = "&"
}
return c.DatabaseURL + sep + "authToken=" + c.AuthToken
}
return c.DatabaseURL
}
func (c *Config) IsRemoteTurso() bool {
return strings.HasPrefix(c.DatabaseURL, "libsql://") || strings.HasPrefix(c.DatabaseURL, "https://")
}

174
internal/db/db.go Normal file
View File

@ -0,0 +1,174 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/tursodatabase/go-libsql"
"gospeak-live-sfu-demo/gen"
)
// Open 打开 Turso 嵌入式数据库(libSQL file)。
// 仅支持嵌入模式:file:./data/live-sfu.db / :memory: / file::memory:
// 已禁用远程 libsql://,如需远程请另行扩展。
func Open(dsn string) (*sql.DB, error) {
if strings.HasPrefix(dsn, "libsql://") || strings.HasPrefix(dsn, "https://") {
return nil, fmt.Errorf("embedded mode only: remote Turso DSN not supported (%q), use file: DSN", dsn)
}
if dsn == "" {
dsn = "file:./data/live-sfu.db?cache=shared&_journal_mode=WAL"
}
// 确保本地文件目录存在
if strings.HasPrefix(dsn, "file:") {
pathPart := strings.TrimPrefix(dsn, "file:")
if idx := strings.Index(pathPart, "?"); idx >= 0 {
pathPart = pathPart[:idx]
}
if pathPart != "" && pathPart != ":memory:" && pathPart != ":memory" {
dir := filepath.Dir(pathPart)
if dir != "." && dir != "" && dir != "/" {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("[db] mkdir %s: %v", dir, err)
}
}
}
}
db, err := sql.Open("libsql", dsn)
if err != nil {
return nil, fmt.Errorf("open db %q: %w", dsn, err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping db %q: %w", dsn, err)
}
if err := Migrate(db); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
log.Printf("[db] turso embedded opened: %s", dsn)
return db, nil
}
// Migrate 创建所需表结构(幂等)。
func Migrate(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS stream_targets (
room TEXT NOT NULL,
backend TEXT NOT NULL,
session_id TEXT,
stream TEXT,
publish_token TEXT,
url TEXT,
published_at INTEGER,
PRIMARY KEY (room, backend)
)`,
`CREATE INDEX IF NOT EXISTS idx_stream_targets_room ON stream_targets(room)`,
`CREATE TABLE IF NOT EXISTS rooms (
name TEXT PRIMARY KEY,
created_at INTEGER,
updated_at INTEGER
)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return fmt.Errorf("exec %q: %w", s, err)
}
}
return nil
}
func StringToBackendKind(s string) gen.BackendKind {
switch strings.ToLower(strings.TrimSpace(s)) {
case "cloudflare":
return gen.BackendKind_BACKEND_KIND_CLOUDFLARE
case "srs":
return gen.BackendKind_BACKEND_KIND_SRS
default:
return gen.BackendKind_BACKEND_KIND_UNSPECIFIED
}
}
// SaveTarget 持久化单个分发目标(INSERT OR REPLACE)。
func SaveTarget(ctx context.Context, db *sql.DB, room, backend string, t *gen.StreamTarget) error {
if db == nil {
return nil
}
_, err := db.ExecContext(ctx, `INSERT OR REPLACE INTO stream_targets (room, backend, session_id, stream, publish_token, url, published_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
room, backend, t.GetSessionId(), t.GetStream(), t.GetPublishToken(), t.GetUrl(), t.GetPublishedAt())
if err != nil {
return err
}
_, _ = db.ExecContext(ctx, `INSERT OR IGNORE INTO rooms (name, created_at, updated_at) VALUES (?, ?, ?)`, room, t.GetPublishedAt(), t.GetPublishedAt())
_, _ = db.ExecContext(ctx, `UPDATE rooms SET updated_at=? WHERE name=?`, t.GetPublishedAt(), room)
return nil
}
// DeleteTarget 删除指定房间在某后端的目标。
func DeleteTarget(ctx context.Context, db *sql.DB, room, backend string) error {
if db == nil {
return nil
}
_, err := db.ExecContext(ctx, `DELETE FROM stream_targets WHERE room=? AND backend=?`, room, backend)
if err != nil {
return err
}
var cnt int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM stream_targets WHERE room=?`, room).Scan(&cnt); err == nil && cnt == 0 {
_, _ = db.ExecContext(ctx, `DELETE FROM rooms WHERE name=?`, room)
}
return nil
}
// LoadAll 读取所有房间的全部目标,按 room 分组。
func LoadAll(ctx context.Context, db *sql.DB) (map[string]map[string]*gen.StreamTarget, error) {
if db == nil {
return map[string]map[string]*gen.StreamTarget{}, nil
}
rows, err := db.QueryContext(ctx, `SELECT room, backend, session_id, stream, publish_token, url, published_at FROM stream_targets`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]map[string]*gen.StreamTarget{}
for rows.Next() {
var room, backend string
var sp, se, pu, ur sql.NullString
var pa sql.NullInt64
if err := rows.Scan(&room, &backend, &sp, &se, &pu, &ur, &pa); err != nil {
return nil, err
}
target := &gen.StreamTarget{
Backend: StringToBackendKind(backend),
}
if sp.Valid {
target.SessionId = sp.String
}
if se.Valid {
target.Stream = se.String
}
if pu.Valid {
target.PublishToken = pu.String
}
if ur.Valid {
target.Url = ur.String
}
if pa.Valid {
target.PublishedAt = pa.Int64
}
if _, ok := out[room]; !ok {
out[room] = map[string]*gen.StreamTarget{}
}
out[room][backend] = target
}
return out, rows.Err()
}

View File

@ -0,0 +1,247 @@
package server
import (
"encoding/json"
"net/http"
"time"
"gospeak-live-sfu-demo/internal/auth"
)
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role,omitempty"`
}
type authResponse struct {
Token string `json:"token"`
Username string `json:"username"`
Role string `json:"role"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
http.Error(w, "username and password required", http.StatusBadRequest)
return
}
token, user, err := s.auth.Login(req.Username, req.Password)
if err != nil {
http.Error(w, "login failed: "+err.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(s.auth.JWT.TTL()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResponse{
Token: token,
Username: user.Username,
Role: user.Role,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
if !s.cfg.AllowRegister {
http.Error(w, "registration disabled", http.StatusForbidden)
return
}
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
http.Error(w, "username and password required", http.StatusBadRequest)
return
}
role := req.Role
if role == "" {
role = auth.RoleViewer
}
if role == auth.RoleAdmin || role == auth.RolePublisher {
token := extractAuthToken(r)
if token != "" {
if claims, _, err := s.auth.VerifyToken(token); err == nil && claims.Role == auth.RoleAdmin {
} else {
role = auth.RoleViewer
}
} else {
role = auth.RoleViewer
}
}
token, user, err := s.auth.Register(req.Username, req.Password, role)
if err != nil {
http.Error(w, "register failed: "+err.Error(), http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(s.auth.JWT.TTL()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResponse{
Token: token,
Username: user.Username,
Role: user.Role,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "logged out"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
token := extractAuthToken(r)
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, user, err := s.auth.VerifyToken(token)
if err != nil {
http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"username": user.Username,
"role": user.Role,
"expires_at": claims.ExpiresAt.Unix(),
"issued_at": claims.IssuedAt.Unix(),
})
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
token := extractAuthToken(r)
claims, _, err := s.auth.VerifyToken(token)
if err != nil || claims.Role != auth.RoleAdmin {
http.Error(w, "forbidden: admin only", http.StatusForbidden)
return
}
users := s.auth.Store.List()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}
func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
token := extractAuthToken(r)
claims, _, err := s.auth.VerifyToken(token)
if err != nil || claims.Role != auth.RoleAdmin {
http.Error(w, "forbidden: admin only", http.StatusForbidden)
return
}
var req struct {
Username string `json:"username"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if req.Username == "" || req.Role == "" {
http.Error(w, "username and role required", http.StatusBadRequest)
return
}
if err := s.auth.UpdateUserRole(req.Username, req.Role); err != nil {
http.Error(w, "update failed: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "username": req.Username, "role": req.Role})
}
func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"enabled": false})
return
}
token := extractAuthToken(r)
if token == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"enabled": true, "authenticated": false, "role": auth.RoleGuest})
return
}
claims, user, err := s.auth.VerifyToken(token)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"enabled": true, "authenticated": false, "error": err.Error()})
return
}
perms := map[string]bool{}
for _, act := range []string{"list", "publish", "subscribe", "watch", "stop"} {
ok, _ := s.auth.Check(claims.Username, claims.Role, "room", act)
perms["room:"+act] = ok
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"enabled": true,
"authenticated": true,
"username": user.Username,
"role": user.Role,
"permissions": perms,
})
}
func extractAuthToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if len(h) > 7 && (h[:7] == "Bearer " || h[:7] == "bearer ") {
return h[7:]
}
}
if c, err := r.Cookie("token"); err == nil && c.Value != "" {
return c.Value
}
if q := r.URL.Query().Get("token"); q != "" {
return q
}
if h := r.Header.Get("X-Token"); h != "" {
return h
}
return ""
}

View File

@ -6,7 +6,6 @@ import (
"gospeak-live-sfu-demo/gen"
)
// grpcServer 把 Service 适配为 protobuf 生成的 gRPC 服务端接口。
type grpcServer struct {
gen.UnimplementedLiveSFUServer
svc *Service
@ -39,3 +38,23 @@ func (g *grpcServer) StopStream(ctx context.Context, req *gen.StopStreamRequest)
func (g *grpcServer) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error {
return g.svc.WatchRoom(req, stream)
}
func (g *grpcServer) Login(ctx context.Context, req *gen.LoginRequest) (*gen.LoginResponse, error) {
return g.svc.Login(ctx, req)
}
func (g *grpcServer) Register(ctx context.Context, req *gen.RegisterRequest) (*gen.RegisterResponse, error) {
return g.svc.Register(ctx, req)
}
func (g *grpcServer) GetMe(ctx context.Context, req *gen.GetMeRequest) (*gen.GetMeResponse, error) {
return g.svc.GetMe(ctx, req)
}
func (g *grpcServer) ListUsers(ctx context.Context, req *gen.ListUsersRequest) (*gen.ListUsersResponse, error) {
return g.svc.ListUsers(ctx, req)
}
func (g *grpcServer) UpdateUserRole(ctx context.Context, req *gen.UpdateUserRoleRequest) (*gen.UpdateUserRoleResponse, error) {
return g.svc.UpdateUserRole(ctx, req)
}

View File

@ -29,6 +29,31 @@ func (s *Server) srsProxyHandler() http.Handler {
})
}
// srsHlsProxyHandler 反向代理 SRS http_server 的 HLS/FLV 静态资源(8080)。
// 浏览器通过 Go 网关拉 HLS,避免直连 8080 的跨域和端口暴露问题。
// 支持 /live/*.m3u8 /live/*.ts /live/*.flv
func (s *Server) srsHlsProxyHandler() http.Handler {
target, err := url.Parse(s.cfg.SRSHttpURL)
if err != nil {
target, _ = url.Parse("http://localhost:8080")
}
rp := httputil.NewSingleHostReverseProxy(target)
origDirector := rp.Director
rp.Director = func(r *http.Request) {
origDirector(r)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
rp.ServeHTTP(w, r)
})
}
// cfProxyHandler 反向代理 Cloudflare Realtime REST,注入 AppSecret。
// 浏览器只交换 SDP(tracks/new),凭证不出服务端。
func (s *Server) cfProxyHandler() http.Handler {

View File

@ -1,17 +1,24 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"log"
"sync"
"time"
"gospeak-live-sfu-demo/gen"
"gospeak-live-sfu-demo/internal/db"
)
// roomHub 维护房间 -> 各后端分发目标(StreamTarget)的内存状态,
// 并向订阅者广播拓扑变化(实现「分流」状态实时可见)。
// 默认由 Turso 嵌入式 (libSQL file) 持久化。
type roomHub struct {
mu sync.RWMutex
rooms map[string]*roomEntry
db *sql.DB
}
type roomEntry struct {
@ -23,6 +30,34 @@ func newRoomHub() *roomHub {
return &roomHub{rooms: map[string]*roomEntry{}}
}
// newRoomHubWithDB 创建带 Turso 嵌入式持久化的 hub,启动时自动从 DB 加载全量房间。
func newRoomHubWithDB(database *sql.DB) *roomHub {
h := &roomHub{rooms: map[string]*roomEntry{}, db: database}
if database != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
all, err := db.LoadAll(ctx, database)
if err != nil {
log.Printf("[hub] load from turso embedded failed: %v", err)
} else {
for room, backends := range all {
e := &roomEntry{targets: map[string]*gen.StreamTarget{}, subs: map[chan []byte]struct{}{}}
for backend, t := range backends {
e.targets[backend] = t
}
h.rooms[room] = e
}
if len(all) > 0 {
log.Printf("[hub] loaded %d rooms from turso embedded", len(all))
}
}
}
return h
}
// DB 返回底层 *sql.DB,供优雅关闭。
func (h *roomHub) DB() *sql.DB { return h.db }
func (h *roomHub) get(name string) *roomEntry {
e, ok := h.rooms[name]
if !ok {
@ -34,25 +69,50 @@ func (h *roomHub) get(name string) *roomEntry {
func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
h.mu.Lock()
defer h.mu.Unlock()
e := h.get(room)
e.targets[backend] = t
h.broadcast(room, e)
h.mu.Unlock()
if h.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.SaveTarget(ctx, h.db, room, backend, t); err != nil {
log.Printf("[hub] save target %s/%s to turso embedded: %v", room, backend, err)
}
}
}
func (h *roomHub) removeTarget(room, backend string) {
h.mu.Lock()
defer h.mu.Unlock()
e, ok := h.rooms[room]
if !ok {
h.mu.Unlock()
return
}
delete(e.targets, backend)
if len(e.targets) == 0 && len(e.subs) == 0 {
delete(h.rooms, room)
h.mu.Unlock()
if h.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.DeleteTarget(ctx, h.db, room, backend); err != nil {
log.Printf("[hub] delete target %s/%s: %v", room, backend, err)
}
}
return
}
h.broadcast(room, e)
h.mu.Unlock()
if h.db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.DeleteTarget(ctx, h.db, room, backend); err != nil {
log.Printf("[hub] delete target %s/%s: %v", room, backend, err)
}
}
}
func (h *roomHub) targets(room string) []*gen.StreamTarget {

View File

@ -4,13 +4,16 @@ import (
"bytes"
"embed"
"io/fs"
"log"
"net"
"net/http"
"time"
"google.golang.org/grpc"
"gospeak-live-sfu-demo/gen"
"gospeak-live-sfu-demo/internal/auth"
"gospeak-live-sfu-demo/internal/config"
"gospeak-live-sfu-demo/internal/db"
"gospeak-live-sfu-demo/internal/sfu/cloudflare"
"gospeak-live-sfu-demo/internal/sfu/srs"
)
@ -18,67 +21,136 @@ import (
//go:embed static
var staticFS embed.FS
// Server 聚合控制面(gRPC + JSON 网关)、媒体面反向代理与静态 UI。
// Server 聚合控制面(gRPC + JSON 网关)、媒体面反向代理与静态 UI,并集成登录与 Casbin 鉴权。
type Server struct {
cfg *config.Config
svc *Service
hub *roomHub
srsProxy http.Handler
cfProxy http.Handler
auth *auth.Manager
}
func New(cfg *config.Config) *Server {
hub := newRoomHub()
var hub *roomHub
if cfg.DatabaseURL != "" {
if dbConn, err := db.Open(cfg.DatabaseURL); err != nil {
log.Printf("[warn] turso db open failed (%v), falling back to memory hub: %s", err, cfg.DatabaseURL)
hub = newRoomHub()
} else {
hub = newRoomHubWithDB(dbConn)
log.Printf("[db] turso enabled: dsn=%s remote=%v", cfg.DSN(), cfg.IsRemoteTurso())
}
} else {
hub = newRoomHub()
}
cf := cloudflare.NewProvider(cfg.CFAppID, cfg.CFAppSecret, cfg.CFBaseURL, cfg.CFStunURL)
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
svc := NewService(cfg, cf, srsP, hub)
s := &Server{cfg: cfg, svc: svc, hub: hub}
s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler()
mgr, err := auth.NewManager(cfg.JWTSecret, cfg.JWTTTL, cfg.AuthModel, cfg.AuthPolicy, cfg.AuthUserFile)
if err != nil {
log.Printf("[warn] auth manager init failed (%v), falling back to memory-only", err)
store, _ := auth.NewUserStore("")
mgr2 := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTTTL)
_ = mgr2
_ = store
} else {
s.auth = mgr
log.Printf("[auth] casbin enabled: model=%s policy=%s users=%s", cfg.AuthModel, cfg.AuthPolicy, cfg.AuthUserFile)
svc.auth = mgr
}
return s
}
// StartGRPC 启动 gRPC 控制面(protobuf 契约的服务端实现)。
func NewWithHub(cfg *config.Config, hub *roomHub) *Server {
cf := cloudflare.NewProvider(cfg.CFAppID, cfg.CFAppSecret, cfg.CFBaseURL, cfg.CFStunURL)
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
svc := NewService(cfg, cf, srsP, hub)
s := &Server{cfg: cfg, svc: svc, hub: hub}
s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler()
mgr, err := auth.NewManager(cfg.JWTSecret, cfg.JWTTTL, cfg.AuthModel, cfg.AuthPolicy, cfg.AuthUserFile)
if err == nil {
s.auth = mgr
svc.auth = mgr
}
return s
}
func (s *Server) Auth() *auth.Manager { return s.auth }
func (s *Server) StartGRPC() error {
lis, err := net.Listen("tcp", ":"+s.cfg.GRPCPort)
if err != nil {
return err
}
gs := grpc.NewServer()
var opts []grpc.ServerOption
if s.auth != nil {
opts = append(opts,
grpc.UnaryInterceptor(s.auth.UnaryAuthInterceptor()),
grpc.StreamInterceptor(s.auth.StreamAuthInterceptor()),
)
}
gs := grpc.NewServer(opts...)
gen.RegisterLiveSFUServer(gs, NewGRPCServer(s.svc))
go func() { _ = gs.Serve(lis) }()
return nil
}
// Handler 返回 HTTP 路由:静态 UI、JSON 网关、SSE、SRS/Cloudflare 媒体反代。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
sub, _ := fs.Sub(staticFS, "static")
mux.Handle("GET /", s.fileServer("static/index.html"))
mux.Handle("GET /publish", s.fileServer("static/publish.html"))
mux.Handle("GET /watch", s.fileServer("static/watch.html"))
mux.Handle("GET /login", s.fileServer("static/login.html"))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("GET /api/rooms", s.handleRooms)
mux.HandleFunc("POST /api/publish", s.handlePublish)
mux.HandleFunc("POST /api/subscribe", s.handleSubscribe)
mux.HandleFunc("POST /api/stop", s.handleStop)
mux.HandleFunc("GET /api/srs/streams", s.handleSRSStreams)
mux.Handle("GET /api/room/{room}/events", http.HandlerFunc(s.handleRoomEvents))
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/register", s.handleRegister)
mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
mux.Handle("GET /api/auth/me", s.authWrap(http.HandlerFunc(s.handleMe), "user", "list", true))
mux.Handle("GET /api/auth/users", s.authWrap(http.HandlerFunc(s.handleListUsers), "user", "list", true))
mux.Handle("POST /api/auth/users/role", s.authWrap(http.HandlerFunc(s.handleUpdateRole), "user", "manage", true))
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
mux.Handle("GET /rtc/v1/", s.srsProxy)
mux.Handle("POST /rtc/v1/", s.srsProxy)
mux.Handle("PUT /rtc/v1/", s.srsProxy)
mux.Handle("DELETE /rtc/v1/", s.srsProxy)
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("POST /api/publish", s.authWrap(http.HandlerFunc(s.handlePublish), "room", "publish", 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("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("GET /api/cf/", s.cfProxy)
mux.Handle("POST /api/cf/", s.cfProxy)
mux.Handle("PUT /api/cf/", s.cfProxy)
mux.Handle("DELETE /api/cf/", s.cfProxy)
mux.Handle("GET /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("POST /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("PUT /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("DELETE /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("GET /api/cf/", s.authWrap(s.cfProxy, "room", "watch", false))
mux.Handle("POST /api/cf/", s.authWrap(s.cfProxy, "room", "publish", false))
mux.Handle("PUT /api/cf/", s.authWrap(s.cfProxy, "room", "publish", false))
mux.Handle("DELETE /api/cf/", s.authWrap(s.cfProxy, "room", "publish", false))
return mux
}
func (s *Server) authWrap(next http.Handler, obj, act string, needAuth bool) http.Handler {
if s.auth == nil {
return next
}
return s.auth.AuthorizeMiddleware(obj, act, needAuth)(next)
}
func (s *Server) Close() error {
if s.hub != nil && s.hub.DB() != nil {
return s.hub.DB().Close()
}
return nil
}
func (s *Server) fileServer(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := staticFS.ReadFile(name)

View File

@ -6,24 +6,34 @@ import (
"time"
"gospeak-live-sfu-demo/gen"
"gospeak-live-sfu-demo/internal/auth"
"gospeak-live-sfu-demo/internal/config"
"gospeak-live-sfu-demo/internal/sfu/cloudflare"
"gospeak-live-sfu-demo/internal/sfu/srs"
)
// Service 实现 LiveSFU 控制面逻辑:协调房间在各 SFU 后端的发布/订阅拓扑。
// 媒体面(SDP 交换)由浏览器经反向代理直连 Cloudflare / SRS,本服务只持有凭证。
type Service struct {
cfg *config.Config
cf *cloudflare.Provider
srsP *srs.Provider
hub *roomHub
auth *auth.Manager
}
func NewService(cfg *config.Config, cf *cloudflare.Provider, srsP *srs.Provider, hub *roomHub) *Service {
return &Service{cfg: cfg, cf: cf, srsP: srsP, hub: hub}
}
func (s *Service) SetAuth(a *auth.Manager) { s.auth = a }
func (s *Service) checkAuth(ctx context.Context, obj, act string) error {
if s.auth == nil {
return nil
}
return s.auth.CheckGRPC(ctx, obj, act)
}
func backendName(k gen.BackendKind) string {
switch k {
case gen.BackendKind_BACKEND_KIND_CLOUDFLARE:
@ -35,7 +45,6 @@ func backendName(k gen.BackendKind) string {
}
}
// GetConfig 返回后端能力与全局拓扑信息(浏览器据此渲染分发面板)。
func (s *Service) GetConfig(ctx context.Context, req *gen.GetConfigRequest) (*gen.GetConfigResponse, error) {
resp := &gen.GetConfigResponse{
Candidate: s.srsP.Candidate(),
@ -53,11 +62,16 @@ func (s *Service) GetConfig(ctx context.Context, req *gen.GetConfigRequest) (*ge
}
func (s *Service) ListRooms(ctx context.Context, req *gen.ListRoomsRequest) (*gen.ListRoomsResponse, error) {
if err := s.checkAuth(ctx, "room", "list"); err != nil {
return nil, err
}
return &gen.ListRoomsResponse{Rooms: s.hub.list()}, nil
}
// Publish 开始向某后端发布:Cloudflare 创建 session;SRS 分配 stream + 签发 JWT。
func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.PublishResponse, error) {
if err := s.checkAuth(ctx, "room", "publish"); err != nil {
return nil, err
}
room := req.GetRoom()
if room == "" {
return nil, fmt.Errorf("room required")
@ -70,7 +84,6 @@ func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.Pu
if identity == "" {
identity = randomID()
}
switch backend {
case "cloudflare":
if !s.cf.Configured() {
@ -95,7 +108,6 @@ func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.Pu
IceServers: iceServersToProto(info.IceServers),
Target: target,
}, nil
case "srs":
stream := "live-" + room
token, _ := signStreamToken(s.cfg.TokenSecret, stream, identity, "publish", 2*time.Hour)
@ -117,8 +129,10 @@ func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.Pu
return nil, fmt.Errorf("unsupported backend")
}
// Subscribe 订阅某房间在某后端的分发目标,返回建立 WebRTC 所需的 session / stream。
func (s *Service) Subscribe(ctx context.Context, req *gen.SubscribeRequest) (*gen.SubscribeResponse, error) {
if err := s.checkAuth(ctx, "room", "subscribe"); err != nil {
return nil, err
}
room := req.GetRoom()
if room == "" {
return nil, fmt.Errorf("room required")
@ -131,7 +145,6 @@ func (s *Service) Subscribe(ctx context.Context, req *gen.SubscribeRequest) (*ge
if pub == nil {
return nil, fmt.Errorf("room %q not live on %s", room, backend)
}
switch backend {
case "cloudflare":
viewerSession, err := s.cf.Client().CreateSession(room)
@ -157,6 +170,9 @@ func (s *Service) Subscribe(ctx context.Context, req *gen.SubscribeRequest) (*ge
}
func (s *Service) StopStream(ctx context.Context, req *gen.StopStreamRequest) (*gen.StopStreamResponse, error) {
if err := s.checkAuth(ctx, "room", "stop"); err != nil {
return nil, err
}
room := req.GetRoom()
backend := backendName(req.GetBackend())
if room == "" || backend == "" {
@ -173,8 +189,10 @@ func (s *Service) StopStream(ctx context.Context, req *gen.StopStreamRequest) (*
return &gen.StopStreamResponse{Ok: true}, nil
}
// WatchRoom 服务端流式推送房间分发拓扑(分流)变化。
func (s *Service) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error {
if err := s.checkAuth(stream.Context(), "room", "watch"); err != nil {
return err
}
room := req.GetRoom()
ch, unsub := s.hub.subscribe(room)
defer unsub()
@ -199,6 +217,112 @@ func (s *Service) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchR
}
}
func (s *Service) Login(ctx context.Context, req *gen.LoginRequest) (*gen.LoginResponse, error) {
if s.auth == nil {
return nil, fmt.Errorf("auth not configured")
}
if req.GetUsername() == "" || req.GetPassword() == "" {
return nil, fmt.Errorf("username and password required")
}
token, user, err := s.auth.Login(req.GetUsername(), req.GetPassword())
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
return &gen.LoginResponse{
Token: token,
User: &gen.UserInfo{Username: user.Username, Role: user.Role, CreatedAt: user.CreatedAt},
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
}, nil
}
func (s *Service) Register(ctx context.Context, req *gen.RegisterRequest) (*gen.RegisterResponse, error) {
if s.auth == nil {
return nil, fmt.Errorf("auth not configured")
}
if !s.cfg.AllowRegister {
return nil, fmt.Errorf("registration disabled")
}
if req.GetUsername() == "" || req.GetPassword() == "" {
return nil, fmt.Errorf("username and password required")
}
role := req.GetRole()
if role == "" {
role = auth.RoleViewer
}
if role == auth.RoleAdmin || role == auth.RolePublisher {
u, _ := auth.FromContext(ctx)
if u == nil || u.Role != auth.RoleAdmin {
role = auth.RoleViewer
}
}
token, user, err := s.auth.Register(req.GetUsername(), req.GetPassword(), role)
if err != nil {
return nil, fmt.Errorf("register failed: %w", err)
}
return &gen.RegisterResponse{
Token: token,
User: &gen.UserInfo{Username: user.Username, Role: user.Role, CreatedAt: user.CreatedAt},
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
}, nil
}
func (s *Service) GetMe(ctx context.Context, req *gen.GetMeRequest) (*gen.GetMeResponse, error) {
if s.auth == nil {
return nil, fmt.Errorf("auth not configured")
}
u, ok := auth.FromContext(ctx)
if !ok || u.Username == "" {
return nil, fmt.Errorf("unauthenticated")
}
if u.Token != "" {
if claims, user, err := s.auth.VerifyToken(u.Token); err == nil {
return &gen.GetMeResponse{
User: &gen.UserInfo{Username: user.Username, Role: user.Role, CreatedAt: user.CreatedAt},
ExpiresAt: claims.ExpiresAt.Unix(),
IssuedAt: claims.IssuedAt.Unix(),
}, nil
}
}
return &gen.GetMeResponse{
User: &gen.UserInfo{Username: u.Username, Role: u.Role},
}, nil
}
func (s *Service) ListUsers(ctx context.Context, req *gen.ListUsersRequest) (*gen.ListUsersResponse, error) {
if s.auth == nil {
return nil, fmt.Errorf("auth not configured")
}
if err := s.checkAuth(ctx, "user", "list"); err != nil {
return nil, err
}
users := s.auth.Store.List()
out := make([]*gen.UserInfo, 0, len(users))
for _, u := range users {
out = append(out, &gen.UserInfo{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt})
}
return &gen.ListUsersResponse{Users: out}, nil
}
func (s *Service) UpdateUserRole(ctx context.Context, req *gen.UpdateUserRoleRequest) (*gen.UpdateUserRoleResponse, error) {
if s.auth == nil {
return nil, fmt.Errorf("auth not configured")
}
if err := s.checkAuth(ctx, "user", "manage"); err != nil {
return nil, err
}
if req.GetUsername() == "" || req.GetRole() == "" {
return nil, fmt.Errorf("username and role required")
}
if err := s.auth.UpdateUserRole(req.GetUsername(), req.GetRole()); err != nil {
return nil, err
}
u, _ := s.auth.Store.Get(req.GetUsername())
return &gen.UpdateUserRoleResponse{
Ok: true,
User: &gen.UserInfo{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt},
}, nil
}
func (s *Service) findTarget(room string, kind gen.BackendKind) *gen.StreamTarget {
for _, t := range s.hub.targets(room) {
if t.GetBackend() == kind {

View File

@ -1,4 +1,5 @@
// 直播 SFU 分流 Demo 前端逻辑。控制面走 JSON 网关(protojson),媒体面走反向代理直连 SFU。
// 已集成登录与 Casbin 权限:自动附加 Authorization header,未登录重定向到 /login
window.LiveSFU = (function () {
const ENUM = { cloudflare: "BACKEND_KIND_CLOUDFLARE", srs: "BACKEND_KIND_SRS" };
const NAME = { BACKEND_KIND_CLOUDFLARE: "Cloudflare Realtime", BACKEND_KIND_SRS: "SRS" };
@ -9,21 +10,100 @@ window.LiveSFU = (function () {
}
function shortName(enumStr) { return NAME[enumStr] || enumStr; }
// ----- Auth helpers -----
function getToken() {
return localStorage.getItem("token") || "";
}
function setToken(t) {
if (t) localStorage.setItem("token", t);
}
function clearToken() {
localStorage.removeItem("token");
}
function authHeaders() {
const h = {};
const tok = getToken();
if (tok) h["Authorization"] = "Bearer " + tok;
return h;
}
function handleAuthError(status) {
if (status === 401) {
const next = encodeURIComponent(location.pathname + location.search);
if (!location.pathname.startsWith("/login")) {
location.href = "/login?next=" + next;
}
}
}
async function apiGet(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(path + " -> " + r.status);
const r = await fetch(path, { headers: { ...authHeaders() } });
if (!r.ok) {
handleAuthError(r.status);
throw new Error(path + " -> " + r.status + " " + (await r.text()));
}
return r.json();
}
async function apiGetWithAuth(path) {
const r = await fetch(path, { headers: { ...authHeaders() } });
const text = await r.text();
if (!r.ok) {
handleAuthError(r.status);
throw new Error(text || (path + " -> " + r.status));
}
return text ? JSON.parse(text) : {};
}
async function apiPost(path, body) {
const r = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) throw new Error((text || r.status));
if (!r.ok) {
handleAuthError(r.status);
throw new Error((text || r.status));
}
return text ? JSON.parse(text) : {};
}
async function apiPostWithAuth(path, body) {
return apiPost(path, body);
}
async function login(username, password) {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const text = await r.text();
if (!r.ok) throw new Error(text || ("login -> " + r.status));
const data = text ? JSON.parse(text) : {};
if (data.token) setToken(data.token);
return data;
}
async function register(username, password, role) {
const r = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ username, password, role }),
});
const text = await r.text();
if (!r.ok) throw new Error(text || ("register -> " + r.status));
const data = text ? JSON.parse(text) : {};
if (data.token) setToken(data.token);
return data;
}
async function getMe() {
return apiGetWithAuth("/api/auth/me");
}
async function authCheck() {
try {
const r = await fetch("/api/auth/check", { headers: { ...authHeaders() } });
return await r.json();
} catch(e) {
return { enabled: false };
}
}
async function loadConfig(el) {
try {
@ -35,6 +115,14 @@ window.LiveSFU = (function () {
span.textContent = shortName(b.kind) + (b.primary ? " · 主" : "") + (b.configured ? " · 就绪" : " · 未配置");
el.appendChild(span);
});
const chk = await authCheck();
const authBadge = document.createElement("span");
authBadge.className = "badge " + (chk.authenticated ? "ok" : "bad");
authBadge.textContent = chk.authenticated ? ("已登录:" + chk.username + " / " + chk.role) : "未登录";
authBadge.style.cursor="pointer";
authBadge.onclick=()=>location.href="/login";
authBadge.title="点击去登录";
el.appendChild(authBadge);
} catch (e) {
el.innerHTML = '<span class="badge bad">控制面不可达</span>';
}
@ -42,11 +130,10 @@ window.LiveSFU = (function () {
function log(el, msg) {
const t = new Date().toLocaleTimeString();
el.textContent += "[" + t + "] " + msg + "\n";
el.textContent += "[" + t + "] " + msg + "\\n";
el.scrollTop = el.scrollHeight;
}
// ---------- WebRTC:Cloudflare Realtime ----------
async function publishCF(sessionId, localStream, iceServers) {
const pc = new RTCPeerConnection({ iceServers });
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
@ -54,13 +141,17 @@ window.LiveSFU = (function () {
await pc.setLocalDescription(offer);
const resp = await fetch("/api/cf/sessions/" + sessionId + "/tracks/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
sessionDescription: { type: "offer", sdp: offer.sdp },
tracks: localStream.getTracks().map((t) => ({ location: "local", trackName: t.kind, kind: t.kind })),
autoDiscover: true,
}),
});
if (!resp.ok) {
handleAuthError(resp.status);
throw new Error("cf publish failed: " + resp.status);
}
const data = await resp.json();
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
return pc;
@ -73,7 +164,7 @@ window.LiveSFU = (function () {
await pc.setLocalDescription(offer);
const resp = await fetch("/api/cf/sessions/" + viewerSessionId + "/tracks/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
sessionDescription: { type: "offer", sdp: offer.sdp },
tracks: [
@ -83,19 +174,26 @@ window.LiveSFU = (function () {
autoDiscover: true,
}),
});
if (!resp.ok) {
handleAuthError(resp.status);
throw new Error("cf watch failed: " + resp.status);
}
const data = await resp.json();
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
return pc;
}
// ---------- WebRTC:SRS (WHIP/WHEP) ----------
async function publishSRS(room, token, stream, iceServers, localStream) {
const pc = new RTCPeerConnection({ iceServers });
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const url = "/rtc/v1/whip/?app=live&stream=" + encodeURIComponent(stream) + "&token=" + encodeURIComponent(token);
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp" }, body: offer.sdp });
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp", ...authHeaders() }, body: offer.sdp });
if (!resp.ok) {
handleAuthError(resp.status);
throw new Error("SRS publish failed: " + resp.status + " " + (await resp.text()));
}
const answer = await resp.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
return pc;
@ -107,7 +205,11 @@ window.LiveSFU = (function () {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const url = "/rtc/v1/whep/?app=live&stream=" + encodeURIComponent(stream);
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp" }, body: offer.sdp });
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp", ...authHeaders() }, body: offer.sdp });
if (!resp.ok) {
handleAuthError(resp.status);
throw new Error("SRS watch failed: " + resp.status + " " + (await resp.text()));
}
const answer = await resp.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
return pc;
@ -117,7 +219,6 @@ window.LiveSFU = (function () {
return (targets || []).find((t) => t.backend === enumStr);
}
// ---------- 发布页 ----------
function initPublish() {
const room = getRoom();
document.getElementById("roomName").textContent = room;
@ -125,7 +226,13 @@ window.LiveSFU = (function () {
const logEl = document.getElementById("log");
const statusEl = document.getElementById("status");
const pcs = {};
authCheck().then(chk=>{
if (!chk.authenticated) {
log(logEl, "提示:未登录,发布需要 publisher 权限,将自动跳转登录页");
} else if (chk.permissions && !chk.permissions["room:publish"]) {
log(logEl, "提示:当前角色 "+chk.role+" 无发布权限,需要 publisher 或 admin");
}
});
document.getElementById("start").onclick = async () => {
try {
const backends = Array.from(document.querySelectorAll('input[name="backend"]:checked')).map((i) => i.value);
@ -150,7 +257,6 @@ window.LiveSFU = (function () {
log(logEl, "获取摄像头失败:" + e.message);
}
};
document.getElementById("stop").onclick = async () => {
for (const b of Object.keys(pcs)) {
try { await apiPost("/api/stop", { room, backend: ENUM[b] }); } catch (e) {}
@ -170,7 +276,6 @@ window.LiveSFU = (function () {
: '<span class="muted">未发布</span>';
}
// ---------- 观看页 ----------
function initWatch() {
const room = getRoom();
document.getElementById("roomName").textContent = room;
@ -180,9 +285,14 @@ window.LiveSFU = (function () {
let pc = null;
let es = null;
let watching = false;
function backend() { return document.querySelector('input[name="watchBackend"]:checked').value; }
authCheck().then(chk=>{
if (!chk.authenticated) {
log(logEl, "提示:未登录,观看需要登录,将自动跳转");
} else if (chk.permissions && !chk.permissions["room:subscribe"]) {
log(logEl, "提示:当前角色 "+chk.role+" 无订阅权限");
}
});
async function subscribe() {
if (watching) return;
const b = backend();
@ -200,9 +310,10 @@ window.LiveSFU = (function () {
log(logEl, "订阅失败:" + e.message);
}
}
document.getElementById("start").onclick = () => {
es = new EventSource("/api/room/" + encodeURIComponent(room) + "/events");
const token = getToken();
const esUrl = "/api/room/" + encodeURIComponent(room) + "/events" + (token ? "?token="+encodeURIComponent(token) : "");
es = new EventSource(esUrl);
es.addEventListener("room", (ev) => {
const data = JSON.parse(ev.data);
const t = findTarget(data.targets, ENUM[backend()]);
@ -211,9 +322,11 @@ window.LiveSFU = (function () {
: '<span class="muted">等待 ' + shortName(ENUM[backend()]) + " 推流…</span>";
if (t && !watching) subscribe();
});
es.onerror = (e)=>{
log(logEl, "房间事件流错误,可能是未登录或权限不足");
};
log(logEl, "已连接房间事件流");
};
document.getElementById("stop").onclick = () => {
if (pc) pc.close();
pc = null;
@ -226,5 +339,5 @@ window.LiveSFU = (function () {
};
}
return { ENUM, getRoom, loadConfig, log, initPublish, initWatch, apiGet, apiPost };
return { ENUM, getRoom, loadConfig, log, initPublish, initWatch, apiGet, apiPost, apiGetWithAuth, apiPostWithAuth, login, register, getMe, authCheck, getToken, setToken, clearToken, authHeaders };
})();

View File

@ -12,6 +12,8 @@
<nav>
<a href="/publish">开始直播</a>
<a href="/watch">观看直播</a>
<a href="/login">登录</a>
<span id="navAuth" class="muted" style="margin-left:12px;"></span>
</nav>
</div>
<div class="band">
@ -28,7 +30,7 @@
<button onclick="goPublish()">开始直播</button>
<button class="secondary" onclick="goWatch()">观看直播</button>
</div>
<div class="muted" style="margin-top:10px">提示:推流与观看使用同一房间名即可配对。</div>
<div class="muted" style="margin-top:10px">提示:推流与观看使用同一房间名即可配对。需先登录(默认 admin/Admin123! / publisher/Publisher123! / viewer/Viewer123!)。</div>
</div>
<div class="card">
<h3>后端能力</h3>
@ -41,6 +43,10 @@
<script src="/static/app.js"></script>
<script>
LiveSFU.loadConfig(document.getElementById('backends'));
LiveSFU.authCheck().then(chk=>{
const el=document.getElementById('navAuth');
if(chk.authenticated){ el.textContent='已登录:'+chk.username+'/'+chk.role; const a=document.createElement('a'); a.href='#'; a.textContent=' 退出'; a.onclick=()=>{LiveSFU.clearToken(); location.reload();}; el.appendChild(a); } else { el.innerHTML='<a href="/login">未登录,点击登录</a>'; }
});
function goPublish() {
const r = document.getElementById('room').value.trim() || 'demo';
location.href = '/publish?room=' + encodeURIComponent(r);

View File

@ -0,0 +1,152 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 直播 SFU 分流 Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
<style>
.login-card { max-width: 420px; margin: 60px auto; }
.role-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; background:#eef; margin-left:6px; }
.tabs { display:flex; gap:8px; margin-bottom:16px; }
.tabs button { flex:1; }
.tabs button.active { background:#111; color:#fff; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>登录 · Casbin 权限</small></div>
<nav><a href="/">首页</a><a href="/publish">开始直播</a><a href="/watch">观看直播</a></nav>
</div>
<div class="band">
<div class="card login-card">
<h3 id="formTitle">登录</h3>
<div class="tabs">
<button id="tabLogin" class="active" onclick="switchTab('login')">登录</button>
<button id="tabRegister" onclick="switchTab('register')">注册</button>
</div>
<div class="muted" style="margin-bottom:12px">
演示账户:<code>admin / Admin123!</code>(管理员)<br/>
<code>publisher / Publisher123!</code>(可推流/拉流)<br/>
<code>viewer / Viewer123!</code>(仅观看)
<div id="authStatus" style="margin-top:8px;"></div>
</div>
<div>
<label>用户名</label>
<input type="text" id="username" placeholder="username" style="width:100%;margin:6px 0 10px;" />
<label>密码</label>
<input type="password" id="password" placeholder="password" style="width:100%;margin:6px 0 10px;" />
<div id="registerRoleWrap" style="display:none;">
<label>角色(仅管理员注册时可指定)</label>
<select id="role" style="width:100%;margin:6px 0 10px;">
<option value="viewer">viewer - 仅观看</option>
<option value="publisher">publisher - 可推流</option>
<option value="admin">admin - 管理员</option>
</select>
</div>
<div class="row" style="margin-top:10px">
<button id="submitBtn" onclick="submit()">登录</button>
<button class="secondary" onclick="logout()">退出登录</button>
</div>
<div id="msg" class="muted" style="margin-top:10px; white-space:pre-wrap;"></div>
</div>
<div id="me" class="muted" style="margin-top:16px; border-top:1px solid #eee; padding-top:12px;"></div>
<div id="adminPanel" style="display:none; margin-top:16px;">
<h4>用户管理(仅 admin)</h4>
<div id="users" class="targets"></div>
<div class="row" style="margin-top:8px;">
<input type="text" id="targetUser" placeholder="用户名" />
<select id="newRole">
<option value="viewer">viewer</option>
<option value="publisher">publisher</option>
<option value="admin">admin</option>
</select>
<button onclick="updateRole()">更新角色</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
let mode = 'login';
function switchTab(m) {
mode = m;
document.getElementById('tabLogin').classList.toggle('active', m==='login');
document.getElementById('tabRegister').classList.toggle('active', m==='register');
document.getElementById('formTitle').textContent = m==='login' ? '登录' : '注册';
document.getElementById('submitBtn').textContent = m==='login' ? '登录' : '注册';
document.getElementById('registerRoleWrap').style.display = m==='register' ? 'block' : 'none';
}
async function refreshMe() {
const meEl = document.getElementById('me');
const statusEl = document.getElementById('authStatus');
const adminPanel = document.getElementById('adminPanel');
try {
const data = await LiveSFU.apiGetWithAuth('/api/auth/me');
meEl.innerHTML = '已登录:<b>' + data.username + '</b> <span class="role-badge">'+data.role+'</span> <br/>过期:' + new Date(data.expires_at*1000).toLocaleString();
statusEl.innerHTML = '<span class="badge primary">已登录</span> ' + data.username + ' / ' + data.role;
if (data.role === 'admin') {
adminPanel.style.display = 'block';
try {
const users = await LiveSFU.apiGetWithAuth('/api/auth/users');
const container = document.getElementById('users');
container.innerHTML = '';
(users.users||[]).forEach(u=>{
const span=document.createElement('span');
span.className='badge';
span.textContent=u.username+' / '+u.role;
container.appendChild(span);
});
} catch(e) {
document.getElementById('users').innerHTML='<span class="muted">'+e.message+'</span>';
}
} else {
adminPanel.style.display='none';
}
} catch(e) {
meEl.textContent='未登录:'+e.message;
statusEl.innerHTML='<span class="badge bad">未登录</span> 请先登录';
adminPanel.style.display='none';
}
}
async function submit() {
const u=document.getElementById('username').value.trim();
const p=document.getElementById('password').value;
const role=document.getElementById('role').value;
const msg=document.getElementById('msg');
msg.textContent='提交中...';
try {
let data;
if (mode==='login') {
data = await LiveSFU.login(u,p);
} else {
data = await LiveSFU.register(u,p,role);
}
msg.textContent='成功:'+JSON.stringify(data,null,2);
await refreshMe();
} catch(e) {
msg.textContent='失败:'+e.message;
}
}
async function logout() {
await fetch('/api/auth/logout',{method:'POST', headers: LiveSFU.authHeaders()});
LiveSFU.clearToken();
document.getElementById('msg').textContent='已退出';
await refreshMe();
}
async function updateRole() {
const username=document.getElementById('targetUser').value.trim();
const role=document.getElementById('newRole').value;
const msg=document.getElementById('msg');
try {
const data=await LiveSFU.apiPostWithAuth('/api/auth/users/role', {username, role});
msg.textContent='更新成功:'+JSON.stringify(data);
await refreshMe();
} catch(e) {
msg.textContent='更新失败:'+e.message;
}
}
refreshMe();
</script>
</body>
</html>

View File

@ -9,7 +9,7 @@
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
<nav><a href="/">首页</a><a href="/watch">观看直播</a></nav>
<nav><a href="/">首页</a><a href="/watch">观看直播</a><a href="/login">登录</a><span id="navAuth" class="muted" style="margin-left:12px;"></span></nav>
</div>
<div class="band">
<div class="hero"><h1>开始直播</h1><p>房间 <b id="roomName">demo</b> · 选择分发后端,一路推流将扇出到所选 SFU。</p></div>
@ -38,6 +38,9 @@
</div>
</div>
<script src="/static/app.js"></script>
<script>LiveSFU.initPublish();</script>
<script>
LiveSFU.authCheck().then(chk=>{const el=document.getElementById('navAuth'); if(chk.authenticated){el.textContent='已登录:'+chk.username+'/'+chk.role;} else {el.innerHTML='<a href="/login">未登录</a>';}});
LiveSFU.initPublish();
</script>
</body>
</html>

View File

@ -9,7 +9,7 @@
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
<nav><a href="/">首页</a><a href="/publish">开始直播</a></nav>
<nav><a href="/">首页</a><a href="/publish">开始直播</a><a href="/login">登录</a><span id="navAuth" class="muted" style="margin-left:12px;"></span></nav>
</div>
<div class="band">
<div class="hero"><h1>观看直播</h1><p>房间 <b id="roomName">demo</b> · 选择从哪个 SFU 拉流,房间一旦直播即自动连接。</p></div>
@ -36,6 +36,9 @@
</div>
</div>
<script src="/static/app.js"></script>
<script>LiveSFU.initWatch();</script>
<script>
LiveSFU.authCheck().then(chk=>{const el=document.getElementById('navAuth'); if(chk.authenticated){el.textContent='已登录:'+chk.username+'/'+chk.role;} else {el.innerHTML='<a href="/login">未登录</a>';}});
LiveSFU.initWatch();
</script>
</body>
</html>