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:
parent
e46db4d365
commit
15a7546a59
|
|
@ -7,6 +7,7 @@ SFU_PROVIDER=cloudflare,srs
|
||||||
|
|
||||||
# SRS(本地对照后端:docker compose up -d srs)
|
# SRS(本地对照后端:docker compose up -d srs)
|
||||||
SRS_API_BASE=http://localhost:1985
|
SRS_API_BASE=http://localhost:1985
|
||||||
|
SRS_HTTP_BASE=http://localhost:8080
|
||||||
SRS_APP=live
|
SRS_APP=live
|
||||||
SRS_SECRET=
|
SRS_SECRET=
|
||||||
SRS_CANDIDATE=127.0.0.1
|
SRS_CANDIDATE=127.0.0.1
|
||||||
|
|
@ -20,3 +21,9 @@ CF_STUN_URL=stun:stun.cloudflare.com:3478
|
||||||
# 推流 JWT(SRS 入口鉴权,可选;设为 1 后 WHIP 必须带有效 token)
|
# 推流 JWT(SRS 入口鉴权,可选;设为 1 后 WHIP 必须带有效 token)
|
||||||
DEMO_TOKEN_SECRET=
|
DEMO_TOKEN_SECRET=
|
||||||
SFU_TOKEN_REQUIRED=0
|
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
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
*.local.env
|
*.local.env
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -6,6 +6,7 @@
|
||||||
- 一路推流 → SFU 扇出 → 多路拉流。
|
- 一路推流 → SFU 扇出 → 多路拉流。
|
||||||
- 同一路发布可同时落到 **Cloudflare Realtime** 与 **SRS** 两条分发链路(分流)。
|
- 同一路发布可同时落到 **Cloudflare Realtime** 与 **SRS** 两条分发链路(分流)。
|
||||||
- 浏览器经服务端反向代理直连 SFU,凭证不出服务端。
|
- 浏览器经服务端反向代理直连 SFU,凭证不出服务端。
|
||||||
|
- **默认 DB 使用 Turso 嵌入式 (libSQL file)**:房间分发拓扑持久化到本地文件。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ app/live-sfu-demo/
|
||||||
gen/ # buf generate 产出(Go)
|
gen/ # buf generate 产出(Go)
|
||||||
internal/
|
internal/
|
||||||
config/ # 运行参数(对齐 GOSpeak 的 env 布局)
|
config/ # 运行参数(对齐 GOSpeak 的 env 布局)
|
||||||
|
db/ # Turso 嵌入式 (libSQL file) 持久化
|
||||||
sfu/cloudflare/ # Cloudflare Realtime REST 客户端 + Provider
|
sfu/cloudflare/ # Cloudflare Realtime REST 客户端 + Provider
|
||||||
sfu/srs/ # SRS Provider
|
sfu/srs/ # SRS Provider
|
||||||
server/ # gRPC 服务 + JSON 网关 + SRS/CF 媒体反代 + 房间扇出状态
|
server/ # gRPC 服务 + JSON 网关 + SRS/CF 媒体反代 + 房间扇出状态
|
||||||
|
|
@ -26,12 +28,12 @@ app/live-sfu-demo/
|
||||||
## 运行(SRS 链路,开箱即跑)
|
## 运行(SRS 链路,开箱即跑)
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
cd app/live-sfu-demo
|
||||||
cp .env.example .env # 可选;SRS 链路无需任何凭证
|
cp .env.example .env
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
|
|
||||||
# 3) 打开浏览器
|
# 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)
|
## 运行(Cloudflare Realtime 链路,主 SFU)
|
||||||
|
|
||||||
在 `.env` 填入 Cloudflare Realtime 凭证后,`go run ./cmd/server` 即启用主 SFU:
|
在 `.env` 填入 Cloudflare Realtime 凭证后,`go run ./cmd/server` 即启用主 SFU:
|
||||||
|
|
@ -67,10 +91,18 @@ buf generate api
|
||||||
`WatchRoom`(服务端流式推送房间分发拓扑)。gRPC 监听 `GRPC_PORT`(默认 9090),浏览器走同端口
|
`WatchRoom`(服务端流式推送房间分发拓扑)。gRPC 监听 `GRPC_PORT`(默认 9090),浏览器走同端口
|
||||||
的 JSON 网关(`protojson`)。
|
的 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)
|
publisher ──WHIP/tracks.new──▶ Cloudflare Realtime SFU ──▶ viewer(s)
|
||||||
└────WHIP────────────▶ SRS SFU (WHEP) ──▶ viewer(s)
|
└────WHIP────────────▶ SRS SFU ─┬─▶ WHEP viewer(s) (低延时 0.2-0.5s)
|
||||||
房间分发目标由 WatchRoom 实时广播,观众任选后端拉流
|
├─▶ 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 双兼容。
|
||||||
|
|
@ -16,6 +16,12 @@ service LiveSFU {
|
||||||
rpc Subscribe(SubscribeRequest) returns (SubscribeResponse);
|
rpc Subscribe(SubscribeRequest) returns (SubscribeResponse);
|
||||||
rpc StopStream(StopStreamRequest) returns (StopStreamResponse);
|
rpc StopStream(StopStreamRequest) returns (StopStreamResponse);
|
||||||
rpc WatchRoom(WatchRoomRequest) returns (stream RoomEvent);
|
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 {
|
enum BackendKind {
|
||||||
|
|
@ -45,13 +51,12 @@ message GetConfigResponse {
|
||||||
bool token_required = 3;
|
bool token_required = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTarget 表示一个房间在某后端的一条分发目标(SFU 扇出出口)。
|
|
||||||
message StreamTarget {
|
message StreamTarget {
|
||||||
BackendKind backend = 1;
|
BackendKind backend = 1;
|
||||||
string session_id = 2; // Cloudflare: 发布者 sessionId
|
string session_id = 2;
|
||||||
string stream = 3; // SRS: stream 名
|
string stream = 3;
|
||||||
string publish_token = 4;// SRS: 推流 JWT(可选)
|
string publish_token = 4;
|
||||||
string url = 5; // 拉流播放地址(SRS WHEP,可选)
|
string url = 5;
|
||||||
int64 published_at = 6;
|
int64 published_at = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,3 +110,52 @@ message RoomEvent {
|
||||||
string room = 1;
|
string room = 1;
|
||||||
repeated StreamTarget targets = 2;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
srv := server.New(cfg)
|
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 {
|
if err := srv.StartGRPC(); err != nil {
|
||||||
log.Printf("[warn] grpc control plane failed to start: %v", err)
|
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("live-sfu demo ready: http://localhost:%s (grpc :%s)", cfg.HTTPPort, cfg.GRPCPort)
|
||||||
log.Printf("backends (primary first): %v", cfg.ProviderList())
|
log.Printf("backends (primary first): %v", cfg.ProviderList())
|
||||||
log.Printf("cloudflare configured: %v", cfg.CFAppID != "" && cfg.CFAppSecret != "")
|
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 {
|
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
|
||||||
log.Fatalf("http server error: %v", err)
|
log.Fatalf("http server error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,10 @@ vhost __defaultVhost__ {
|
||||||
enabled on;
|
enabled on;
|
||||||
mount [vhost]/[app]/[stream].flv;
|
mount [vhost]/[app]/[stream].flv;
|
||||||
}
|
}
|
||||||
|
hls {
|
||||||
|
enabled on;
|
||||||
|
hls_path ./objs/nginx/html;
|
||||||
|
hls_fragment 10;
|
||||||
|
hls_window 60;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 已启用并经网关反代,文档与代码同源。*
|
||||||
|
|
@ -294,14 +294,13 @@ func (x *GetConfigResponse) GetTokenRequired() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTarget 表示一个房间在某后端的一条分发目标(SFU 扇出出口)。
|
|
||||||
type StreamTarget struct {
|
type StreamTarget struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Backend BackendKind `protobuf:"varint,1,opt,name=backend,proto3,enum=gospeak.livedemo.v1.BackendKind" json:"backend,omitempty"`
|
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
|
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"` // SRS: stream 名
|
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"` // SRS: 推流 JWT(可选)
|
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"` // 拉流播放地址(SRS WHEP,可选)
|
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"`
|
PublishedAt int64 `protobuf:"varint,6,opt,name=published_at,json=publishedAt,proto3" json:"published_at,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
|
@ -967,6 +966,579 @@ func (x *RoomEvent) GetTargets() []*StreamTarget {
|
||||||
return nil
|
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
|
var File_live_sfu_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_live_sfu_proto_rawDesc = "" +
|
const file_live_sfu_proto_rawDesc = "" +
|
||||||
|
|
@ -1036,11 +1608,48 @@ const file_live_sfu_proto_rawDesc = "" +
|
||||||
"\x04room\x18\x01 \x01(\tR\x04room\"\\\n" +
|
"\x04room\x18\x01 \x01(\tR\x04room\"\\\n" +
|
||||||
"\tRoomEvent\x12\x12\n" +
|
"\tRoomEvent\x12\x12\n" +
|
||||||
"\x04room\x18\x01 \x01(\tR\x04room\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" +
|
"\vBackendKind\x12\x1c\n" +
|
||||||
"\x18BACKEND_KIND_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
"\x18BACKEND_KIND_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
||||||
"\x17BACKEND_KIND_CLOUDFLARE\x10\x01\x12\x14\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" +
|
"\aLiveSFU\x12Z\n" +
|
||||||
"\tGetConfig\x12%.gospeak.livedemo.v1.GetConfigRequest\x1a&.gospeak.livedemo.v1.GetConfigResponse\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" +
|
"\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" +
|
"\tSubscribe\x12%.gospeak.livedemo.v1.SubscribeRequest\x1a&.gospeak.livedemo.v1.SubscribeResponse\x12]\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"StopStream\x12&.gospeak.livedemo.v1.StopStreamRequest\x1a'.gospeak.livedemo.v1.StopStreamResponse\x12T\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 (
|
var (
|
||||||
file_live_sfu_proto_rawDescOnce sync.Once
|
file_live_sfu_proto_rawDescOnce sync.Once
|
||||||
|
|
@ -1063,25 +1677,36 @@ func file_live_sfu_proto_rawDescGZIP() []byte {
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_live_sfu_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
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{
|
var file_live_sfu_proto_goTypes = []any{
|
||||||
(BackendKind)(0), // 0: gospeak.livedemo.v1.BackendKind
|
(BackendKind)(0), // 0: gospeak.livedemo.v1.BackendKind
|
||||||
(*IceServer)(nil), // 1: gospeak.livedemo.v1.IceServer
|
(*IceServer)(nil), // 1: gospeak.livedemo.v1.IceServer
|
||||||
(*GetConfigRequest)(nil), // 2: gospeak.livedemo.v1.GetConfigRequest
|
(*GetConfigRequest)(nil), // 2: gospeak.livedemo.v1.GetConfigRequest
|
||||||
(*BackendInfo)(nil), // 3: gospeak.livedemo.v1.BackendInfo
|
(*BackendInfo)(nil), // 3: gospeak.livedemo.v1.BackendInfo
|
||||||
(*GetConfigResponse)(nil), // 4: gospeak.livedemo.v1.GetConfigResponse
|
(*GetConfigResponse)(nil), // 4: gospeak.livedemo.v1.GetConfigResponse
|
||||||
(*StreamTarget)(nil), // 5: gospeak.livedemo.v1.StreamTarget
|
(*StreamTarget)(nil), // 5: gospeak.livedemo.v1.StreamTarget
|
||||||
(*Room)(nil), // 6: gospeak.livedemo.v1.Room
|
(*Room)(nil), // 6: gospeak.livedemo.v1.Room
|
||||||
(*ListRoomsRequest)(nil), // 7: gospeak.livedemo.v1.ListRoomsRequest
|
(*ListRoomsRequest)(nil), // 7: gospeak.livedemo.v1.ListRoomsRequest
|
||||||
(*ListRoomsResponse)(nil), // 8: gospeak.livedemo.v1.ListRoomsResponse
|
(*ListRoomsResponse)(nil), // 8: gospeak.livedemo.v1.ListRoomsResponse
|
||||||
(*PublishRequest)(nil), // 9: gospeak.livedemo.v1.PublishRequest
|
(*PublishRequest)(nil), // 9: gospeak.livedemo.v1.PublishRequest
|
||||||
(*PublishResponse)(nil), // 10: gospeak.livedemo.v1.PublishResponse
|
(*PublishResponse)(nil), // 10: gospeak.livedemo.v1.PublishResponse
|
||||||
(*SubscribeRequest)(nil), // 11: gospeak.livedemo.v1.SubscribeRequest
|
(*SubscribeRequest)(nil), // 11: gospeak.livedemo.v1.SubscribeRequest
|
||||||
(*SubscribeResponse)(nil), // 12: gospeak.livedemo.v1.SubscribeResponse
|
(*SubscribeResponse)(nil), // 12: gospeak.livedemo.v1.SubscribeResponse
|
||||||
(*StopStreamRequest)(nil), // 13: gospeak.livedemo.v1.StopStreamRequest
|
(*StopStreamRequest)(nil), // 13: gospeak.livedemo.v1.StopStreamRequest
|
||||||
(*StopStreamResponse)(nil), // 14: gospeak.livedemo.v1.StopStreamResponse
|
(*StopStreamResponse)(nil), // 14: gospeak.livedemo.v1.StopStreamResponse
|
||||||
(*WatchRoomRequest)(nil), // 15: gospeak.livedemo.v1.WatchRoomRequest
|
(*WatchRoomRequest)(nil), // 15: gospeak.livedemo.v1.WatchRoomRequest
|
||||||
(*RoomEvent)(nil), // 16: gospeak.livedemo.v1.RoomEvent
|
(*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{
|
var file_live_sfu_proto_depIdxs = []int32{
|
||||||
0, // 0: gospeak.livedemo.v1.BackendInfo.kind:type_name -> gospeak.livedemo.v1.BackendKind
|
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
|
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
|
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
|
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
|
17, // 12: gospeak.livedemo.v1.LoginResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
|
||||||
7, // 13: gospeak.livedemo.v1.LiveSFU.ListRooms:input_type -> gospeak.livedemo.v1.ListRoomsRequest
|
17, // 13: gospeak.livedemo.v1.RegisterResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
|
||||||
9, // 14: gospeak.livedemo.v1.LiveSFU.Publish:input_type -> gospeak.livedemo.v1.PublishRequest
|
17, // 14: gospeak.livedemo.v1.GetMeResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
|
||||||
11, // 15: gospeak.livedemo.v1.LiveSFU.Subscribe:input_type -> gospeak.livedemo.v1.SubscribeRequest
|
17, // 15: gospeak.livedemo.v1.ListUsersResponse.users:type_name -> gospeak.livedemo.v1.UserInfo
|
||||||
13, // 16: gospeak.livedemo.v1.LiveSFU.StopStream:input_type -> gospeak.livedemo.v1.StopStreamRequest
|
17, // 16: gospeak.livedemo.v1.UpdateUserRoleResponse.user:type_name -> gospeak.livedemo.v1.UserInfo
|
||||||
15, // 17: gospeak.livedemo.v1.LiveSFU.WatchRoom:input_type -> gospeak.livedemo.v1.WatchRoomRequest
|
2, // 17: gospeak.livedemo.v1.LiveSFU.GetConfig:input_type -> gospeak.livedemo.v1.GetConfigRequest
|
||||||
4, // 18: gospeak.livedemo.v1.LiveSFU.GetConfig:output_type -> gospeak.livedemo.v1.GetConfigResponse
|
7, // 18: gospeak.livedemo.v1.LiveSFU.ListRooms:input_type -> gospeak.livedemo.v1.ListRoomsRequest
|
||||||
8, // 19: gospeak.livedemo.v1.LiveSFU.ListRooms:output_type -> gospeak.livedemo.v1.ListRoomsResponse
|
9, // 19: gospeak.livedemo.v1.LiveSFU.Publish:input_type -> gospeak.livedemo.v1.PublishRequest
|
||||||
10, // 20: gospeak.livedemo.v1.LiveSFU.Publish:output_type -> gospeak.livedemo.v1.PublishResponse
|
11, // 20: gospeak.livedemo.v1.LiveSFU.Subscribe:input_type -> gospeak.livedemo.v1.SubscribeRequest
|
||||||
12, // 21: gospeak.livedemo.v1.LiveSFU.Subscribe:output_type -> gospeak.livedemo.v1.SubscribeResponse
|
13, // 21: gospeak.livedemo.v1.LiveSFU.StopStream:input_type -> gospeak.livedemo.v1.StopStreamRequest
|
||||||
14, // 22: gospeak.livedemo.v1.LiveSFU.StopStream:output_type -> gospeak.livedemo.v1.StopStreamResponse
|
15, // 22: gospeak.livedemo.v1.LiveSFU.WatchRoom:input_type -> gospeak.livedemo.v1.WatchRoomRequest
|
||||||
16, // 23: gospeak.livedemo.v1.LiveSFU.WatchRoom:output_type -> gospeak.livedemo.v1.RoomEvent
|
18, // 23: gospeak.livedemo.v1.LiveSFU.Login:input_type -> gospeak.livedemo.v1.LoginRequest
|
||||||
18, // [18:24] is the sub-list for method output_type
|
20, // 24: gospeak.livedemo.v1.LiveSFU.Register:input_type -> gospeak.livedemo.v1.RegisterRequest
|
||||||
12, // [12:18] is the sub-list for method input_type
|
22, // 25: gospeak.livedemo.v1.LiveSFU.GetMe:input_type -> gospeak.livedemo.v1.GetMeRequest
|
||||||
12, // [12:12] is the sub-list for extension type_name
|
24, // 26: gospeak.livedemo.v1.LiveSFU.ListUsers:input_type -> gospeak.livedemo.v1.ListUsersRequest
|
||||||
12, // [12:12] is the sub-list for extension extendee
|
26, // 27: gospeak.livedemo.v1.LiveSFU.UpdateUserRole:input_type -> gospeak.livedemo.v1.UpdateUserRoleRequest
|
||||||
0, // [0:12] is the sub-list for field type_name
|
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() }
|
func init() { file_live_sfu_proto_init() }
|
||||||
|
|
@ -1126,7 +1766,7 @@ func file_live_sfu_proto_init() {
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_live_sfu_proto_rawDesc), len(file_live_sfu_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_live_sfu_proto_rawDesc), len(file_live_sfu_proto_rawDesc)),
|
||||||
NumEnums: 1,
|
NumEnums: 1,
|
||||||
NumMessages: 16,
|
NumMessages: 27,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,17 @@ import (
|
||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LiveSFU_GetConfig_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/GetConfig"
|
LiveSFU_GetConfig_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/GetConfig"
|
||||||
LiveSFU_ListRooms_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/ListRooms"
|
LiveSFU_ListRooms_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/ListRooms"
|
||||||
LiveSFU_Publish_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Publish"
|
LiveSFU_Publish_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Publish"
|
||||||
LiveSFU_Subscribe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Subscribe"
|
LiveSFU_Subscribe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Subscribe"
|
||||||
LiveSFU_StopStream_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/StopStream"
|
LiveSFU_StopStream_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/StopStream"
|
||||||
LiveSFU_WatchRoom_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/WatchRoom"
|
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.
|
// 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)
|
Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error)
|
||||||
StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, 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)
|
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 {
|
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.
|
// 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]
|
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.
|
// LiveSFUServer is the server API for LiveSFU service.
|
||||||
// All implementations should embed UnimplementedLiveSFUServer
|
// All implementations should embed UnimplementedLiveSFUServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
|
|
@ -138,6 +199,12 @@ type LiveSFUServer interface {
|
||||||
Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error)
|
Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error)
|
||||||
StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error)
|
StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error)
|
||||||
WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) 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
|
// UnimplementedLiveSFUServer should be embedded to have
|
||||||
|
|
@ -165,6 +232,21 @@ func (UnimplementedLiveSFUServer) StopStream(context.Context, *StopStreamRequest
|
||||||
func (UnimplementedLiveSFUServer) WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error {
|
func (UnimplementedLiveSFUServer) WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error {
|
||||||
return status.Errorf(codes.Unimplemented, "method WatchRoom not implemented")
|
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() {}
|
func (UnimplementedLiveSFUServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
// UnsafeLiveSFUServer may be embedded to opt out of forward compatibility for this service.
|
// 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.
|
// 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]
|
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.
|
// LiveSFU_ServiceDesc is the grpc.ServiceDesc for LiveSFU service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
|
@ -313,6 +485,26 @@ var LiveSFU_ServiceDesc = grpc.ServiceDesc{
|
||||||
MethodName: "StopStream",
|
MethodName: "StopStream",
|
||||||
Handler: _LiveSFU_StopStream_Handler,
|
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{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
20
go.mod
20
go.mod
|
|
@ -1,17 +1,25 @@
|
||||||
module gospeak-live-sfu-demo
|
module gospeak-live-sfu-demo
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.5
|
|
||||||
|
|
||||||
require (
|
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/grpc v1.80.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/net v0.49.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||||
golang.org/x/text v0.33.0 // 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
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
44
go.sum
44
go.sum
|
|
@ -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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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=
|
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/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 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
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/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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
@ -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} }
|
||||||
|
|
@ -3,16 +3,19 @@ package config
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 承载直播 SFU 分流 Demo 的运行参数。
|
// Config 承载直播 SFU 分流 Demo 的运行参数。
|
||||||
// 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。
|
// 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。
|
||||||
|
// 默认 DB 使用 Turso 嵌入式 (libSQL file) 持久化房间拓扑;同时支持 JWT/Casbin 登录。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
HTTPPort string
|
HTTPPort string
|
||||||
GRPCPort string
|
GRPCPort string
|
||||||
ProviderOrder string // 逗号分隔的后端顺序,如 "cloudflare,srs"
|
ProviderOrder string // 逗号分隔的后端顺序,如 "cloudflare,srs"
|
||||||
|
|
||||||
SRSBaseURL string
|
SRSBaseURL string
|
||||||
|
SRSHttpURL string
|
||||||
SRSApp string
|
SRSApp string
|
||||||
SRSSecret string
|
SRSSecret string
|
||||||
SRSCandidate string
|
SRSCandidate string
|
||||||
|
|
@ -24,6 +27,16 @@ type Config struct {
|
||||||
|
|
||||||
TokenSecret string
|
TokenSecret string
|
||||||
TokenRequired bool
|
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 {
|
func Load() *Config {
|
||||||
|
|
@ -33,6 +46,7 @@ func Load() *Config {
|
||||||
ProviderOrder: getenv("SFU_PROVIDER", "cloudflare,srs"),
|
ProviderOrder: getenv("SFU_PROVIDER", "cloudflare,srs"),
|
||||||
|
|
||||||
SRSBaseURL: getenv("SRS_API_BASE", "http://localhost:1985"),
|
SRSBaseURL: getenv("SRS_API_BASE", "http://localhost:1985"),
|
||||||
|
SRSHttpURL: getenv("SRS_HTTP_BASE", "http://localhost:8080"),
|
||||||
SRSApp: getenv("SRS_APP", "live"),
|
SRSApp: getenv("SRS_APP", "live"),
|
||||||
SRSSecret: getenv("SRS_SECRET", ""),
|
SRSSecret: getenv("SRS_SECRET", ""),
|
||||||
SRSCandidate: getenv("SRS_CANDIDATE", "127.0.0.1"),
|
SRSCandidate: getenv("SRS_CANDIDATE", "127.0.0.1"),
|
||||||
|
|
@ -44,10 +58,40 @@ func Load() *Config {
|
||||||
|
|
||||||
TokenSecret: getenv("DEMO_TOKEN_SECRET", ""),
|
TokenSecret: getenv("DEMO_TOKEN_SECRET", ""),
|
||||||
TokenRequired: getenv("SFU_TOKEN_REQUIRED", "0") == "1",
|
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 == "" {
|
if c.TokenSecret == "" {
|
||||||
c.TokenSecret = "insecure-demo-secret-change-me"
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +102,16 @@ func getenv(k, def string) string {
|
||||||
return def
|
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 {
|
func (c *Config) ProviderList() []string {
|
||||||
parts := strings.Split(c.ProviderOrder, ",")
|
parts := strings.Split(c.ProviderOrder, ",")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
|
|
@ -83,3 +136,18 @@ func (c *Config) ProviderList() []string {
|
||||||
}
|
}
|
||||||
return out
|
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://")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"gospeak-live-sfu-demo/gen"
|
"gospeak-live-sfu-demo/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// grpcServer 把 Service 适配为 protobuf 生成的 gRPC 服务端接口。
|
|
||||||
type grpcServer struct {
|
type grpcServer struct {
|
||||||
gen.UnimplementedLiveSFUServer
|
gen.UnimplementedLiveSFUServer
|
||||||
svc *Service
|
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 {
|
func (g *grpcServer) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error {
|
||||||
return g.svc.WatchRoom(req, stream)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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。
|
// cfProxyHandler 反向代理 Cloudflare Realtime REST,注入 AppSecret。
|
||||||
// 浏览器只交换 SDP(tracks/new),凭证不出服务端。
|
// 浏览器只交换 SDP(tracks/new),凭证不出服务端。
|
||||||
func (s *Server) cfProxyHandler() http.Handler {
|
func (s *Server) cfProxyHandler() http.Handler {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gospeak-live-sfu-demo/gen"
|
"gospeak-live-sfu-demo/gen"
|
||||||
|
"gospeak-live-sfu-demo/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// roomHub 维护房间 -> 各后端分发目标(StreamTarget)的内存状态,
|
// roomHub 维护房间 -> 各后端分发目标(StreamTarget)的内存状态,
|
||||||
// 并向订阅者广播拓扑变化(实现「分流」状态实时可见)。
|
// 并向订阅者广播拓扑变化(实现「分流」状态实时可见)。
|
||||||
|
// 默认由 Turso 嵌入式 (libSQL file) 持久化。
|
||||||
type roomHub struct {
|
type roomHub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*roomEntry
|
rooms map[string]*roomEntry
|
||||||
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type roomEntry struct {
|
type roomEntry struct {
|
||||||
|
|
@ -23,6 +30,34 @@ func newRoomHub() *roomHub {
|
||||||
return &roomHub{rooms: map[string]*roomEntry{}}
|
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 {
|
func (h *roomHub) get(name string) *roomEntry {
|
||||||
e, ok := h.rooms[name]
|
e, ok := h.rooms[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -34,25 +69,50 @@ func (h *roomHub) get(name string) *roomEntry {
|
||||||
|
|
||||||
func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
|
func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
e := h.get(room)
|
e := h.get(room)
|
||||||
e.targets[backend] = t
|
e.targets[backend] = t
|
||||||
h.broadcast(room, e)
|
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) {
|
func (h *roomHub) removeTarget(room, backend string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
e, ok := h.rooms[room]
|
e, ok := h.rooms[room]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(e.targets, backend)
|
delete(e.targets, backend)
|
||||||
if len(e.targets) == 0 && len(e.subs) == 0 {
|
if len(e.targets) == 0 && len(e.subs) == 0 {
|
||||||
delete(h.rooms, room)
|
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
|
return
|
||||||
}
|
}
|
||||||
h.broadcast(room, e)
|
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 {
|
func (h *roomHub) targets(room string) []*gen.StreamTarget {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"gospeak-live-sfu-demo/gen"
|
"gospeak-live-sfu-demo/gen"
|
||||||
|
"gospeak-live-sfu-demo/internal/auth"
|
||||||
"gospeak-live-sfu-demo/internal/config"
|
"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/cloudflare"
|
||||||
"gospeak-live-sfu-demo/internal/sfu/srs"
|
"gospeak-live-sfu-demo/internal/sfu/srs"
|
||||||
)
|
)
|
||||||
|
|
@ -18,67 +21,136 @@ import (
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// Server 聚合控制面(gRPC + JSON 网关)、媒体面反向代理与静态 UI。
|
// Server 聚合控制面(gRPC + JSON 网关)、媒体面反向代理与静态 UI,并集成登录与 Casbin 鉴权。
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
svc *Service
|
svc *Service
|
||||||
hub *roomHub
|
hub *roomHub
|
||||||
srsProxy http.Handler
|
srsProxy http.Handler
|
||||||
cfProxy http.Handler
|
cfProxy http.Handler
|
||||||
|
auth *auth.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) *Server {
|
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)
|
cf := cloudflare.NewProvider(cfg.CFAppID, cfg.CFAppSecret, cfg.CFBaseURL, cfg.CFStunURL)
|
||||||
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
|
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
|
||||||
svc := NewService(cfg, cf, srsP, hub)
|
svc := NewService(cfg, cf, srsP, hub)
|
||||||
s := &Server{cfg: cfg, svc: svc, hub: hub}
|
s := &Server{cfg: cfg, svc: svc, hub: hub}
|
||||||
s.srsProxy = s.srsProxyHandler()
|
s.srsProxy = s.srsProxyHandler()
|
||||||
s.cfProxy = s.cfProxyHandler()
|
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
|
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 {
|
func (s *Server) StartGRPC() error {
|
||||||
lis, err := net.Listen("tcp", ":"+s.cfg.GRPCPort)
|
lis, err := net.Listen("tcp", ":"+s.cfg.GRPCPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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))
|
gen.RegisterLiveSFUServer(gs, NewGRPCServer(s.svc))
|
||||||
go func() { _ = gs.Serve(lis) }()
|
go func() { _ = gs.Serve(lis) }()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler 返回 HTTP 路由:静态 UI、JSON 网关、SSE、SRS/Cloudflare 媒体反代。
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
sub, _ := fs.Sub(staticFS, "static")
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
mux.Handle("GET /", s.fileServer("static/index.html"))
|
mux.Handle("GET /", s.fileServer("static/index.html"))
|
||||||
mux.Handle("GET /publish", s.fileServer("static/publish.html"))
|
mux.Handle("GET /publish", s.fileServer("static/publish.html"))
|
||||||
mux.Handle("GET /watch", s.fileServer("static/watch.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.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||||
mux.HandleFunc("GET /api/rooms", s.handleRooms)
|
mux.HandleFunc("POST /api/auth/register", s.handleRegister)
|
||||||
mux.HandleFunc("POST /api/publish", s.handlePublish)
|
mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
|
||||||
mux.HandleFunc("POST /api/subscribe", s.handleSubscribe)
|
mux.Handle("GET /api/auth/me", s.authWrap(http.HandlerFunc(s.handleMe), "user", "list", true))
|
||||||
mux.HandleFunc("POST /api/stop", s.handleStop)
|
mux.Handle("GET /api/auth/users", s.authWrap(http.HandlerFunc(s.handleListUsers), "user", "list", true))
|
||||||
mux.HandleFunc("GET /api/srs/streams", s.handleSRSStreams)
|
mux.Handle("POST /api/auth/users/role", s.authWrap(http.HandlerFunc(s.handleUpdateRole), "user", "manage", true))
|
||||||
mux.Handle("GET /api/room/{room}/events", http.HandlerFunc(s.handleRoomEvents))
|
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
|
||||||
|
|
||||||
mux.Handle("GET /rtc/v1/", s.srsProxy)
|
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
|
||||||
mux.Handle("POST /rtc/v1/", s.srsProxy)
|
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
|
||||||
mux.Handle("PUT /rtc/v1/", s.srsProxy)
|
mux.Handle("POST /api/publish", s.authWrap(http.HandlerFunc(s.handlePublish), "room", "publish", true))
|
||||||
mux.Handle("DELETE /rtc/v1/", s.srsProxy)
|
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("GET /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
|
||||||
mux.Handle("POST /api/cf/", s.cfProxy)
|
mux.Handle("POST /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
|
||||||
mux.Handle("PUT /api/cf/", s.cfProxy)
|
mux.Handle("PUT /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
|
||||||
mux.Handle("DELETE /api/cf/", s.cfProxy)
|
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
|
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 {
|
func (s *Server) fileServer(name string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := staticFS.ReadFile(name)
|
data, err := staticFS.ReadFile(name)
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,34 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gospeak-live-sfu-demo/gen"
|
"gospeak-live-sfu-demo/gen"
|
||||||
|
"gospeak-live-sfu-demo/internal/auth"
|
||||||
"gospeak-live-sfu-demo/internal/config"
|
"gospeak-live-sfu-demo/internal/config"
|
||||||
"gospeak-live-sfu-demo/internal/sfu/cloudflare"
|
"gospeak-live-sfu-demo/internal/sfu/cloudflare"
|
||||||
"gospeak-live-sfu-demo/internal/sfu/srs"
|
"gospeak-live-sfu-demo/internal/sfu/srs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service 实现 LiveSFU 控制面逻辑:协调房间在各 SFU 后端的发布/订阅拓扑。
|
// Service 实现 LiveSFU 控制面逻辑:协调房间在各 SFU 后端的发布/订阅拓扑。
|
||||||
// 媒体面(SDP 交换)由浏览器经反向代理直连 Cloudflare / SRS,本服务只持有凭证。
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
cf *cloudflare.Provider
|
cf *cloudflare.Provider
|
||||||
srsP *srs.Provider
|
srsP *srs.Provider
|
||||||
hub *roomHub
|
hub *roomHub
|
||||||
|
auth *auth.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(cfg *config.Config, cf *cloudflare.Provider, srsP *srs.Provider, hub *roomHub) *Service {
|
func NewService(cfg *config.Config, cf *cloudflare.Provider, srsP *srs.Provider, hub *roomHub) *Service {
|
||||||
return &Service{cfg: cfg, cf: cf, srsP: srsP, hub: hub}
|
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 {
|
func backendName(k gen.BackendKind) string {
|
||||||
switch k {
|
switch k {
|
||||||
case gen.BackendKind_BACKEND_KIND_CLOUDFLARE:
|
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) {
|
func (s *Service) GetConfig(ctx context.Context, req *gen.GetConfigRequest) (*gen.GetConfigResponse, error) {
|
||||||
resp := &gen.GetConfigResponse{
|
resp := &gen.GetConfigResponse{
|
||||||
Candidate: s.srsP.Candidate(),
|
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) {
|
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
|
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) {
|
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()
|
room := req.GetRoom()
|
||||||
if room == "" {
|
if room == "" {
|
||||||
return nil, fmt.Errorf("room required")
|
return nil, fmt.Errorf("room required")
|
||||||
|
|
@ -70,7 +84,6 @@ func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.Pu
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
identity = randomID()
|
identity = randomID()
|
||||||
}
|
}
|
||||||
|
|
||||||
switch backend {
|
switch backend {
|
||||||
case "cloudflare":
|
case "cloudflare":
|
||||||
if !s.cf.Configured() {
|
if !s.cf.Configured() {
|
||||||
|
|
@ -95,7 +108,6 @@ func (s *Service) Publish(ctx context.Context, req *gen.PublishRequest) (*gen.Pu
|
||||||
IceServers: iceServersToProto(info.IceServers),
|
IceServers: iceServersToProto(info.IceServers),
|
||||||
Target: target,
|
Target: target,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case "srs":
|
case "srs":
|
||||||
stream := "live-" + room
|
stream := "live-" + room
|
||||||
token, _ := signStreamToken(s.cfg.TokenSecret, stream, identity, "publish", 2*time.Hour)
|
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")
|
return nil, fmt.Errorf("unsupported backend")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe 订阅某房间在某后端的分发目标,返回建立 WebRTC 所需的 session / stream。
|
|
||||||
func (s *Service) Subscribe(ctx context.Context, req *gen.SubscribeRequest) (*gen.SubscribeResponse, error) {
|
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()
|
room := req.GetRoom()
|
||||||
if room == "" {
|
if room == "" {
|
||||||
return nil, fmt.Errorf("room required")
|
return nil, fmt.Errorf("room required")
|
||||||
|
|
@ -131,7 +145,6 @@ func (s *Service) Subscribe(ctx context.Context, req *gen.SubscribeRequest) (*ge
|
||||||
if pub == nil {
|
if pub == nil {
|
||||||
return nil, fmt.Errorf("room %q not live on %s", room, backend)
|
return nil, fmt.Errorf("room %q not live on %s", room, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch backend {
|
switch backend {
|
||||||
case "cloudflare":
|
case "cloudflare":
|
||||||
viewerSession, err := s.cf.Client().CreateSession(room)
|
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) {
|
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()
|
room := req.GetRoom()
|
||||||
backend := backendName(req.GetBackend())
|
backend := backendName(req.GetBackend())
|
||||||
if room == "" || backend == "" {
|
if room == "" || backend == "" {
|
||||||
|
|
@ -173,8 +189,10 @@ func (s *Service) StopStream(ctx context.Context, req *gen.StopStreamRequest) (*
|
||||||
return &gen.StopStreamResponse{Ok: true}, nil
|
return &gen.StopStreamResponse{Ok: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchRoom 服务端流式推送房间分发拓扑(分流)变化。
|
|
||||||
func (s *Service) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error {
|
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()
|
room := req.GetRoom()
|
||||||
ch, unsub := s.hub.subscribe(room)
|
ch, unsub := s.hub.subscribe(room)
|
||||||
defer unsub()
|
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 {
|
func (s *Service) findTarget(room string, kind gen.BackendKind) *gen.StreamTarget {
|
||||||
for _, t := range s.hub.targets(room) {
|
for _, t := range s.hub.targets(room) {
|
||||||
if t.GetBackend() == kind {
|
if t.GetBackend() == kind {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// 直播 SFU 分流 Demo 前端逻辑。控制面走 JSON 网关(protojson),媒体面走反向代理直连 SFU。
|
// 直播 SFU 分流 Demo 前端逻辑。控制面走 JSON 网关(protojson),媒体面走反向代理直连 SFU。
|
||||||
|
// 已集成登录与 Casbin 权限:自动附加 Authorization header,未登录重定向到 /login
|
||||||
window.LiveSFU = (function () {
|
window.LiveSFU = (function () {
|
||||||
const ENUM = { cloudflare: "BACKEND_KIND_CLOUDFLARE", srs: "BACKEND_KIND_SRS" };
|
const ENUM = { cloudflare: "BACKEND_KIND_CLOUDFLARE", srs: "BACKEND_KIND_SRS" };
|
||||||
const NAME = { BACKEND_KIND_CLOUDFLARE: "Cloudflare Realtime", BACKEND_KIND_SRS: "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; }
|
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) {
|
async function apiGet(path) {
|
||||||
const r = await fetch(path);
|
const r = await fetch(path, { headers: { ...authHeaders() } });
|
||||||
if (!r.ok) throw new Error(path + " -> " + r.status);
|
if (!r.ok) {
|
||||||
|
handleAuthError(r.status);
|
||||||
|
throw new Error(path + " -> " + r.status + " " + (await r.text()));
|
||||||
|
}
|
||||||
return r.json();
|
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) {
|
async function apiPost(path, body) {
|
||||||
const r = await fetch(path, {
|
const r = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const text = await r.text();
|
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) : {};
|
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) {
|
async function loadConfig(el) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -35,6 +115,14 @@ window.LiveSFU = (function () {
|
||||||
span.textContent = shortName(b.kind) + (b.primary ? " · 主" : "") + (b.configured ? " · 就绪" : " · 未配置");
|
span.textContent = shortName(b.kind) + (b.primary ? " · 主" : "") + (b.configured ? " · 就绪" : " · 未配置");
|
||||||
el.appendChild(span);
|
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) {
|
} catch (e) {
|
||||||
el.innerHTML = '<span class="badge bad">控制面不可达</span>';
|
el.innerHTML = '<span class="badge bad">控制面不可达</span>';
|
||||||
}
|
}
|
||||||
|
|
@ -42,11 +130,10 @@ window.LiveSFU = (function () {
|
||||||
|
|
||||||
function log(el, msg) {
|
function log(el, msg) {
|
||||||
const t = new Date().toLocaleTimeString();
|
const t = new Date().toLocaleTimeString();
|
||||||
el.textContent += "[" + t + "] " + msg + "\n";
|
el.textContent += "[" + t + "] " + msg + "\\n";
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- WebRTC:Cloudflare Realtime ----------
|
|
||||||
async function publishCF(sessionId, localStream, iceServers) {
|
async function publishCF(sessionId, localStream, iceServers) {
|
||||||
const pc = new RTCPeerConnection({ iceServers });
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
||||||
|
|
@ -54,13 +141,17 @@ window.LiveSFU = (function () {
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
const resp = await fetch("/api/cf/sessions/" + sessionId + "/tracks/new", {
|
const resp = await fetch("/api/cf/sessions/" + sessionId + "/tracks/new", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionDescription: { type: "offer", sdp: offer.sdp },
|
sessionDescription: { type: "offer", sdp: offer.sdp },
|
||||||
tracks: localStream.getTracks().map((t) => ({ location: "local", trackName: t.kind, kind: t.kind })),
|
tracks: localStream.getTracks().map((t) => ({ location: "local", trackName: t.kind, kind: t.kind })),
|
||||||
autoDiscover: true,
|
autoDiscover: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
handleAuthError(resp.status);
|
||||||
|
throw new Error("cf publish failed: " + resp.status);
|
||||||
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
|
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
|
||||||
return pc;
|
return pc;
|
||||||
|
|
@ -73,7 +164,7 @@ window.LiveSFU = (function () {
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
const resp = await fetch("/api/cf/sessions/" + viewerSessionId + "/tracks/new", {
|
const resp = await fetch("/api/cf/sessions/" + viewerSessionId + "/tracks/new", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionDescription: { type: "offer", sdp: offer.sdp },
|
sessionDescription: { type: "offer", sdp: offer.sdp },
|
||||||
tracks: [
|
tracks: [
|
||||||
|
|
@ -83,19 +174,26 @@ window.LiveSFU = (function () {
|
||||||
autoDiscover: true,
|
autoDiscover: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
handleAuthError(resp.status);
|
||||||
|
throw new Error("cf watch failed: " + resp.status);
|
||||||
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
|
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp });
|
||||||
return pc;
|
return pc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- WebRTC:SRS (WHIP/WHEP) ----------
|
|
||||||
async function publishSRS(room, token, stream, iceServers, localStream) {
|
async function publishSRS(room, token, stream, iceServers, localStream) {
|
||||||
const pc = new RTCPeerConnection({ iceServers });
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
const url = "/rtc/v1/whip/?app=live&stream=" + encodeURIComponent(stream) + "&token=" + encodeURIComponent(token);
|
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();
|
const answer = await resp.text();
|
||||||
await pc.setRemoteDescription({ type: "answer", sdp: answer });
|
await pc.setRemoteDescription({ type: "answer", sdp: answer });
|
||||||
return pc;
|
return pc;
|
||||||
|
|
@ -107,7 +205,11 @@ window.LiveSFU = (function () {
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
const url = "/rtc/v1/whep/?app=live&stream=" + encodeURIComponent(stream);
|
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();
|
const answer = await resp.text();
|
||||||
await pc.setRemoteDescription({ type: "answer", sdp: answer });
|
await pc.setRemoteDescription({ type: "answer", sdp: answer });
|
||||||
return pc;
|
return pc;
|
||||||
|
|
@ -117,7 +219,6 @@ window.LiveSFU = (function () {
|
||||||
return (targets || []).find((t) => t.backend === enumStr);
|
return (targets || []).find((t) => t.backend === enumStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 发布页 ----------
|
|
||||||
function initPublish() {
|
function initPublish() {
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
document.getElementById("roomName").textContent = room;
|
document.getElementById("roomName").textContent = room;
|
||||||
|
|
@ -125,7 +226,13 @@ window.LiveSFU = (function () {
|
||||||
const logEl = document.getElementById("log");
|
const logEl = document.getElementById("log");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const pcs = {};
|
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 () => {
|
document.getElementById("start").onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const backends = Array.from(document.querySelectorAll('input[name="backend"]:checked')).map((i) => i.value);
|
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);
|
log(logEl, "获取摄像头失败:" + e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById("stop").onclick = async () => {
|
document.getElementById("stop").onclick = async () => {
|
||||||
for (const b of Object.keys(pcs)) {
|
for (const b of Object.keys(pcs)) {
|
||||||
try { await apiPost("/api/stop", { room, backend: ENUM[b] }); } catch (e) {}
|
try { await apiPost("/api/stop", { room, backend: ENUM[b] }); } catch (e) {}
|
||||||
|
|
@ -170,7 +276,6 @@ window.LiveSFU = (function () {
|
||||||
: '<span class="muted">未发布</span>';
|
: '<span class="muted">未发布</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 观看页 ----------
|
|
||||||
function initWatch() {
|
function initWatch() {
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
document.getElementById("roomName").textContent = room;
|
document.getElementById("roomName").textContent = room;
|
||||||
|
|
@ -180,9 +285,14 @@ window.LiveSFU = (function () {
|
||||||
let pc = null;
|
let pc = null;
|
||||||
let es = null;
|
let es = null;
|
||||||
let watching = false;
|
let watching = false;
|
||||||
|
|
||||||
function backend() { return document.querySelector('input[name="watchBackend"]:checked').value; }
|
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() {
|
async function subscribe() {
|
||||||
if (watching) return;
|
if (watching) return;
|
||||||
const b = backend();
|
const b = backend();
|
||||||
|
|
@ -200,9 +310,10 @@ window.LiveSFU = (function () {
|
||||||
log(logEl, "订阅失败:" + e.message);
|
log(logEl, "订阅失败:" + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("start").onclick = () => {
|
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) => {
|
es.addEventListener("room", (ev) => {
|
||||||
const data = JSON.parse(ev.data);
|
const data = JSON.parse(ev.data);
|
||||||
const t = findTarget(data.targets, ENUM[backend()]);
|
const t = findTarget(data.targets, ENUM[backend()]);
|
||||||
|
|
@ -211,9 +322,11 @@ window.LiveSFU = (function () {
|
||||||
: '<span class="muted">等待 ' + shortName(ENUM[backend()]) + " 推流…</span>";
|
: '<span class="muted">等待 ' + shortName(ENUM[backend()]) + " 推流…</span>";
|
||||||
if (t && !watching) subscribe();
|
if (t && !watching) subscribe();
|
||||||
});
|
});
|
||||||
|
es.onerror = (e)=>{
|
||||||
|
log(logEl, "房间事件流错误,可能是未登录或权限不足");
|
||||||
|
};
|
||||||
log(logEl, "已连接房间事件流");
|
log(logEl, "已连接房间事件流");
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById("stop").onclick = () => {
|
document.getElementById("stop").onclick = () => {
|
||||||
if (pc) pc.close();
|
if (pc) pc.close();
|
||||||
pc = null;
|
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 };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/publish">开始直播</a>
|
<a href="/publish">开始直播</a>
|
||||||
<a href="/watch">观看直播</a>
|
<a href="/watch">观看直播</a>
|
||||||
|
<a href="/login">登录</a>
|
||||||
|
<span id="navAuth" class="muted" style="margin-left:12px;"></span>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
|
|
@ -28,7 +30,7 @@
|
||||||
<button onclick="goPublish()">开始直播</button>
|
<button onclick="goPublish()">开始直播</button>
|
||||||
<button class="secondary" onclick="goWatch()">观看直播</button>
|
<button class="secondary" onclick="goWatch()">观看直播</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>后端能力</h3>
|
<h3>后端能力</h3>
|
||||||
|
|
@ -41,6 +43,10 @@
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
LiveSFU.loadConfig(document.getElementById('backends'));
|
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() {
|
function goPublish() {
|
||||||
const r = document.getElementById('room').value.trim() || 'demo';
|
const r = document.getElementById('room').value.trim() || 'demo';
|
||||||
location.href = '/publish?room=' + encodeURIComponent(r);
|
location.href = '/publish?room=' + encodeURIComponent(r);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
|
<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>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
<div class="hero"><h1>开始直播</h1><p>房间 <b id="roomName">demo</b> · 选择分发后端,一路推流将扇出到所选 SFU。</p></div>
|
<div class="hero"><h1>开始直播</h1><p>房间 <b id="roomName">demo</b> · 选择分发后端,一路推流将扇出到所选 SFU。</p></div>
|
||||||
|
|
@ -38,6 +38,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/app.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
|
<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>
|
||||||
<div class="band">
|
<div class="band">
|
||||||
<div class="hero"><h1>观看直播</h1><p>房间 <b id="roomName">demo</b> · 选择从哪个 SFU 拉流,房间一旦直播即自动连接。</p></div>
|
<div class="hero"><h1>观看直播</h1><p>房间 <b id="roomName">demo</b> · 选择从哪个 SFU 拉流,房间一旦直播即自动连接。</p></div>
|
||||||
|
|
@ -36,6 +36,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/app.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue