feat: 接入 sync-live 后端重构,前端全面对齐新协议与接口

- proto: live_sfu.proto -> sync_live.proto (synclive.v1/SyncLive),重生成 gen/sync_live.*
- module: example.com/live-sfu -> sync-live,go.mod/go.sum 同步
- 后端: 新增 internal/room (role/member/room/service/store/permission),manage 治理面 internal/server/manage.go,auth(jwt/policy) 与 gateway/server 重构,适配 token_type/access+refresh 与 HttpOnly Cookie
- 前端: types/api/auth/permissions 全量对齐,rooms 优先 query 分页、manage 创建,login 双模式+角色降级,用户管理封禁/解封,新增 /manage 5 Tab 治理页,主题与路由同步
- 构建: web 产物直出 internal/server/static,role 字符串序列化兼容数字,libsql 链接告警忽略

验证: web tsc --noEmit / go test / vite build / go build 常驻联调通过 (role:"creator" / manage/overview/rooms/query/create/members/refresh)
This commit is contained in:
noelorin 2026-08-21 00:31:51 +08:00
parent ffc69d691c
commit 46a6bd02d9
86 changed files with 5589 additions and 1266 deletions

View File

@ -2,6 +2,11 @@
HTTP_PORT=8088 HTTP_PORT=8088
GRPC_PORT=9090 GRPC_PORT=9090
# 鉴权密钥(正式环境必填;缺失将拒绝启动)
JWT_SECRET=
# 可选:单独指定推流 JWT 签名密钥;留空则复用 JWT_SECRET
SFU_TOKEN_SECRET=
# 后端顺序(主 SFU 在前):cloudflare 优先,srs 本地对照 # 后端顺序(主 SFU 在前):cloudflare 优先,srs 本地对照
SFU_PROVIDER=cloudflare,srs SFU_PROVIDER=cloudflare,srs
@ -18,12 +23,12 @@ CF_APP_SECRET=
CF_BASE_URL=https://rtc.live.cloudflare.com/v1 CF_BASE_URL=https://rtc.live.cloudflare.com/v1
CF_STUN_URL=stun:stun.cloudflare.com:3478 CF_STUN_URL=stun:stun.cloudflare.com:3478
# 推流 JWT(SRS 入口鉴权,可选;设为 1 后 WHIP 必须带有效 token) # 冷启动首个管理员(仅在用户库为空时生效;正式部署建议显式配置)
DEMO_TOKEN_SECRET= BOOTSTRAP_ADMIN_USER=
SFU_TOKEN_REQUIRED=0 BOOTSTRAP_ADMIN_PASS=
# Turso 嵌入式(默认 DB,房间分发拓扑持久化) # 嵌入式 libSQL(默认 DB,房间分发拓扑持久化)
# 仅支持嵌入 file: 模式,开箱即用,无需远端凭证 # 仅支持嵌入 file: 模式,开箱即用,无需远端凭证
TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL TURSO_DATABASE_URL=file:./data/sync-live.db?cache=shared&_journal_mode=WAL
# 兼容 DATABASE_URL # 兼容 DATABASE_URL
# DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL # DATABASE_URL=file:./data/sync-live.db?cache=shared&_journal_mode=WAL

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ data/*
# 前端工程 # 前端工程
web/node_modules web/node_modules
server
/tmp/sync-live

120
README.md
View File

@ -1,110 +1,113 @@
# 直播 SFU 分流 Demo(Cloudflare Realtime · SRS) # SyncLive — 同步直播 · SFU 分流服务(对标 SyncTV)
参考 GOSpeak 技术栈的直播 SFU 分流演示。控制面用 **protobuf / gRPC** 定义,媒体面走 > **对标 SyncTV**:SyncTV 是 *Sync + TV*(一起看),**SyncLive 是 *Sync + Live*(一起播·一起看直播)**。本项目是 SyncTV 理念在直播场景的姐妹实现:一路推流,多端同步扇出。
**Cloudflare Realtime**(主 SFU)与 **SRS**(本地对照)的 WebRTC 扇出。
基于 **Cloudflare Realtime + SRS** 双 SFU 的同步直播分流服务。控制面以 **protobuf / gRPC** 定义,媒体面走
**Cloudflare Realtime**(主 SFU)与 **SRS**(本地/对照)的 WebRTC 扇出。
- 一路推流 → SFU 扇出 → 多路拉流。 - 一路推流 → SFU 扇出 → 多路拉流。
- 同一路发布可同时落到 **Cloudflare Realtime** 与 **SRS** 两条分发链路(分流)。 - 同一路发布可同时落到 **Cloudflare Realtime** 与 **SRS** 两条分发链路(分流)。
- 浏览器经服务端反向代理直连 SFU,凭证不出服务端。 - 浏览器经服务端反向代理直连 SFU,凭证不出服务端。
- 默认 DB 使用 Turso 嵌入式 (libSQL file):房间分发拓扑持久化到本地文件。 - 默认 DB 使用嵌入式 libSQL (file) 持久化房间分发拓扑,零外部依赖。
- 前端为 **SolidJS + TanStack Router/Query + Ark UI + Tailwind v4** 单页应用,构建产物嵌入 Go 二进制。 - 前端为 **SolidJS + TanStack Router/Query + Ark UI + Tailwind v4** 单页应用,构建产物嵌入 Go 二进制。
## 目录结构 ## 目录结构
``` ```
live-sfu-demo/ sync-live/
api/live_sfu.proto # protobuf 控制面契约 api/sync_live.proto # protobuf 控制面契约
gen/ # buf generate 产出(Go) gen/ # buf generate 产出(Go)
internal/ internal/
config/ # 运行参数 config/ # 运行参数 + 正式环境密钥校验
db/ # Turso 嵌入式 (libSQL file) 持久化 db/ # 嵌入式 libSQL 持久化
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 媒体反代 + 房间拓扑
static/ # 前端构建产物(由 web/ 构建生成,Go embed) static/ # 前端构建产物(由 web/ 构建生成,Go embed)
auth/ # Casbin RBAC + JWT + bcrypt 用户存储 auth/ # Casbin RBAC + JWT + 用户存储
cmd/server/main.go cmd/server/main.go
deploy/ # docker-compose.yml + srs.conf deploy/ # docker-compose.yml + srs.conf
web/ # 前端工程(Solid + TanStack + Ark UI + Tailwind v4) web/ # 前端工程(Solid + TanStack + Ark UI + Tailwind v4)
src/ # 页面 / 组件 / 库 src/
vite.config.ts # 构建输出到 ../internal/server/static vite.config.ts # 构建输出到 ../internal/server/static
``` ```
## 运行(SRS 链路,开箱即跑) ## 运行
### 1. 配置环境变量
复制示例并填写鉴权密钥(正式环境必填):
```bash ```bash
# 1) 起本地 SRS(WHIP/WHEP/HLS 三协议后端)
cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs
# 2) 起 Demo 控制面(默认 Turso 嵌入式,无需额外配置)
cd live-sfu-demo
cp .env.example .env cp .env.example .env
go run ./cmd/server # 必填:JWT_SECRET(与可选 SFU_TOKEN_SECRET)
# 可选:BOOTSTRAP_ADMIN_USER / BOOTSTRAP_ADMIN_PASS 冷启动首个管理员
# 3) 打开浏览器(发布/订阅需先登录,见下方鉴权说明)
# 首页: http://localhost:8088/
# 发布: http://localhost:8088/publish?room=demo
# 观看: http://localhost:8088/watch?room=demo
``` ```
两个标签页用同一房间名即可配对。发布页勾选的分发后端会在「分发状态」中实时显示; 未配置 `JWT_SECRET` 时服务将拒绝启动,避免以不安全默认密钥上线。
SRS 链路同时产出 HLS/FLV,分发状态面板可直接获取 `/live/<stream>.m3u8` / `.flv` 链接,
经 Go 网关 (`/live/*`) 反代,无需浏览器直连 8080。 ### 2. 起本地 SRS(可选,对照后端)
```bash
cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs
```
### 3. 起控制面
```bash
go run ./cmd/server
# 首页: http://localhost:8088/
# 发布: http://localhost:8088/publish?room=<房间名>
# 观看: http://localhost:8088/watch?room=<房间名>
# 登录: http://localhost:8088/login
```
发布/订阅需先登录;房间名通过 URL `?room=` 指定,不依赖默认占位。
## 鉴权(Casbin RBAC + JWT) ## 鉴权(Casbin RBAC + JWT)
控制面已启用登录与 RBAC: 控制面启用登录与 RBAC:
- 发布 / 订阅 / 房间管理需要登录;`guest` 仅可读取配置与 SRS 切片流(HLS/FLV)。 - 发布 / 订阅 / 房间管理需登录;`guest` 仅可读取配置与 SRS 切片流(HLS/FLV)。
- 默认账号(bcrypt,落盘 `data/users.json`): - 用户存储于 `data/users.json`(首次启动为空,需通过 `BOOTSTRAP_ADMIN_*` 或注册创建首个管理员)。
- `admin / Admin123!`(admin) - 登录后服务端下发 HttpOnly Cookie,前端同源请求自动携带。
- `publisher / Publisher123!`(publisher)
- `viewer / Viewer123!`(viewer)
- 登录后服务端下发 HttpOnly Cookie,前端同源请求自动携带;在 `/login` 登录。
## 默认 DB:Turso 嵌入式 角色:`admin` / `publisher` / `viewer` / `guest`,策略见 `internal/auth/policy.csv`。
房间“分发目标”拓扑(`stream_targets`)默认持久化到 **Turso 嵌入式** ## 持久化(嵌入式 libSQL)
(`github.com/tursodatabase/go-libsql` 的 SQLite 兼容 file 模式),而非纯内存。
- **DSN**:`TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL`(默认) 房间「分发目标」拓扑(`stream_targets`)默认持久化到嵌入式 libSQL file,而非纯内存:
- 兼容 `DATABASE_URL` 覆盖
- 仅支持嵌入 file: / :memory:,拒绝 libsql:// 远程(嵌入适配专注单机持久化) - DSN:`file:./data/sync-live.db?cache=shared&_journal_mode=WAL`(默认)
- 自动建表 `(stream_targets, rooms)`,`SetMaxOpenConns(1)` 适配 SQLite 单写模型 - 兼容 `DATABASE_URL` 覆盖;仅支持 file: / :memory:,拒绝远程 libsql://
- 重启后自动 `LoadAll` 恢复房间 - 自动建表,重启后自动恢复房间拓扑
```bash ```bash
# 默认即嵌入文件
TURSO_DATABASE_URL=file:./data/live-sfu.db?cache=shared&_journal_mode=WAL
# 验证持久化:发布后重启,/api/rooms 仍在
curl -b cookie.txt http://localhost:8088/api/rooms | jq curl -b cookie.txt 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 凭证后启用主 SFU:
``` ```
CF_APP_ID=xxxxxxxxxxxx CF_APP_ID=xxxxxxxxxxxx
CF_APP_SECRET=xxxxxxxxxxxx CF_APP_SECRET=xxxxxxxxxxxx
``` ```
- 发布:服务端用 `CF_APP_SECRET` 创建 Cloudflare session,浏览器只交换 SDP(`/api/cf/...` 反代注入 Bearer)。 - 发布:服务端用 `CF_APP_SECRET` 创建 Cloudflare session,浏览器只交换 SDP(反代注入 Bearer)。
- 观看:服务端为观众创建 viewer session,并订阅发布者 session 的轨道(`location=remote`)。 - 观看:服务端为观众创建 viewer session,并订阅发布者 session 的轨道。
Cloudflare 未配置时,UI 会标注「未配置」,SRS 链路不受影响。 Cloudflare 未配置时 UI 标注「未配置」,SRS 链路不受影响。
## protobuf / gRPC ## protobuf / gRPC
```bash ```bash
# 修改 api/live_sfu.proto 后重新生成
export PATH="$HOME/go/bin:$PATH" export PATH="$HOME/go/bin:$PATH"
buf generate api buf generate api
``` ```
契约(`LiveSFU` 服务):`GetConfig` / `ListRooms` / `Publish` / `Subscribe` / `StopStream` / 契约(`SyncLive` 服务):`GetConfig` / `ListRooms` / `Publish` / `Subscribe` / `StopStream` /
`WatchRoom`(服务端流式推送房间分发拓扑)以及 `Login` / `Register` / `GetMe` / `ListUsers` / `UpdateUserRole`。 `WatchRoom`(服务端流式推送房间分发拓扑)以及 `Login` / `Register` / `GetMe` / `ListUsers` / `UpdateUserRole`。
gRPC 监听 `GRPC_PORT`(默认 9090),浏览器走同端口的 JSON 网关(`protojson`)。 gRPC 监听 `GRPC_PORT`(默认 9090),浏览器走同端口的 JSON 网关(`protojson`)。
@ -117,10 +120,6 @@ pnpm dev # http://localhost:5173 热更新
pnpm build # 构建进 ../internal/server/static(被 Go embed) pnpm build # 构建进 ../internal/server/static(被 Go embed)
``` ```
技术栈:SolidJS(`vite-plugin-solid`)、TanStack Router + Query、Ark UI(无样式原语)、
Tailwind v4(CSS-first `@theme` 做 design token 体系化驱动)。design token 集中在
`web/src/index.css` 的 `@theme`,组件语义类在 `web/src/components/ui.tsx` 中以 token 派生。
## 分流拓扑 ## 分流拓扑
``` ```
@ -128,14 +127,13 @@ Tailwind v4(CSS-first `@theme` 做 design token 体系化驱动)。design to
└────WHIP────────────▶ SRS SFU ─┬─▶ WHEP viewer(s) (低延时 0.2-0.5s) └────WHIP────────────▶ SRS SFU ─┬─▶ WHEP viewer(s) (低延时 0.2-0.5s)
├─▶ HLS viewer(s) (全端兼容,/live/*.m3u8) ├─▶ HLS viewer(s) (全端兼容,/live/*.m3u8)
└─▶ FLV viewer(s) └─▶ FLV viewer(s)
一路 WHIP 推流,SRS 自动 remux 三协议同出;房间分发目标由 WatchRoom 实时广播
``` ```
> SRS 推流链路详解见 [`docs/streaming-pipeline.md`](docs/streaming-pipeline.md)。 > SRS 推流链路详解见 [`docs/streaming-pipeline.md`](docs/streaming-pipeline.md)。
## 主播外部推送 API(弹幕广播) ## 主播外部推送 API(弹幕广播)
除观众可发送的 `POST /api/room/{room}/chat` 外,额外提供**仅主播可用**的独立推送端点,供 OBS 脚本 / 机器人 / 管理工具以主播身份推送弹幕: 除观众可发送的 `POST /api/room/{room}/chat` 外,额外提供**仅主播可用**的独立推送端点:
``` ```
POST /api/room/{room}/broadcast POST /api/room/{room}/broadcast
@ -146,6 +144,4 @@ Content-Type: application/json
``` ```
- 权限:需 `room:publish`(仅 `publisher` / `admin`),未登录返回 `401`,越权返回 `403`。 - 权限:需 `room:publish`(仅 `publisher` / `admin`),未登录返回 `401`,越权返回 `403`。
- 推送的弹幕自动标记 `host: true`,经同一 `chatHub` 广播,主播端与观众端实时可见;前端在消息列表与弹幕叠加层以「主播」标识区分。 - 推送的弹幕自动标记 `host: true`,经同一 `chatHub` 广播;同样受滑动窗口限流保护。
- 同样受滑动窗口限流(房间 + 发送者 + IP,3s 内 5 条)保护。
- 外部工具用 `Authorization: Bearer <token>` 调用;浏览器端同源请求自动携带登录 Cookie。

View File

@ -1,15 +1,15 @@
syntax = "proto3"; syntax = "proto3";
package gospeak.livedemo.v1; package synclive.v1;
option go_package = "gospeak-live-sfu-demo/gen"; option go_package = "sync-live/gen";
// LiveSFU 是直播 SFU 分流 Demo 的控制面契约(protobuf / gRPC)。 // SyncLive 是直播 SFU 分流服务的控制面契约(protobuf / gRPC)。
// //
// 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS // 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS
// 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。 // 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。
// 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。 // 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。
service LiveSFU { service SyncLive {
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse); rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse); rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
rpc Publish(PublishRequest) returns (PublishResponse); rpc Publish(PublishRequest) returns (PublishResponse);

View File

@ -4,12 +4,15 @@ import (
"log" "log"
"net/http" "net/http"
"gospeak-live-sfu-demo/internal/config" "sync-live/internal/config"
"gospeak-live-sfu-demo/internal/server" "sync-live/internal/server"
) )
func main() { func main() {
cfg := config.Load() cfg := config.Load()
if err := cfg.Validate(); err != nil {
log.Fatalf("config invalid: %v", err)
}
srv := server.New(cfg) srv := server.New(cfg)
defer func() { defer func() {
if err := srv.Close(); err != nil { if err := srv.Close(); err != nil {
@ -20,10 +23,10 @@ func main() {
log.Printf("[warn] grpc control plane failed to start: %v", err) log.Printf("[warn] grpc control plane failed to start: %v", err)
} }
addr := ":" + cfg.HTTPPort addr := ":" + cfg.HTTPPort
log.Printf("live-sfu demo ready: http://localhost:%s (grpc :%s)", cfg.HTTPPort, cfg.GRPCPort) log.Printf("sync-live 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) log.Printf("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)
} }

View File

@ -2,7 +2,7 @@ services:
# SRS:本地对照 SFU(WHIP/WHEP)。媒体走 :8000,信令/HTTP API 走 :1985。 # SRS:本地对照 SFU(WHIP/WHEP)。媒体走 :8000,信令/HTTP API 走 :1985。
srs: srs:
image: ossrs/srs:6 image: ossrs/srs:6
container_name: live-sfu-srs container_name: sync-live-srs
ports: ports:
- "1935:1935" - "1935:1935"
- "1985:1985" - "1985:1985"

View File

@ -1,13 +1,39 @@
# Design Spec — 设计规范 # 架构设计规范 — SyncLive
## 设计标准 SyncLive(同步直播,对标 SyncTV)的整体架构与模块划分说明。
对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。 ## 分层架构
## 创意自由度 ```
浏览器 (SolidJS SPA)
│ HTTP / SSE / WebRTC (信令经服务端反代,媒体直连 SFU)
▼
控制面 (Go)
├─ gRPC + JSON 网关 # protobuf 定义的统一控制契约
├─ 鉴权 (Casbin RBAC + JWT) # 登录、权限校验、用户管理
├─ 房间拓扑 (roomHub) # 内存态 + 嵌入式 libSQL 持久化
└─ 媒体面反代 (httputil) # SRS / Cloudflare 信令与 HLS 反代
│
▼
媒体面 (SFU Provider 抽象)
├─ Cloudflare Realtime # 主 SFU(托管,低延迟扇出)
└─ SRS # 本地/对照 SFU(WHIP/WHEP + HLS/FLV 同出)
```
将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。 ## 模块职责
## 沉浸式体验 - `api/`:protobuf 控制面契约(`SyncLive` 服务),单一事实来源,经 `buf generate` 生成 Go 代码到 `gen/`。
- `internal/config/`:运行参数与环境变量加载,含正式环境密钥校验(`Validate`)。
- `internal/db/`:嵌入式 libSQL 持久化(房间分发拓扑),仅 file:/:memory: 模式。
- `internal/auth/`:Casbin RBAC 模型与策略、JWT 签发校验、用户存储。
- `internal/sfu/`:SFU 后端 Provider 抽象(Cloudflare / SRS),统一 `BackendInfo` 与能力查询。
- `internal/server/`:HTTP/gRPC 服务装配、JSON 网关、房间与聊天 SSE、媒体反代、静态 UI 嵌入。
- `cmd/server/`:进程入口,装配配置、服务与优雅关闭。
- `web/`:前端单页应用(SolidJS + TanStack + Ark UI + Tailwind v4),构建产物由 Go embed 内联。
融合代码、高级渲染逻辑,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。 ## 设计约束
- 控制面与媒体面分离:浏览器只与服务端交换 SDP/信令,SFU 凭证不出服务端。
- 多 SFU 后端可同时承载同一路发布(分流),拓扑由 `WatchRoom` / SSE 实时广播。
- 持久化默认嵌入式、零外部依赖;远程 libSQL 仅作可选项。
- 正式环境密钥必须显式配置,禁止内置弱密码与不安全默认密钥。

View File

@ -1,4 +1,4 @@
# 推流链路文档 · PC → WHIP → SRS → HLS / WHEP / FLV # SyncLive 推流链路文档 · PC → WHIP → SRS → HLS / WHEP / FLV
> 默认推流链路已升级为 **一路 WHIP 推流,SRS 三协议同出**。本文档沉淀该链路的拓扑、配置与验证方法,便于后续维护与排查。 > 默认推流链路已升级为 **一路 WHIP 推流,SRS 三协议同出**。本文档沉淀该链路的拓扑、配置与验证方法,便于后续维护与排查。
@ -107,7 +107,7 @@ services:
| `SRS_APP` | `live` | 推流 app,决定 URL 路径 `/live/<stream>` | | `SRS_APP` | `live` | 推流 app,决定 URL 路径 `/live/<stream>` |
| `SRS_CANDIDATE` | `127.0.0.1` | ICE candidate,容器部署填宿主机/公网 IP | | `SRS_CANDIDATE` | `127.0.0.1` | ICE candidate,容器部署填宿主机/公网 IP |
| `SFU_PROVIDER` | `cloudflare,srs` | 后端顺序,(`GetConfig` 返回) | | `SFU_PROVIDER` | `cloudflare,srs` | 后端顺序,(`GetConfig` 返回) |
| `TURSO_DATABASE_URL` | `file:./data/live-sfu.db?cache=shared&_journal_mode=WAL` | 房间拓扑持久化(本次未改) | | `TURSO_DATABASE_URL` | `file:./data/sync-live.db?cache=shared&_journal_mode=WAL` | 房间拓扑持久化(本次未改) |
### 2.4 反向代理 · `internal/server/proxy.go` + `internal/server/server.go` ### 2.4 反向代理 · `internal/server/proxy.go` + `internal/server/server.go`
@ -174,15 +174,15 @@ Body: offer.sdp → 200 answer.sdp → pc.setRemoteDescription(answer)
```bash ```bash
# 1) 起 SRS(含 HLS) # 1) 起 SRS(含 HLS)
cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs && docker logs -f live-sfu-srs cd deploy && SRS_CANDIDATE=127.0.0.1 docker compose up -d srs && docker logs -f sync-live-srs
# 2) 起控制面 # 2) 起控制面
cd .. && go run ./cmd/server cd .. && go run ./cmd/server
# 日志:live-sfu demo ready: http://localhost:8088 # 日志:sync-live ready: http://localhost:8088
# 3) 浏览器 # 3) 浏览器
# 发布: http://localhost:8088/publish?room=demo → 勾选 SRS → 开始推流 # 发布: http://localhost:8088/publish?room=<房间名> → 勾选 SRS → 开始推流
# 观看 WHEP: http://localhost:8088/watch?room=demo → 选 SRS WebRTC → 开始观看(<1s) # 观看 WHEP: http://localhost:8088/watch?room=<房间名> → 选 SRS WebRTC → 开始观看(<1s)
# 观看 HLS : 同页切 SRS HLS → 停止后重新开始观看(约 5–10s 后出画面) # 观看 HLS : 同页切 SRS HLS → 停止后重新开始观看(约 5–10s 后出画面)
# 裸 HLS: http://localhost:8088/live/live-demo.m3u8 (VLC / ffplay / curl 均可) # 裸 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.m3u8
@ -192,7 +192,7 @@ 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/` 是否有切片。 * `m3u8 404`:推流后等待 10–20s 再试;检查 `docker exec sync-live-srs ls /usr/local/srs/objs/nginx/html/live/` 是否有切片。
* `candidate` 不通:容器内 `ip` 与浏览器不在同一网段时,`SRS_CANDIDATE` 改为宿主机公网/局域网 IP 后 `docker compose up -d --force-recreate`。 * `candidate` 不通:容器内 `ip` 与浏览器不在同一网段时,`SRS_CANDIDATE` 改为宿主机公网/局域网 IP 后 `docker compose up -d --force-recreate`。
* HLS 跨域:已由 `srsHlsProxyHandler` 统一加 CORS,若直连 8080 需自行在 `srs.conf` 加 `crossdomain`(http_server 无此指令,建议走网关)。 * HLS 跨域:已由 `srsHlsProxyHandler` 统一加 CORS,若直连 8080 需自行在 `srs.conf` 加 `crossdomain`(http_server 无此指令,建议走网关)。
@ -206,7 +206,7 @@ ffplay http://localhost:8088/live/live-demo.m3u8
| 单向大并发/回看友好 | HLS | 全端兼容,CDN 友好,成本最低 | | 单向大并发/回看友好 | HLS | 全端兼容,CDN 友好,成本最低 |
| 演示/对比 | WHEP + HLS 双出(默认) | 一路推流兼顾低延时与兼容性 | | 演示/对比 | WHEP + HLS 双出(默认) | 一路推流兼顾低延时与兼容性 |
> 本 Demos 默认即为双出:无需额外推流或配置,开箱获得两条观看链路。 > 默认即为双出:无需额外推流或配置,开箱获得两条观看链路。
--- ---
@ -215,7 +215,7 @@ ffplay http://localhost:8088/live/live-demo.m3u8
* 服务端配置:`deploy/srs.conf`、`deploy/docker-compose.yml`、`internal/config/config.go`、`.env.example` * 服务端配置:`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/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` * 前端:`internal/server/static/app.js`(`publishSRS`/`watchSRS`/`watchHLS`)、`internal/server/static/publish.html`、`internal/server/static/watch.html`
* 协议:`api/live_sfu.proto`(`BackendKind` / `StreamTarget`) * 协议:`api/sync_live.proto`(`BackendKind` / `StreamTarget`)
--- ---

View File

@ -1,517 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// source: live_sfu.proto
package gen
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
LiveSFU_GetConfig_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/GetConfig"
LiveSFU_ListRooms_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/ListRooms"
LiveSFU_Publish_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Publish"
LiveSFU_Subscribe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Subscribe"
LiveSFU_StopStream_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/StopStream"
LiveSFU_WatchRoom_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/WatchRoom"
LiveSFU_Login_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Login"
LiveSFU_Register_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/Register"
LiveSFU_GetMe_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/GetMe"
LiveSFU_ListUsers_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/ListUsers"
LiveSFU_UpdateUserRole_FullMethodName = "/gospeak.livedemo.v1.LiveSFU/UpdateUserRole"
)
// LiveSFUClient is the client API for LiveSFU service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// LiveSFU 是直播 SFU 分流 Demo 的控制面契约(protobuf / gRPC)。
//
// 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS
// 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。
// 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。
type LiveSFUClient interface {
GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error)
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error)
Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error)
StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, error)
WatchRoom(ctx context.Context, in *WatchRoomRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[RoomEvent], error)
// ---- Auth & Casbin ----
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
GetMe(ctx context.Context, in *GetMeRequest, opts ...grpc.CallOption) (*GetMeResponse, error)
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
UpdateUserRole(ctx context.Context, in *UpdateUserRoleRequest, opts ...grpc.CallOption) (*UpdateUserRoleResponse, error)
}
type liveSFUClient struct {
cc grpc.ClientConnInterface
}
func NewLiveSFUClient(cc grpc.ClientConnInterface) LiveSFUClient {
return &liveSFUClient{cc}
}
func (c *liveSFUClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetConfigResponse)
err := c.cc.Invoke(ctx, LiveSFU_GetConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomsResponse)
err := c.cc.Invoke(ctx, LiveSFU_ListRooms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishResponse)
err := c.cc.Invoke(ctx, LiveSFU_Publish_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubscribeResponse)
err := c.cc.Invoke(ctx, LiveSFU_Subscribe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StopStreamResponse)
err := c.cc.Invoke(ctx, LiveSFU_StopStream_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) WatchRoom(ctx context.Context, in *WatchRoomRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[RoomEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &LiveSFU_ServiceDesc.Streams[0], LiveSFU_WatchRoom_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchRoomRequest, RoomEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LiveSFU_WatchRoomClient = grpc.ServerStreamingClient[RoomEvent]
func (c *liveSFUClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, LiveSFU_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterResponse)
err := c.cc.Invoke(ctx, LiveSFU_Register_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) GetMe(ctx context.Context, in *GetMeRequest, opts ...grpc.CallOption) (*GetMeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMeResponse)
err := c.cc.Invoke(ctx, LiveSFU_GetMe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUsersResponse)
err := c.cc.Invoke(ctx, LiveSFU_ListUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *liveSFUClient) UpdateUserRole(ctx context.Context, in *UpdateUserRoleRequest, opts ...grpc.CallOption) (*UpdateUserRoleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserRoleResponse)
err := c.cc.Invoke(ctx, LiveSFU_UpdateUserRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// LiveSFUServer is the server API for LiveSFU service.
// All implementations should embed UnimplementedLiveSFUServer
// for forward compatibility.
//
// LiveSFU 是直播 SFU 分流 Demo 的控制面契约(protobuf / gRPC)。
//
// 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS
// 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。
// 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。
type LiveSFUServer interface {
GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error)
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
Publish(context.Context, *PublishRequest) (*PublishResponse, error)
Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error)
StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error)
WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error
// ---- Auth & Casbin ----
Login(context.Context, *LoginRequest) (*LoginResponse, error)
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error)
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error)
}
// UnimplementedLiveSFUServer should be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedLiveSFUServer struct{}
func (UnimplementedLiveSFUServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
}
func (UnimplementedLiveSFUServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRooms not implemented")
}
func (UnimplementedLiveSFUServer) Publish(context.Context, *PublishRequest) (*PublishResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Publish not implemented")
}
func (UnimplementedLiveSFUServer) Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Subscribe not implemented")
}
func (UnimplementedLiveSFUServer) StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopStream not implemented")
}
func (UnimplementedLiveSFUServer) WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error {
return status.Errorf(codes.Unimplemented, "method WatchRoom not implemented")
}
func (UnimplementedLiveSFUServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedLiveSFUServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedLiveSFUServer) GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMe not implemented")
}
func (UnimplementedLiveSFUServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented")
}
func (UnimplementedLiveSFUServer) UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserRole not implemented")
}
func (UnimplementedLiveSFUServer) testEmbeddedByValue() {}
// UnsafeLiveSFUServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to LiveSFUServer will
// result in compilation errors.
type UnsafeLiveSFUServer interface {
mustEmbedUnimplementedLiveSFUServer()
}
func RegisterLiveSFUServer(s grpc.ServiceRegistrar, srv LiveSFUServer) {
// If the following call pancis, it indicates UnimplementedLiveSFUServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&LiveSFU_ServiceDesc, srv)
}
func _LiveSFU_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).GetConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_GetConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).GetConfig(ctx, req.(*GetConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).ListRooms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_ListRooms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).ListRooms(ctx, req.(*ListRoomsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_Publish_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Publish(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Publish_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Publish(ctx, req.(*PublishRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_Subscribe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubscribeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Subscribe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Subscribe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Subscribe(ctx, req.(*SubscribeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_StopStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopStreamRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).StopStream(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_StopStream_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).StopStream(ctx, req.(*StopStreamRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_WatchRoom_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchRoomRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(LiveSFUServer).WatchRoom(m, &grpc.GenericServerStream[WatchRoomRequest, RoomEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type LiveSFU_WatchRoomServer = grpc.ServerStreamingServer[RoomEvent]
func _LiveSFU_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_Register_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_GetMe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).GetMe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_GetMe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).GetMe(ctx, req.(*GetMeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).ListUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_ListUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).ListUsers(ctx, req.(*ListUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LiveSFU_UpdateUserRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserRoleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LiveSFUServer).UpdateUserRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: LiveSFU_UpdateUserRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LiveSFUServer).UpdateUserRole(ctx, req.(*UpdateUserRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
// LiveSFU_ServiceDesc is the grpc.ServiceDesc for LiveSFU service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var LiveSFU_ServiceDesc = grpc.ServiceDesc{
ServiceName: "gospeak.livedemo.v1.LiveSFU",
HandlerType: (*LiveSFUServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetConfig",
Handler: _LiveSFU_GetConfig_Handler,
},
{
MethodName: "ListRooms",
Handler: _LiveSFU_ListRooms_Handler,
},
{
MethodName: "Publish",
Handler: _LiveSFU_Publish_Handler,
},
{
MethodName: "Subscribe",
Handler: _LiveSFU_Subscribe_Handler,
},
{
MethodName: "StopStream",
Handler: _LiveSFU_StopStream_Handler,
},
{
MethodName: "Login",
Handler: _LiveSFU_Login_Handler,
},
{
MethodName: "Register",
Handler: _LiveSFU_Register_Handler,
},
{
MethodName: "GetMe",
Handler: _LiveSFU_GetMe_Handler,
},
{
MethodName: "ListUsers",
Handler: _LiveSFU_ListUsers_Handler,
},
{
MethodName: "UpdateUserRole",
Handler: _LiveSFU_UpdateUserRole_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchRoom",
Handler: _LiveSFU_WatchRoom_Handler,
ServerStreams: true,
},
},
Metadata: "live_sfu.proto",
}

File diff suppressed because it is too large Load Diff

517
gen/sync_live_grpc.pb.go Normal file
View File

@ -0,0 +1,517 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// source: sync_live.proto
package gen
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SyncLive_GetConfig_FullMethodName = "/synclive.v1.SyncLive/GetConfig"
SyncLive_ListRooms_FullMethodName = "/synclive.v1.SyncLive/ListRooms"
SyncLive_Publish_FullMethodName = "/synclive.v1.SyncLive/Publish"
SyncLive_Subscribe_FullMethodName = "/synclive.v1.SyncLive/Subscribe"
SyncLive_StopStream_FullMethodName = "/synclive.v1.SyncLive/StopStream"
SyncLive_WatchRoom_FullMethodName = "/synclive.v1.SyncLive/WatchRoom"
SyncLive_Login_FullMethodName = "/synclive.v1.SyncLive/Login"
SyncLive_Register_FullMethodName = "/synclive.v1.SyncLive/Register"
SyncLive_GetMe_FullMethodName = "/synclive.v1.SyncLive/GetMe"
SyncLive_ListUsers_FullMethodName = "/synclive.v1.SyncLive/ListUsers"
SyncLive_UpdateUserRole_FullMethodName = "/synclive.v1.SyncLive/UpdateUserRole"
)
// SyncLiveClient is the client API for SyncLive service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// SyncLive 是直播 SFU 分流服务的控制面契约(protobuf / gRPC)。
//
// 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS
// 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。
// 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。
type SyncLiveClient interface {
GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error)
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error)
Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error)
StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, error)
WatchRoom(ctx context.Context, in *WatchRoomRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[RoomEvent], error)
// ---- Auth & Casbin ----
Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
GetMe(ctx context.Context, in *GetMeRequest, opts ...grpc.CallOption) (*GetMeResponse, error)
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
UpdateUserRole(ctx context.Context, in *UpdateUserRoleRequest, opts ...grpc.CallOption) (*UpdateUserRoleResponse, error)
}
type syncLiveClient struct {
cc grpc.ClientConnInterface
}
func NewSyncLiveClient(cc grpc.ClientConnInterface) SyncLiveClient {
return &syncLiveClient{cc}
}
func (c *syncLiveClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetConfigResponse)
err := c.cc.Invoke(ctx, SyncLive_GetConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomsResponse)
err := c.cc.Invoke(ctx, SyncLive_ListRooms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) Publish(ctx context.Context, in *PublishRequest, opts ...grpc.CallOption) (*PublishResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishResponse)
err := c.cc.Invoke(ctx, SyncLive_Publish_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (*SubscribeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubscribeResponse)
err := c.cc.Invoke(ctx, SyncLive_Subscribe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) StopStream(ctx context.Context, in *StopStreamRequest, opts ...grpc.CallOption) (*StopStreamResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StopStreamResponse)
err := c.cc.Invoke(ctx, SyncLive_StopStream_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) WatchRoom(ctx context.Context, in *WatchRoomRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[RoomEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &SyncLive_ServiceDesc.Streams[0], SyncLive_WatchRoom_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchRoomRequest, RoomEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SyncLive_WatchRoomClient = grpc.ServerStreamingClient[RoomEvent]
func (c *syncLiveClient) 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, SyncLive_Login_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) 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, SyncLive_Register_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) 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, SyncLive_GetMe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) 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, SyncLive_ListUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *syncLiveClient) 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, SyncLive_UpdateUserRole_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SyncLiveServer is the server API for SyncLive service.
// All implementations should embed UnimplementedSyncLiveServer
// for forward compatibility.
//
// SyncLive 是直播 SFU 分流服务的控制面契约(protobuf / gRPC)。
//
// 媒体面(WebRTC SDP 交换)不在此契约内:浏览器与 Cloudflare Realtime / SRS
// 直连,经本服务反向代理转发(服务端注入 AppSecret / stream token)。
// 本契约只负责房间、后端、发布/订阅拓扑的协调与扇出状态广播。
type SyncLiveServer interface {
GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error)
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
Publish(context.Context, *PublishRequest) (*PublishResponse, error)
Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error)
StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error)
WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error
// ---- Auth & Casbin ----
Login(context.Context, *LoginRequest) (*LoginResponse, error)
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error)
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error)
}
// UnimplementedSyncLiveServer should be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSyncLiveServer struct{}
func (UnimplementedSyncLiveServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented")
}
func (UnimplementedSyncLiveServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRooms not implemented")
}
func (UnimplementedSyncLiveServer) Publish(context.Context, *PublishRequest) (*PublishResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Publish not implemented")
}
func (UnimplementedSyncLiveServer) Subscribe(context.Context, *SubscribeRequest) (*SubscribeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Subscribe not implemented")
}
func (UnimplementedSyncLiveServer) StopStream(context.Context, *StopStreamRequest) (*StopStreamResponse, error) {
return nil, status.Error(codes.Unimplemented, "method StopStream not implemented")
}
func (UnimplementedSyncLiveServer) WatchRoom(*WatchRoomRequest, grpc.ServerStreamingServer[RoomEvent]) error {
return status.Error(codes.Unimplemented, "method WatchRoom not implemented")
}
func (UnimplementedSyncLiveServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Login not implemented")
}
func (UnimplementedSyncLiveServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedSyncLiveServer) GetMe(context.Context, *GetMeRequest) (*GetMeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetMe not implemented")
}
func (UnimplementedSyncLiveServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented")
}
func (UnimplementedSyncLiveServer) UpdateUserRole(context.Context, *UpdateUserRoleRequest) (*UpdateUserRoleResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateUserRole not implemented")
}
func (UnimplementedSyncLiveServer) testEmbeddedByValue() {}
// UnsafeSyncLiveServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SyncLiveServer will
// result in compilation errors.
type UnsafeSyncLiveServer interface {
mustEmbedUnimplementedSyncLiveServer()
}
func RegisterSyncLiveServer(s grpc.ServiceRegistrar, srv SyncLiveServer) {
// If the following call panics, it indicates UnimplementedSyncLiveServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SyncLive_ServiceDesc, srv)
}
func _SyncLive_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SyncLiveServer).GetConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_GetConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).GetConfig(ctx, req.(*GetConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SyncLiveServer).ListRooms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_ListRooms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).ListRooms(ctx, req.(*ListRoomsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_Publish_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SyncLiveServer).Publish(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_Publish_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).Publish(ctx, req.(*PublishRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_Subscribe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubscribeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SyncLiveServer).Subscribe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_Subscribe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).Subscribe(ctx, req.(*SubscribeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_StopStream_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopStreamRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SyncLiveServer).StopStream(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_StopStream_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).StopStream(ctx, req.(*StopStreamRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_WatchRoom_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchRoomRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(SyncLiveServer).WatchRoom(m, &grpc.GenericServerStream[WatchRoomRequest, RoomEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type SyncLive_WatchRoomServer = grpc.ServerStreamingServer[RoomEvent]
func _SyncLive_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.(SyncLiveServer).Login(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_Login_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).Login(ctx, req.(*LoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_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.(SyncLiveServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_Register_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_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.(SyncLiveServer).GetMe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_GetMe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).GetMe(ctx, req.(*GetMeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_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.(SyncLiveServer).ListUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_ListUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).ListUsers(ctx, req.(*ListUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SyncLive_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.(SyncLiveServer).UpdateUserRole(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SyncLive_UpdateUserRole_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SyncLiveServer).UpdateUserRole(ctx, req.(*UpdateUserRoleRequest))
}
return interceptor(ctx, in, info, handler)
}
// SyncLive_ServiceDesc is the grpc.ServiceDesc for SyncLive service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SyncLive_ServiceDesc = grpc.ServiceDesc{
ServiceName: "synclive.v1.SyncLive",
HandlerType: (*SyncLiveServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetConfig",
Handler: _SyncLive_GetConfig_Handler,
},
{
MethodName: "ListRooms",
Handler: _SyncLive_ListRooms_Handler,
},
{
MethodName: "Publish",
Handler: _SyncLive_Publish_Handler,
},
{
MethodName: "Subscribe",
Handler: _SyncLive_Subscribe_Handler,
},
{
MethodName: "StopStream",
Handler: _SyncLive_StopStream_Handler,
},
{
MethodName: "Login",
Handler: _SyncLive_Login_Handler,
},
{
MethodName: "Register",
Handler: _SyncLive_Register_Handler,
},
{
MethodName: "GetMe",
Handler: _SyncLive_GetMe_Handler,
},
{
MethodName: "ListUsers",
Handler: _SyncLive_ListUsers_Handler,
},
{
MethodName: "UpdateUserRole",
Handler: _SyncLive_UpdateUserRole_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchRoom",
Handler: _SyncLive_WatchRoom_Handler,
ServerStreams: true,
},
},
Metadata: "sync_live.proto",
}

27
go.mod
View File

@ -1,4 +1,4 @@
module gospeak-live-sfu-demo module sync-live
go 1.25.0 go 1.25.0
@ -12,14 +12,39 @@ require (
) )
require ( require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/aarondl/authboss/v3 v3.5.3 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect github.com/casbin/govaluate v1.3.0 // indirect
github.com/dghubble/oauth1 v0.7.3 // indirect
github.com/friendsofgo/errors v0.9.2 // indirect
github.com/go-oauth2/oauth2/v4 v4.5.4 // indirect
github.com/go-pkgz/auth v1.26.0 // indirect
github.com/go-pkgz/repeater/v2 v2.2.0 // indirect
github.com/go-pkgz/rest v1.24.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
github.com/markbates/goth v1.82.0 // indirect
github.com/montanaflynn/stats v0.12.4 // indirect
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect
github.com/wneessen/go-mail v0.8.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // 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
) )

70
go.sum
View File

@ -1,3 +1,7 @@
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/aarondl/authboss/v3 v3.5.3 h1:eF2G85mGjADsRQnBekMSsgqvW5Naec6kew5B0PRT1nE=
github.com/aarondl/authboss/v3 v3.5.3/go.mod h1:Q/t4SEfVVI+i/JPVuB6gB5LBSkqBnsf4JezLekg5X/g=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= 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/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 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
@ -8,26 +12,65 @@ github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9my
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= 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/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE=
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk=
github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI=
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/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0=
github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ=
github.com/go-pkgz/auth v1.26.0 h1:2CL91EO8ntGWcHkXj2AXDx2NUfZ//qgo+DTX6ZLyffM=
github.com/go-pkgz/auth v1.26.0/go.mod h1:z4stIOtrnxMumDchQ3BP7sDOP36Qyq7+xgT9+E/E+/c=
github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg=
github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8=
github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 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-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 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 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/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/montanaflynn/stats v0.12.4 h1:amtNRsti20yIhcrkfUJGwoYqBR82jKQFE8SNNYVgGn0=
github.com/montanaflynn/stats v0.12.4/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d h1:l3+2LWCbVxn5itfvXAfH9n4YL9jh8l1g5zcncbIc1cs=
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d/go.mod h1:TbpErkob6SY7cyozRVSGoB3OlO2qOAgVN8O3KAJ4fMI=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc= 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= github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
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=
@ -41,23 +84,50 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w
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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= 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/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= 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/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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= 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/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
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=

View File

@ -2,6 +2,7 @@ package auth
import ( import (
"fmt" "fmt"
"os"
"time" "time"
) )
@ -16,6 +17,17 @@ func NewManager(jwtSecret string, jwtTTL time.Duration, modelPath, policyPath, u
if err != nil { if err != nil {
return nil, fmt.Errorf("init user store: %w", err) return nil, fmt.Errorf("init user store: %w", err)
} }
if bu, bp := os.Getenv("BOOTSTRAP_ADMIN_USER"), os.Getenv("BOOTSTRAP_ADMIN_PASS"); bu != "" && bp != "" {
if len(store.List()) == 0 {
if err := store.BootstrapAdmin(bu, bp); err != nil {
return nil, fmt.Errorf("bootstrap admin: %w", err)
}
// 若配置为 root 引导,则提升首个用户为 root(对标 SyncTV Root 超管)
if br := os.Getenv("BOOTSTRAP_ADMIN_ROLE"); br == RoleRoot {
_ = store.UpdateRole(bu, RoleRoot)
}
}
}
jwtMgr := NewJWTManager(jwtSecret, jwtTTL) jwtMgr := NewJWTManager(jwtSecret, jwtTTL)
enc, err := NewEnforcer(modelPath, policyPath, store) enc, err := NewEnforcer(modelPath, policyPath, store)
if err != nil { if err != nil {
@ -38,19 +50,44 @@ func (m *Manager) Login(username, password string) (string, *User, error) {
if !ok { if !ok {
return "", nil, fmt.Errorf("invalid credentials") return "", nil, fmt.Errorf("invalid credentials")
} }
if u.Status == StatusBanned {
return "", nil, fmt.Errorf("user is banned")
}
_ = m.Enforcer.AddUserRole(u.Username, u.Role) _ = m.Enforcer.AddUserRole(u.Username, u.Role)
token, err := m.JWT.Sign(u.Username, u.Role) token, err := m.JWT.Sign(u.Username, u.Role)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
ret := &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt} ret := &User{Username: u.Username, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt}
return token, ret, nil return token, ret, nil
} }
func (m *Manager) LoginWithRefresh(username, password string) (access, refresh string, user *User, err error) {
u, ok := m.Store.Verify(username, password)
if !ok {
return "", "", nil, fmt.Errorf("invalid credentials")
}
if u.Status == StatusBanned {
return "", "", nil, fmt.Errorf("user is banned")
}
_ = m.Enforcer.AddUserRole(u.Username, u.Role)
access, refresh, err = m.JWT.IssuePair(u.Username, u.Role)
if err != nil {
return "", "", nil, err
}
ret := &User{Username: u.Username, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt}
return access, refresh, ret, nil
}
func (m *Manager) Register(username, password, role string) (string, *User, error) { func (m *Manager) Register(username, password, role string) (string, *User, error) {
if role == "" { if role == "" {
role = RoleViewer role = RoleViewer
} }
role = NormalizeRole(role)
// 非 root/admin 不可自行注册高权限角色
if role == RoleRoot {
return "", nil, fmt.Errorf("cannot register as root")
}
u, err := m.Store.Create(username, password, role) u, err := m.Store.Create(username, password, role)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
@ -69,19 +106,51 @@ func (m *Manager) VerifyToken(token string) (*Claims, *User, error) {
return nil, nil, err return nil, nil, err
} }
if u, ok := m.Store.Get(claims.Username); ok { if u, ok := m.Store.Get(claims.Username); ok {
if u.Status == StatusBanned {
return nil, nil, fmt.Errorf("user is banned")
}
claims.Role = u.Role claims.Role = u.Role
return claims, &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt}, nil return claims, &User{Username: u.Username, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt}, nil
} }
return claims, &User{Username: claims.Username, Role: claims.Role}, nil return claims, &User{Username: claims.Username, Role: claims.Role, Status: StatusActive}, nil
}
func (m *Manager) RefreshAccess(refreshToken string) (string, *Claims, error) {
claims, err := m.JWT.VerifyRefresh(refreshToken)
if err != nil {
return "", nil, fmt.Errorf("invalid refresh token: %w", err)
}
if u, ok := m.Store.Get(claims.Username); ok {
if u.Status == StatusBanned {
return "", nil, fmt.Errorf("user is banned")
}
claims.Role = u.Role
}
newAccess, err := m.JWT.Sign(claims.Username, claims.Role)
if err != nil {
return "", nil, err
}
newClaims, err := m.JWT.Verify(newAccess)
if err != nil {
return "", nil, err
}
return newAccess, newClaims, nil
} }
func (m *Manager) Check(username, role, obj, act string) (bool, error) { func (m *Manager) Check(username, role, obj, act string) (bool, error) {
if role == "" { if role == "" {
if u, ok := m.Store.Get(username); ok { if u, ok := m.Store.Get(username); ok {
if u.Status == StatusBanned {
return false, nil
}
role = u.Role role = u.Role
} else { } else {
role = RoleGuest role = RoleGuest
} }
} else {
if u, ok := m.Store.Get(username); ok && u.Status == StatusBanned {
return false, nil
}
} }
sub := username sub := username
if sub == "" { if sub == "" {
@ -91,9 +160,73 @@ func (m *Manager) Check(username, role, obj, act string) (bool, error) {
} }
func (m *Manager) UpdateUserRole(targetUser, newRole string) error { func (m *Manager) UpdateUserRole(targetUser, newRole string) error {
return m.UpdateUserRoleAs("", targetUser, newRole)
}
func (m *Manager) UpdateUserRoleAs(actor, targetUser, newRole string) error {
newRole = NormalizeRole(newRole)
if newRole == RoleRoot && actor != targetUser {
// 仅 root 可授予 root,对标 SyncTV can_manage(Root, _)
if actorRole := m.getRole(actor); actorRole != RoleRoot {
return fmt.Errorf("only root can grant root role")
}
}
if !isValidRole(newRole) {
return fmt.Errorf("invalid role %q", newRole)
}
actorRole := m.getRole(actor)
targetRole := m.getRole(targetUser)
if actor != "" && !CanManage(actorRole, newRole) && actorRole != RoleRoot {
// Admin 只能管理 viewer/publisher,不能提权为 admin/root
if targetRole == newRole {
return fmt.Errorf("permission denied: %s cannot manage %s", actorRole, newRole)
}
if !CanManage(actorRole, targetRole) {
return fmt.Errorf("permission denied: %s cannot manage %s", actorRole, targetRole)
}
if newRole == RoleAdmin || newRole == RoleRoot {
return fmt.Errorf("permission denied: %s cannot grant %s", actorRole, newRole)
}
}
if err := m.Store.UpdateRole(targetUser, newRole); err != nil { if err := m.Store.UpdateRole(targetUser, newRole); err != nil {
return err return err
} }
return m.Enforcer.AddUserRole(targetUser, newRole) return m.Enforcer.AddUserRole(targetUser, newRole)
} }
func (m *Manager) UpdateUserRoleSimple(targetUser, newRole string) error {
return m.UpdateUserRoleAs("", targetUser, newRole)
}
func (m *Manager) BanUser(actor, target string) error {
actorRole := m.getRole(actor)
if actorRole != RoleRoot && actorRole != RoleAdmin {
return fmt.Errorf("permission denied: ban requires admin")
}
targetRole := m.getRole(target)
if targetRole == RoleRoot {
return fmt.Errorf("cannot ban root")
}
if actorRole == RoleAdmin && targetRole == RoleAdmin && actor != target {
return fmt.Errorf("admin cannot ban another admin")
}
return m.Store.BanUser(target)
}
func (m *Manager) UnbanUser(actor, target string) error {
actorRole := m.getRole(actor)
if actorRole != RoleRoot && actorRole != RoleAdmin {
return fmt.Errorf("permission denied: unban requires admin")
}
return m.Store.UnbanUser(target)
}
func (m *Manager) getRole(username string) string {
if username == "" {
return RoleGuest
}
if u, ok := m.Store.Get(username); ok {
return u.Role
}
return RoleGuest
}

View File

@ -10,8 +10,15 @@ import (
func TestUserStore(t *testing.T) { func TestUserStore(t *testing.T) {
store, _ := NewUserStore("") store, _ := NewUserStore("")
// 正式运行不再内置种子账号,用户需显式创建。
if _, ok := store.Get("admin"); ok {
t.Fatal("no seed admin should exist")
}
if _, err := store.Create("admin", "Admin123!", "admin"); err != nil {
t.Fatalf("create admin: %v", err)
}
if _, ok := store.Get("admin"); !ok { if _, ok := store.Get("admin"); !ok {
t.Fatal("admin should exist") t.Fatal("admin should exist after create")
} }
_, ok := store.Verify("admin", "Admin123!") _, ok := store.Verify("admin", "Admin123!")
if !ok { if !ok {
@ -101,6 +108,7 @@ func TestAuthManagerLogin(t *testing.T) {
defer os.Remove(tmpPol) defer os.Remove(tmpPol)
store, _ := NewUserStore("") store, _ := NewUserStore("")
_, _ = store.Create("admin", "Admin123!", "admin")
enc, _ := NewEnforcer("model.conf", tmpPol, store) enc, _ := NewEnforcer("model.conf", tmpPol, store)
mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc) mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc)
@ -134,6 +142,8 @@ func TestHTTPMiddleware(t *testing.T) {
defer os.Remove(tmpPol) defer os.Remove(tmpPol)
store, _ := NewUserStore("") store, _ := NewUserStore("")
_, _ = store.Create("viewer", "Viewer123!", "viewer")
_, _ = store.Create("publisher", "Publisher123!", "publisher")
enc, _ := NewEnforcer("model.conf", tmpPol, store) enc, _ := NewEnforcer("model.conf", tmpPol, store)
mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc) mgr := NewManagerWithStore(store, "test-jwt", time.Hour, enc)

View File

@ -10,13 +10,15 @@ import (
type Claims struct { type Claims struct {
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` Role string `json:"role"`
TokenType string `json:"token_type,omitempty"` // access / refresh,兼容 SyncTV 双令牌模型
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type JWTManager struct { type JWTManager struct {
secret []byte secret []byte
ttl time.Duration ttl time.Duration
issuer string refreshTTL time.Duration
issuer string
} }
func NewJWTManager(secret string, ttl time.Duration) *JWTManager { func NewJWTManager(secret string, ttl time.Duration) *JWTManager {
@ -26,20 +28,54 @@ func NewJWTManager(secret string, ttl time.Duration) *JWTManager {
if ttl == 0 { if ttl == 0 {
ttl = 2 * time.Hour ttl = 2 * time.Hour
} }
return &JWTManager{secret: []byte(secret), ttl: ttl, issuer: "live-sfu-demo"} return &JWTManager{secret: []byte(secret), ttl: ttl, refreshTTL: 7 * 24 * time.Hour, issuer: "sync-live"}
}
func NewJWTManagerWithRefresh(secret string, ttl, refreshTTL time.Duration) *JWTManager {
if secret == "" {
secret = "insecure-jwt-secret-change-me"
}
if ttl == 0 {
ttl = 2 * time.Hour
}
if refreshTTL == 0 {
refreshTTL = 7 * 24 * time.Hour
}
return &JWTManager{secret: []byte(secret), ttl: ttl, refreshTTL: refreshTTL, issuer: "sync-live"}
} }
func (j *JWTManager) Sign(username, role string) (string, error) { func (j *JWTManager) Sign(username, role string) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
Username: username, Username: username,
Role: role, Role: role,
TokenType: "access",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: j.issuer, Issuer: j.issuer,
Subject: username, Subject: username,
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)), ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
ID: username + "-access-" + now.Format(time.RFC3339Nano),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString(j.secret)
}
func (j *JWTManager) SignRefresh(username, role string) (string, error) {
now := time.Now()
claims := Claims{
Username: username,
Role: role,
TokenType: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: j.issuer,
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(j.refreshTTL)),
NotBefore: jwt.NewNumericDate(now),
ID: username + "-refresh-" + now.Format(time.RFC3339Nano),
}, },
} }
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@ -66,5 +102,40 @@ func (j *JWTManager) Verify(tokenStr string) (*Claims, error) {
return claims, nil return claims, nil
} }
func (j *JWTManager) TTL() time.Duration { return j.ttl } func (j *JWTManager) VerifyRefresh(tokenStr string) (*Claims, error) {
claims, err := j.Verify(tokenStr)
if err != nil {
return nil, err
}
if claims.TokenType != "" && claims.TokenType != "refresh" {
return nil, errors.New("not a refresh token")
}
return claims, nil
}
func (j *JWTManager) Refresh(refreshToken string) (string, *Claims, error) {
claims, err := j.VerifyRefresh(refreshToken)
if err != nil {
return "", nil, err
}
newAccess, err := j.Sign(claims.Username, claims.Role)
if err != nil {
return "", nil, err
}
newClaims, _ := j.Verify(newAccess)
return newAccess, newClaims, nil
}
func (j *JWTManager) TTL() time.Duration { return j.ttl }
func (j *JWTManager) RefreshTTL() time.Duration { return j.refreshTTL }
func (j *JWTManager) IssuePair(username, role string) (access, refresh string, err error) {
access, err = j.Sign(username, role)
if err != nil {
return "", "", err
}
refresh, err = j.SignRefresh(username, role)
if err != nil {
return "", "", err
}
return access, refresh, nil
}

View File

@ -1,35 +1,47 @@
# Casbin RBAC 策略 p, root, config, read
# p, 角色, 资源, 动作 p, root, room, list
p, root, room, publish
p, root, room, subscribe
p, root, room, stop
p, root, room, watch
p, root, room, chat
p, root, room, manage
p, root, room, delete
p, root, user, list
p, root, user, manage
p, root, srs, streams
p, root, system, manage
p, admin, config, read p, admin, config, read
p, admin, room, list p, admin, room, list
p, admin, room, publish p, admin, room, publish
p, admin, room, subscribe p, admin, room, subscribe
p, admin, room, stop p, admin, room, stop
p, admin, room, watch p, admin, room, watch
p, admin, room, chat
p, admin, room, manage
p, admin, user, list p, admin, user, list
p, admin, user, manage p, admin, user, manage
p, admin, srs, streams p, admin, srs, streams
p, publisher, config, read p, publisher, config, read
p, publisher, room, list p, publisher, room, list
p, publisher, room, publish p, publisher, room, publish
p, publisher, room, subscribe p, publisher, room, subscribe
p, publisher, room, stop p, publisher, room, stop
p, publisher, room, watch p, publisher, room, watch
p, publisher, room, chat
p, publisher, srs, streams p, publisher, srs, streams
p, viewer, config, read p, viewer, config, read
p, viewer, room, list p, viewer, room, list
p, viewer, room, subscribe p, viewer, room, subscribe
p, viewer, room, watch p, viewer, room, watch
p, viewer, room, chat
p, viewer, srs, streams p, viewer, srs, streams
p, guest, config, read p, guest, config, read
p, guest, srs, streams p, guest, srs, streams
# 默认 g 关系由代码动态维护,亦可在此预设示例用户
p, admin, room, chat
p, publisher, room, chat
p, viewer, room, chat
p, guest, room, chat p, guest, room, chat
# g, alice, admin g, admin, admin
g, root, root
g, publisher, publisher
g, viewer, viewer
g, guest, guest
g, alice, viewer
Can't render this file because it has a wrong number of fields in line 42.

View File

@ -11,23 +11,21 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// User 表示系统用户
type User struct { type User struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"-"` // bcrypt hash,不序列化到前端 Password string `json:"-"` // bcrypt hash,不序列化到前端
Hash string `json:"hash,omitempty"` // 持久化用 Hash string `json:"hash,omitempty"` // 持久化用
Role string `json:"role"` Role string `json:"role"`
Status string `json:"status,omitempty"` // active / banned,对标 SyncTV UserStatus
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
// UserStore 负责用户持久化与校验,内存为主,可选落盘到 JSON 文件
type UserStore struct { type UserStore struct {
mu sync.RWMutex mu sync.RWMutex
users map[string]*User users map[string]*User
filePath string filePath string
} }
// NewUserStore 创建用户存储,若 filePath 非空则尝试从文件加载;若文件不存在则预置种子用户
func NewUserStore(filePath string) (*UserStore, error) { func NewUserStore(filePath string) (*UserStore, error) {
s := &UserStore{ s := &UserStore{
users: make(map[string]*User), users: make(map[string]*User),
@ -38,38 +36,27 @@ func NewUserStore(filePath string) (*UserStore, error) {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return nil, fmt.Errorf("load users: %w", 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 return s, nil
} }
func (s *UserStore) seed() { func (s *UserStore) BootstrapAdmin(username, password string) error {
seeds := []struct { if username == "" || password == "" {
username string return fmt.Errorf("bootstrap admin requires user and password")
password string
role string
}{
{"admin", "Admin123!", RoleAdmin},
{"publisher", "Publisher123!", RolePublisher},
{"viewer", "Viewer123!", RoleViewer},
} }
for _, u := range seeds { if len(password) < 8 {
hash, _ := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost) return fmt.Errorf("bootstrap admin password too short (min 8)")
s.users[u.username] = &User{
Username: u.username,
Password: string(hash),
Hash: string(hash),
Role: u.role,
CreatedAt: time.Now().Unix(),
}
} }
if _, exists := s.users[username]; exists {
return nil
}
u, err := s.Create(username, password, RoleAdmin)
if err != nil {
return err
}
_ = u
return nil
} }
func (s *UserStore) Get(username string) (*User, bool) { func (s *UserStore) Get(username string) (*User, bool) {
@ -88,7 +75,7 @@ func (s *UserStore) List() []*User {
defer s.mu.RUnlock() defer s.mu.RUnlock()
out := make([]*User, 0, len(s.users)) out := make([]*User, 0, len(s.users))
for _, u := range s.users { for _, u := range s.users {
out = append(out, &User{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt}) out = append(out, &User{Username: u.Username, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt})
} }
return out return out
} }
@ -120,6 +107,7 @@ func (s *UserStore) Create(username, password, role string) (*User, error) {
Password: string(hash), Password: string(hash),
Hash: string(hash), Hash: string(hash),
Role: role, Role: role,
Status: StatusActive,
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
} }
s.users[username] = u s.users[username] = u
@ -137,6 +125,9 @@ func (s *UserStore) Verify(username, password string) (*User, bool) {
if !ok { if !ok {
return nil, false return nil, false
} }
if u.Status == StatusBanned {
return nil, false
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
return nil, false return nil, false
} }
@ -154,10 +145,45 @@ func (s *UserStore) UpdateRole(username, role string) error {
if !ok { if !ok {
return fmt.Errorf("user %q not found", username) return fmt.Errorf("user %q not found", username)
} }
if u.Status == StatusBanned {
return fmt.Errorf("user %q is banned", username)
}
u.Role = role u.Role = role
return s.saveLocked() return s.saveLocked()
} }
func (s *UserStore) BanUser(username string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[username]
if !ok {
return fmt.Errorf("user %q not found", username)
}
u.Status = StatusBanned
return s.saveLocked()
}
func (s *UserStore) UnbanUser(username string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[username]
if !ok {
return fmt.Errorf("user %q not found", username)
}
u.Status = StatusActive
return s.saveLocked()
}
func (s *UserStore) IsBanned(username string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[username]
if !ok {
return false
}
return u.Status == StatusBanned
}
func (s *UserStore) load() error { func (s *UserStore) load() error {
data, err := os.ReadFile(s.filePath) data, err := os.ReadFile(s.filePath)
if err != nil { if err != nil {
@ -174,6 +200,12 @@ func (s *UserStore) load() error {
if u.Hash == "" && u.Password != "" { if u.Hash == "" && u.Password != "" {
u.Hash = u.Password u.Hash = u.Password
} }
if u.Status == "" {
u.Status = StatusActive
}
if u.Status != StatusBanned {
u.Status = StatusActive
}
s.users[u.Username] = u s.users[u.Username] = u
} }
return nil return nil
@ -197,6 +229,9 @@ func (s *UserStore) saveLocked() error {
if u.Hash == "" { if u.Hash == "" {
u.Hash = u.Password u.Hash = u.Password
} }
if u.Status == "" {
u.Status = StatusActive
}
list = append(list, u) list = append(list, u)
} }
data, _ := json.MarshalIndent(list, "", " ") data, _ := json.MarshalIndent(list, "", " ")
@ -204,19 +239,46 @@ func (s *UserStore) saveLocked() error {
} }
const ( const (
RoleRoot = "root"
RoleAdmin = "admin" RoleAdmin = "admin"
RolePublisher = "publisher" RolePublisher = "publisher"
RoleViewer = "viewer" RoleViewer = "viewer"
RoleGuest = "guest" RoleGuest = "guest"
StatusActive = "active"
StatusBanned = "banned"
) )
func isValidRole(r string) bool { func isValidRole(r string) bool {
switch r { switch r {
case RoleAdmin, RolePublisher, RoleViewer: case RoleRoot, RoleAdmin, RolePublisher, RoleViewer:
return true return true
default: default:
return false return false
} }
} }
func AllRoles() []string { return []string{RoleAdmin, RolePublisher, RoleViewer} } func AllRoles() []string { return []string{RoleRoot, RoleAdmin, RolePublisher, RoleViewer} }
func IsAdminOrAbove(role string) bool { return role == RoleRoot || role == RoleAdmin }
func CanManage(actorRole, targetRole string) bool {
switch actorRole {
case RoleRoot:
return true
case RoleAdmin:
return targetRole == RoleViewer || targetRole == RolePublisher
default:
return false
}
}
func NormalizeRole(r string) string {
switch r {
case RoleRoot, RoleAdmin, RolePublisher, RoleViewer:
return r
case "user":
return RoleViewer
default:
return RoleViewer
}
}

View File

@ -1,14 +1,15 @@
package config package config
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
) )
// Config 承载直播 SFU 分流 Demo 的运行参数。 // Config 承载直播 SFU 分流服务的运行参数。
// 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。 // 优先级:环境变量 > 默认值。Cloudflare Realtime 为主 SFU,SRS 为本地对照后端。
// 默认 DB 使用 Turso 嵌入式 (libSQL file) 持久化房间拓扑;同时支持 JWT/Casbin 登录。 // 默认 DB 使用 libSQL 嵌入式 (file) 持久化房间拓扑;同时支持 JWT/Casbin 登录。
type Config struct { type Config struct {
HTTPPort string HTTPPort string
GRPCPort string GRPCPort string
@ -25,18 +26,46 @@ type Config struct {
CFBaseURL string CFBaseURL string
CFStunURL string CFStunURL string
TokenSecret string TokenSecret string // 推流 JWT 签名密钥(SRS 入口鉴权)
TokenRequired bool TokenRequired bool
JWTSecret string JWTSecret string
JWTTTL time.Duration JWTTTL time.Duration
AuthModel string AuthModel string
AuthPolicy string AuthPolicy string
AuthUserFile string AuthUserFile string
AllowRegister bool AllowRegister bool
DatabaseURL string // TURSO_DATABASE_URL 或 DATABASE_URL,默认 file:./data/live-sfu.db SMTPHost string
AuthToken string // 兼容远程 Turso,嵌入模式可为空 SMTPPort int
SMTPUser string
SMTPPass string
SMTPFrom string
SMTPFromName string
SMTPTLS bool
SMTPEnabled bool
EmailVerifyRequired bool
EmailVerifyTTL time.Duration
EmailVerifyBaseURL string
OAuthEnabled bool
OAuthCallbackBase string
OAuthGoogleID string
OAuthGoogleSecret string
OAuthGithubID string
OAuthGithubSecret string
OAuthGitlabID string
OAuthGitlabSecret string
OAuthGenericID string
OAuthGenericSecret string
OAuthGenericURL string
OAuthGenericName string
AuthBossEnabled bool
DatabaseURL string // 嵌入式 DB DSN(TURSO_DATABASE_URL / DATABASE_URL),默认 file:./data/sync-live.db
AuthToken string // 远程 libSQL 凭证,嵌入模式可为空
} }
func Load() *Config { func Load() *Config {
@ -56,7 +85,7 @@ func Load() *Config {
CFBaseURL: getenv("CF_BASE_URL", "https://rtc.live.cloudflare.com/v1"), CFBaseURL: getenv("CF_BASE_URL", "https://rtc.live.cloudflare.com/v1"),
CFStunURL: getenv("CF_STUN_URL", "stun:stun.cloudflare.com:3478"), CFStunURL: getenv("CF_STUN_URL", "stun:stun.cloudflare.com:3478"),
TokenSecret: getenv("DEMO_TOKEN_SECRET", ""), TokenSecret: getenv("SFU_TOKEN_SECRET", ""),
TokenRequired: getenv("SFU_TOKEN_REQUIRED", "0") == "1", TokenRequired: getenv("SFU_TOKEN_REQUIRED", "0") == "1",
JWTSecret: getenv("JWT_SECRET", ""), JWTSecret: getenv("JWT_SECRET", ""),
@ -66,19 +95,42 @@ func Load() *Config {
AuthUserFile: getenv("AUTH_USER_FILE", "data/users.json"), AuthUserFile: getenv("AUTH_USER_FILE", "data/users.json"),
AllowRegister: getenv("ALLOW_REGISTER", "1") == "1", AllowRegister: getenv("ALLOW_REGISTER", "1") == "1",
DatabaseURL: getenv("TURSO_DATABASE_URL", "file:./data/live-sfu.db?cache=shared&_journal_mode=WAL"), SMTPHost: getenv("SMTP_HOST", ""),
SMTPPort: parseInt(getenv("SMTP_PORT", "587"), 587),
SMTPUser: getenv("SMTP_USER", ""),
SMTPPass: getenv("SMTP_PASS", ""),
SMTPFrom: getenv("SMTP_FROM", ""),
SMTPFromName: getenv("SMTP_FROM_NAME", "SyncLive"),
SMTPTLS: getenv("SMTP_TLS", "1") == "1",
SMTPEnabled: getenv("SMTP_ENABLED", "0") == "1",
EmailVerifyRequired: getenv("EMAIL_VERIFY_REQUIRED", "0") == "1",
EmailVerifyTTL: parseDuration(getenv("EMAIL_VERIFY_TTL", "24h"), 24*time.Hour),
EmailVerifyBaseURL: getenv("EMAIL_VERIFY_BASE_URL", ""),
OAuthEnabled: getenv("OAUTH_ENABLED", "0") == "1",
OAuthCallbackBase: getenv("OAUTH_CALLBACK_BASE", ""),
OAuthGoogleID: getenv("OAUTH_GOOGLE_CLIENT_ID", ""),
OAuthGoogleSecret: getenv("OAUTH_GOOGLE_CLIENT_SECRET", ""),
OAuthGithubID: getenv("OAUTH_GITHUB_CLIENT_ID", ""),
OAuthGithubSecret: getenv("OAUTH_GITHUB_CLIENT_SECRET", ""),
OAuthGitlabID: getenv("OAUTH_GITLAB_CLIENT_ID", ""),
OAuthGitlabSecret: getenv("OAUTH_GITLAB_CLIENT_SECRET", ""),
OAuthGenericID: getenv("OAUTH_GENERIC_CLIENT_ID", ""),
OAuthGenericSecret: getenv("OAUTH_GENERIC_CLIENT_SECRET", ""),
OAuthGenericURL: getenv("OAUTH_GENERIC_URL", ""),
OAuthGenericName: getenv("OAUTH_GENERIC_NAME", "generic"),
AuthBossEnabled: getenv("AUTHBOSS_ENABLED", "0") == "1",
DatabaseURL: getenv("TURSO_DATABASE_URL", "file:./data/sync-live.db?cache=shared&_journal_mode=WAL"),
AuthToken: getenv("TURSO_AUTH_TOKEN", ""), AuthToken: getenv("TURSO_AUTH_TOKEN", ""),
} }
if c.TokenSecret == "" { // JWT 密钥优先使用专用配置;否则回退到推流密钥(两者都应显式配置)。
c.TokenSecret = "insecure-demo-secret-change-me"
}
if c.JWTSecret == "" { if c.JWTSecret == "" {
c.JWTSecret = c.TokenSecret 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 c.DatabaseURL == "file:./data/sync-live.db?cache=shared&_journal_mode=WAL" {
if v := os.Getenv("DATABASE_URL"); v != "" { if v := os.Getenv("DATABASE_URL"); v != "" {
c.DatabaseURL = v c.DatabaseURL = v
} else if v := os.Getenv("LIBSQL_URL"); v != "" { } else if v := os.Getenv("LIBSQL_URL"); v != "" {
@ -92,9 +144,30 @@ func Load() *Config {
c.AuthToken = v c.AuthToken = v
} }
} }
if c.SMTPFrom == "" && c.SMTPUser != "" {
c.SMTPFrom = c.SMTPUser
}
if c.EmailVerifyBaseURL == "" {
c.EmailVerifyBaseURL = "http://localhost:" + c.HTTPPort
}
if c.OAuthCallbackBase == "" {
c.OAuthCallbackBase = "http://localhost:" + c.HTTPPort
}
return c return c
} }
// Validate 校验正式运行所需的敏感配置,缺省会返回错误,避免误以不安全默认密钥上线。
func (c *Config) Validate() error {
if c.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET 必须显式配置,禁止依赖不安全默认密钥")
}
if c.TokenRequired && c.TokenSecret == "" {
return fmt.Errorf("已启用 SFU_TOKEN_REQUIRED,但 SFU_TOKEN_SECRET 未配置")
}
return nil
}
func getenv(k, def string) string { func getenv(k, def string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
return v return v
@ -112,6 +185,17 @@ func parseDuration(s string, def time.Duration) time.Duration {
return def return def
} }
func parseInt(s string, def int) int {
if s == "" {
return def
}
var v int
if _, err := fmt.Sscanf(s, "%d", &v); err == nil {
return v
}
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))
@ -151,3 +235,11 @@ func (c *Config) DSN() string {
func (c *Config) IsRemoteTurso() bool { func (c *Config) IsRemoteTurso() bool {
return strings.HasPrefix(c.DatabaseURL, "libsql://") || strings.HasPrefix(c.DatabaseURL, "https://") return strings.HasPrefix(c.DatabaseURL, "libsql://") || strings.HasPrefix(c.DatabaseURL, "https://")
} }
func (c *Config) SMTPConfigured() bool {
return c.SMTPHost != "" && c.SMTPUser != "" && c.SMTPPass != ""
}
func (c *Config) OAuthConfigured() bool {
return c.OAuthGoogleID != "" || c.OAuthGithubID != "" || c.OAuthGitlabID != ""
}

View File

@ -11,18 +11,18 @@ import (
"time" "time"
_ "github.com/tursodatabase/go-libsql" _ "github.com/tursodatabase/go-libsql"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
) )
// Open 打开 Turso 嵌入式数据库(libSQL file)。 // Open 打开嵌入式 libSQL 数据库(file 模式)。
// 仅支持嵌入模式:file:./data/live-sfu.db / :memory: / file::memory: // 仅支持嵌入模式:file:./data/sync-live.db / :memory: / file::memory:
// 已禁用远程 libsql://,如需远程请另行扩展。 // 已禁用远程 libsql://,如需远程请另行扩展。
func Open(dsn string) (*sql.DB, error) { func Open(dsn string) (*sql.DB, error) {
if strings.HasPrefix(dsn, "libsql://") || strings.HasPrefix(dsn, "https://") { 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) return nil, fmt.Errorf("embedded mode only: remote libSQL DSN not supported (%q), use file: DSN", dsn)
} }
if dsn == "" { if dsn == "" {
dsn = "file:./data/live-sfu.db?cache=shared&_journal_mode=WAL" dsn = "file:./data/sync-live.db?cache=shared&_journal_mode=WAL"
} }
// 确保本地文件目录存在 // 确保本地文件目录存在
if strings.HasPrefix(dsn, "file:") { if strings.HasPrefix(dsn, "file:") {
@ -55,7 +55,7 @@ func Open(dsn string) (*sql.DB, error) {
if err := Migrate(db); err != nil { if err := Migrate(db); err != nil {
return nil, fmt.Errorf("migrate: %w", err) return nil, fmt.Errorf("migrate: %w", err)
} }
log.Printf("[db] turso embedded opened: %s", dsn) log.Printf("[db] embedded libsql opened: %s", dsn)
return db, nil return db, nil
} }
@ -78,12 +78,102 @@ func Migrate(db *sql.DB) error {
created_at INTEGER, created_at INTEGER,
updated_at INTEGER updated_at INTEGER
)`, )`,
`CREATE TABLE IF NOT EXISTS rooms_v2 (
name TEXT PRIMARY KEY,
display_name TEXT,
description TEXT,
creator TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
is_public INTEGER NOT NULL DEFAULT 1,
password_hash TEXT DEFAULT '',
require_approval INTEGER NOT NULL DEFAULT 0,
max_members INTEGER NOT NULL DEFAULT 100,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER,
settings_json TEXT,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS room_members (
room TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
role INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
added_permissions INTEGER NOT NULL DEFAULT 0,
removed_permissions INTEGER NOT NULL DEFAULT 0,
admin_added_permissions INTEGER NOT NULL DEFAULT 0,
admin_removed_permissions INTEGER NOT NULL DEFAULT 0,
joined_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (room, user_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_room_members_room_role ON room_members(room, role)`,
`CREATE TABLE IF NOT EXISTS room_bans (
room TEXT PRIMARY KEY,
reason TEXT,
banned_at INTEGER,
banned_by TEXT
)`,
`CREATE TABLE IF NOT EXISTS room_join_requests (
room TEXT NOT NULL,
user_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
PRIMARY KEY (room, user_id)
)`,
`CREATE TABLE IF NOT EXISTS room_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS room_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
color TEXT DEFAULT '',
category_id INTEGER REFERENCES room_categories(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS user_bans (
username TEXT PRIMARY KEY,
reason TEXT,
banned_at INTEGER NOT NULL,
banned_by TEXT,
expires_at INTEGER
)`,
`CREATE TABLE IF NOT EXISTS user_registration_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
reviewed_at INTEGER,
reviewed_by TEXT
)`,
} }
for _, s := range stmts { for _, s := range stmts {
if _, err := db.Exec(s); err != nil { if _, err := db.Exec(s); err != nil {
return fmt.Errorf("exec %q: %w", s, err) return fmt.Errorf("exec %q: %w", s, err)
} }
} }
// 兼容旧库:尝试新增列(若已存在则忽略错误,对标 SyncTV 的增量迁移策略)
for _, alter := range []string{
`ALTER TABLE rooms_v2 ADD COLUMN category_id INTEGER`,
`ALTER TABLE rooms_v2 ADD COLUMN cover_file_id TEXT`,
`ALTER TABLE rooms_v2 ADD COLUMN label_ids TEXT`,
} {
_, _ = db.Exec(alter)
}
return nil return nil
} }

95
internal/room/member.go Normal file
View File

@ -0,0 +1,95 @@
package room
import (
"time"
)
type RoomMember struct {
RoomName string `json:"room_name"`
RoomID string `json:"room_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
Role RoomRole `json:"role"`
Status MemberStatus `json:"status"`
AddedPerms uint64 `json:"added_permissions"`
RemovedPerms uint64 `json:"removed_permissions"`
AdminAdded uint64 `json:"admin_added_permissions"`
AdminRemoved uint64 `json:"admin_removed_permissions"`
JoinedAt time.Time `json:"joined_at"`
Version int64 `json:"version"`
}
func NewMember(room, username string, role RoomRole) *RoomMember {
return &RoomMember{
RoomName: room,
Username: username,
UserID: username,
Role: role,
Status: MemberActive,
JoinedAt: time.Now(),
Version: 1,
}
}
func (m *RoomMember) EffectivePermissions(settings *RoomSettings) PermissionSet {
if m.Role == RoleCreator {
return PermAll
}
base := m.basePermissions(settings)
switch m.Role {
case RoleAdmin:
res := base
res |= PermissionSet(m.AdminAdded)
res &^= PermissionSet(m.AdminRemoved)
return res
case RoleMember:
res := base
res |= PermissionSet(m.AddedPerms)
res &^= PermissionSet(m.RemovedPerms)
return res
case RoleGuest:
res := base & PermGuestAssignable
added := PermissionSet(m.AddedPerms) & PermGuestAssignable
removed := PermissionSet(m.RemovedPerms) & PermGuestAssignable
res |= added
res &^= removed
return res
default:
return base
}
}
func (m *RoomMember) basePermissions(settings *RoomSettings) PermissionSet {
if settings == nil {
return DefaultForRole(m.Role)
}
switch m.Role {
case RoleAdmin:
return settings.AdminPermissions()
case RoleMember:
return settings.MemberPermissions()
case RoleGuest:
return settings.GuestPermissions()
case RoleCreator:
return PermAll
default:
return DefaultForRole(m.Role)
}
}
func (m *RoomMember) HasPermission(p RoomPermission, settings *RoomSettings) bool {
return m.EffectivePermissions(settings).Has(p)
}
func (m *RoomMember) CanGrant(target *RoomMember, perm RoomPermission, settings *RoomSettings) bool {
if !m.HasPermission(PermManagePermissions, settings) {
return false
}
if !m.Role.CanManage(target.Role) {
return false
}
if m.Role == RoleAdmin && perm == PermDeleteRoom {
return false
}
return true
}

211
internal/room/permission.go Normal file
View File

@ -0,0 +1,211 @@
package room
import (
"fmt"
"sort"
"strings"
)
type RoomPermission int
const (
PermSendChat RoomPermission = iota
PermViewChatHistory
PermViewMembers
PermSubscribe
PermWatchRoom
PermPublish
PermStopStream
PermBroadcast
PermManageMembers
PermAddMembers
PermRemoveMembers
PermManagePermissions
PermManageRoom
PermDeleteRoom
PermDeleteChatMessages
PermControlStream
_permCount
)
func (p RoomPermission) Bit() uint64 { return 1 << uint(p) }
func (p RoomPermission) String() string {
switch p {
case PermSendChat:
return "send_chat"
case PermViewChatHistory:
return "view_chat_history"
case PermViewMembers:
return "view_members"
case PermSubscribe:
return "subscribe"
case PermWatchRoom:
return "watch_room"
case PermPublish:
return "publish"
case PermStopStream:
return "stop_stream"
case PermBroadcast:
return "broadcast"
case PermManageMembers:
return "manage_members"
case PermAddMembers:
return "add_members"
case PermRemoveMembers:
return "remove_members"
case PermManagePermissions:
return "manage_permissions"
case PermManageRoom:
return "manage_room"
case PermDeleteRoom:
return "delete_room"
case PermDeleteChatMessages:
return "delete_chat_messages"
case PermControlStream:
return "control_stream"
default:
return fmt.Sprintf("perm_%d", int(p))
}
}
func ParsePermission(s string) (RoomPermission, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "send_chat", "sendchat", "chat", "room:chat":
return PermSendChat, nil
case "view_chat_history", "viewchathistory":
return PermViewChatHistory, nil
case "view_members", "viewmembers", "members:view":
return PermViewMembers, nil
case "subscribe", "room:subscribe":
return PermSubscribe, nil
case "watch", "watch_room", "room:watch":
return PermWatchRoom, nil
case "publish", "room:publish":
return PermPublish, nil
case "stop", "stop_stream", "room:stop":
return PermStopStream, nil
case "broadcast", "room:broadcast":
return PermBroadcast, nil
case "manage_members", "managemembers":
return PermManageMembers, nil
case "add_members", "addmembers":
return PermAddMembers, nil
case "remove_members", "removemembers", "kick":
return PermRemoveMembers, nil
case "manage_permissions", "managepermissions":
return PermManagePermissions, nil
case "manage_room", "manageroom", "room:manage":
return PermManageRoom, nil
case "delete_room", "deleteroom", "room:delete":
return PermDeleteRoom, nil
case "delete_chat_messages", "deletechat":
return PermDeleteChatMessages, nil
case "control_stream", "controlstream":
return PermControlStream, nil
default:
return 0, fmt.Errorf("unknown permission %q", s)
}
}
type PermissionSet uint64
func (s PermissionSet) Has(p RoomPermission) bool { return uint64(s)&p.Bit() != 0 }
func (s PermissionSet) HasAll(o PermissionSet) bool { return uint64(s)&uint64(o) == uint64(o) }
func (s PermissionSet) HasAny(o PermissionSet) bool { return uint64(s)&uint64(o) != 0 }
func (s *PermissionSet) Grant(p RoomPermission) { *s |= PermissionSet(p.Bit()) }
func (s *PermissionSet) Revoke(p RoomPermission) { *s &^= PermissionSet(p.Bit()) }
func (s PermissionSet) Bits() uint64 { return uint64(s) }
func (s PermissionSet) List() []RoomPermission {
var out []RoomPermission
for i := 0; i < int(_permCount); i++ {
p := RoomPermission(i)
if s.Has(p) {
out = append(out, p)
}
}
return out
}
func (s PermissionSet) Names() []string {
list := s.List()
names := make([]string, len(list))
for i, p := range list {
names[i] = p.String()
}
sort.Strings(names)
return names
}
func (s PermissionSet) String() string { return strings.Join(s.Names(), ",") }
func AllPermissions() PermissionSet {
var all PermissionSet
for i := 0; i < int(_permCount); i++ {
all.Grant(RoomPermission(i))
}
return all
}
var (
PermAll = AllPermissions()
PermNone = PermissionSet(0)
PermDefaultCreator = PermAll
PermDefaultAdmin = func() PermissionSet {
var s PermissionSet
s.Grant(PermSendChat)
s.Grant(PermViewChatHistory)
s.Grant(PermViewMembers)
s.Grant(PermSubscribe)
s.Grant(PermWatchRoom)
s.Grant(PermPublish)
s.Grant(PermStopStream)
s.Grant(PermBroadcast)
s.Grant(PermManageMembers)
s.Grant(PermAddMembers)
s.Grant(PermRemoveMembers)
s.Grant(PermManagePermissions)
s.Grant(PermManageRoom)
s.Grant(PermDeleteChatMessages)
s.Grant(PermControlStream)
return s
}()
PermDefaultMember = func() PermissionSet {
var s PermissionSet
s.Grant(PermSendChat)
s.Grant(PermViewChatHistory)
s.Grant(PermViewMembers)
s.Grant(PermSubscribe)
s.Grant(PermWatchRoom)
s.Grant(PermPublish)
s.Grant(PermStopStream)
return s
}()
PermDefaultGuest = func() PermissionSet {
var s PermissionSet
s.Grant(PermViewChatHistory)
s.Grant(PermViewMembers)
s.Grant(PermSubscribe)
s.Grant(PermWatchRoom)
return s
}()
PermGuestAssignable = PermDefaultGuest | func() PermissionSet {
var s PermissionSet
s.Grant(PermSendChat)
return s
}()
)
func DefaultForRole(r RoomRole) PermissionSet {
switch r {
case RoleCreator:
return PermDefaultCreator
case RoleAdmin:
return PermDefaultAdmin
case RoleMember:
return PermDefaultMember
case RoleGuest:
return PermDefaultGuest
default:
return PermDefaultGuest
}
}

156
internal/room/role.go Normal file
View File

@ -0,0 +1,156 @@
package room
import (
"encoding/json"
"fmt"
"strings"
)
type RoomRole int16
const (
RoleCreator RoomRole = 1
RoleAdmin RoomRole = 2
RoleMember RoomRole = 3
RoleGuest RoomRole = 4
)
func (r RoomRole) String() string {
switch r {
case RoleCreator:
return "creator"
case RoleAdmin:
return "admin"
case RoleMember:
return "member"
case RoleGuest:
return "guest"
default:
return fmt.Sprintf("role_%d", int(r))
}
}
func ParseRoomRole(s string) (RoomRole, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "creator", "owner":
return RoleCreator, nil
case "admin":
return RoleAdmin, nil
case "member", "publisher", "viewer":
return RoleMember, nil
case "guest":
return RoleGuest, nil
default:
return 0, fmt.Errorf("unknown role %q", s)
}
}
func (r RoomRole) IsPrivileged() bool { return r == RoleCreator || r == RoleAdmin }
func (r RoomRole) Rank() int {
switch r {
case RoleCreator:
return 100
case RoleAdmin:
return 80
case RoleMember:
return 50
case RoleGuest:
return 10
default:
return 0
}
}
func (r RoomRole) MarshalJSON() ([]byte, error) {
return json.Marshal(r.String())
}
func (r *RoomRole) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*r = 0
return nil
}
var s string
if err := json.Unmarshal(data, &s); err == nil {
parsed, err := ParseRoomRole(s)
if err != nil {
return err
}
*r = parsed
return nil
}
var n int16
if err := json.Unmarshal(data, &n); err == nil {
*r = RoomRole(n)
return nil
}
return fmt.Errorf("invalid RoomRole %s", string(data))
}
func (r RoomRole) CanManage(target RoomRole) bool {
if r == RoleCreator {
return true
}
if r == RoleAdmin && target != RoleCreator {
return true
}
return false
}
type MemberStatus int16
const (
MemberActive MemberStatus = 1
MemberBanned MemberStatus = 2
MemberPending MemberStatus = 3
MemberKicked MemberStatus = 4
)
func (s MemberStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *MemberStatus) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*s = 0
return nil
}
var str string
if err := json.Unmarshal(data, &str); err == nil {
switch strings.ToLower(strings.TrimSpace(str)) {
case "active":
*s = MemberActive
case "banned":
*s = MemberBanned
case "pending":
*s = MemberPending
case "kicked":
*s = MemberKicked
default:
return fmt.Errorf("unknown status %q", str)
}
return nil
}
var n int16
if err := json.Unmarshal(data, &n); err == nil {
*s = MemberStatus(n)
return nil
}
return fmt.Errorf("invalid MemberStatus %s", string(data))
}
func (s MemberStatus) String() string {
switch s {
case MemberActive:
return "active"
case MemberBanned:
return "banned"
case MemberPending:
return "pending"
case MemberKicked:
return "kicked"
default:
return fmt.Sprintf("status_%d", int(s))
}
}

281
internal/room/room.go Normal file
View File

@ -0,0 +1,281 @@
package room
import (
"fmt"
"strings"
"time"
)
type RoomStatus string
const (
RoomActive RoomStatus = "active"
RoomClosed RoomStatus = "closed"
RoomBanned RoomStatus = "banned"
)
func (s RoomStatus) IsActive() bool { return s == RoomActive }
func (s RoomStatus) IsClosed() bool { return s == RoomClosed || s == RoomBanned }
func (s RoomStatus) CanTransitionTo(next RoomStatus) bool {
if s == next {
return true
}
switch s {
case RoomActive:
return next == RoomClosed || next == RoomBanned
case RoomClosed:
return next == RoomActive
case RoomBanned:
return next == RoomActive
default:
return false
}
}
func ParseRoomStatus(raw string) (RoomStatus, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "active":
return RoomActive, nil
case "closed":
return RoomClosed, nil
case "banned":
return RoomBanned, nil
default:
return "", fmt.Errorf("unknown room status %q", raw)
}
}
type PlayMode string
const (
PlaySequential PlayMode = "sequential"
PlayRepeatOne PlayMode = "repeat_one"
PlayRepeatAll PlayMode = "repeat_all"
PlayShuffle PlayMode = "shuffle"
)
func ParsePlayMode(s string) (PlayMode, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "sequential", "":
return PlaySequential, nil
case "repeat_one", "repeatone":
return PlayRepeatOne, nil
case "repeat_all", "repeatall":
return PlayRepeatAll, nil
case "shuffle", "random":
return PlayShuffle, nil
default:
return "", fmt.Errorf("unknown play mode %q", s)
}
}
type AutoPlaySettings struct {
Enabled bool `json:"enabled"`
Mode PlayMode `json:"mode"`
Delay uint32 `json:"delay"` // seconds
}
func DefaultAutoPlay() AutoPlaySettings {
return AutoPlaySettings{Enabled: true, Mode: PlaySequential, Delay: 3}
}
type RoomCategory struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
SortOrder int32 `json:"sort_order"`
IsEnabled bool `json:"is_enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RoomLabel struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
CategoryID *int64 `json:"category_id,omitempty"`
SortOrder int32 `json:"sort_order"`
IsEnabled bool `json:"is_enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Room struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description,omitempty"`
Creator string `json:"creator"`
Status RoomStatus `json:"status"`
IsPublic bool `json:"is_public"`
PasswordHash string `json:"-"`
RequireApproval bool `json:"require_approval"`
MaxMembers int `json:"max_members"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
Settings *RoomSettings `json:"settings,omitempty"`
Version int64 `json:"version"`
CategoryID *int64 `json:"category_id,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
CoverFileID *string `json:"cover_file_id,omitempty"`
AutoPlay AutoPlaySettings `json:"auto_play"`
}
type RoomWithCount struct {
Room `json:",inline"`
MemberCount int `json:"member_count"`
}
type SortDirection string
const (
SortAsc SortDirection = "asc"
SortDesc SortDirection = "desc"
)
type PageParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func (p PageParams) Offset() int {
if p.Page <= 1 {
return 0
}
return (p.Page - 1) * p.Limit()
}
func (p PageParams) Limit() int {
if p.PageSize <= 0 {
return 20
}
if p.PageSize > 100 {
return 100
}
return p.PageSize
}
type RoomListSortBy string
const (
SortByCreatedAt RoomListSortBy = "created_at"
SortByUpdatedAt RoomListSortBy = "updated_at"
SortByName RoomListSortBy = "name"
SortByMemberCount RoomListSortBy = "member_count"
)
func (s RoomListSortBy) AsSQL() string {
switch s {
case SortByName:
return "name"
case SortByMemberCount:
return "member_count"
case SortByUpdatedAt:
return "updated_at"
default:
return "created_at"
}
}
type RoomListQuery struct {
Pagination PageParams `json:"pagination"`
Status *RoomStatus `json:"status,omitempty"`
Search *string `json:"search,omitempty"`
IsBanned *bool `json:"is_banned,omitempty"`
IsPublic *bool `json:"is_public,omitempty"`
Creator *string `json:"creator,omitempty"`
CategoryID *int64 `json:"category_id,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
SortBy RoomListSortBy `json:"sort_by"`
SortDirection SortDirection `json:"sort_direction"`
}
func DefaultRoomListQuery() RoomListQuery {
active := RoomActive
return RoomListQuery{
Pagination: PageParams{Page: 1, PageSize: 20},
Status: &active,
SortBy: SortByCreatedAt,
SortDirection: SortDesc,
}
}
func NewRoom(name, creator string) *Room {
now := time.Now()
return &Room{
Name: name,
DisplayName: name,
Creator: creator,
Status: RoomActive,
IsPublic: true,
MaxMembers: 100,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
Settings: DefaultSettings(),
AutoPlay: DefaultAutoPlay(),
}
}
type RoomSettings struct {
IsPublic bool `json:"is_public"`
RequireApproval bool `json:"require_approval"`
AllowGuestJoin bool `json:"allow_guest_join"`
ChatEnabled bool `json:"chat_enabled"`
MaxMembers int `json:"max_members"`
AllowAutoJoin bool `json:"allow_auto_join"`
AdminAddedPerms uint64 `json:"admin_added_permissions"`
AdminRemovedPerms uint64 `json:"admin_removed_permissions"`
MemberAddedPerms uint64 `json:"member_added_permissions"`
MemberRemovedPerms uint64 `json:"member_removed_permissions"`
GuestAddedPerms uint64 `json:"guest_added_permissions"`
GuestRemovedPerms uint64 `json:"guest_removed_permissions"`
AutoPlayEnabled bool `json:"auto_play_enabled"`
PlayMode PlayMode `json:"play_mode"`
}
func DefaultSettings() *RoomSettings {
return &RoomSettings{
IsPublic: true,
AllowGuestJoin: false,
ChatEnabled: true,
MaxMembers: 100,
AllowAutoJoin: true,
RequireApproval: false,
AutoPlayEnabled: true,
PlayMode: PlaySequential,
}
}
func (s *RoomSettings) AdminPermissions() PermissionSet {
base := PermDefaultAdmin
base |= PermissionSet(s.AdminAddedPerms)
base &^= PermissionSet(s.AdminRemovedPerms)
return base
}
func (s *RoomSettings) MemberPermissions() PermissionSet {
base := PermDefaultMember
base |= PermissionSet(s.MemberAddedPerms)
base &^= PermissionSet(s.MemberRemovedPerms)
return base
}
func (s *RoomSettings) GuestPermissions() PermissionSet {
base := PermDefaultGuest
base |= PermissionSet(s.GuestAddedPerms)
base &^= PermissionSet(s.GuestRemovedPerms)
return base & PermGuestAssignable
}
func (s *RoomSettings) IsChatEnabled() bool { return s.ChatEnabled }
func (s *RoomSettings) EffectiveAutoPlay() AutoPlaySettings {
enabled := s.AutoPlayEnabled
mode := s.PlayMode
if mode == "" {
mode = PlaySequential
}
return AutoPlaySettings{Enabled: enabled, Mode: mode, Delay: 3}
}

263
internal/room/service.go Normal file
View File

@ -0,0 +1,263 @@
package room
import (
"context"
"database/sql"
"errors"
)
type Service struct {
store *Store
}
func NewService(store *Store) *Service { return &Service{store: store} }
func (s *Service) Store() *Store { return s.store }
func (s *Service) GetRoom(ctx context.Context, name string) (*Room, error) {
if s.store == nil || s.store.db == nil {
return nil, errors.New("room store not configured")
}
return s.store.GetRoom(ctx, name)
}
func (s *Service) CreateRoom(ctx context.Context, name, creator string, settings *RoomSettings) (*Room, error) {
if name == "" {
return nil, errors.New("room name required")
}
if creator == "" {
return nil, errors.New("creator required")
}
r := NewRoom(name, creator)
if settings != nil {
r.Settings = settings
r.IsPublic = settings.IsPublic
r.RequireApproval = settings.RequireApproval
if settings.MaxMembers > 0 {
r.MaxMembers = settings.MaxMembers
}
}
if err := s.store.CreateRoom(ctx, r); err != nil {
return nil, err
}
creatorMember := NewMember(name, creator, RoleCreator)
if err := s.store.UpsertMember(ctx, creatorMember); err != nil {
return nil, err
}
return r, nil
}
func (s *Service) JoinRoom(ctx context.Context, roomName, username string, password string) (*RoomMember, error) {
r, err := s.store.GetRoom(ctx, roomName)
if err != nil {
return nil, err
}
if r.Status == RoomBanned {
return nil, errors.New("room is banned")
}
if r.Status == RoomClosed {
return nil, errors.New("room is closed")
}
if r.PasswordHash != "" {
if !verifyPassword(password, r.PasswordHash) {
return nil, errors.New("invalid room password")
}
}
if cnt, _ := s.store.CountMembers(ctx, roomName); cnt >= r.MaxMembers && r.MaxMembers > 0 {
return nil, errors.New("room is full")
}
member, err := s.store.GetMember(ctx, roomName, username)
if err == nil && member.Status == MemberActive {
return member, nil
}
if err == nil && member.Status == MemberBanned {
return nil, errors.New("you are banned from this room")
}
newMember := NewMember(roomName, username, RoleMember)
if s.store != nil {
_ = s.store.UpsertMember(ctx, newMember)
}
return newMember, nil
}
func (s *Service) GetMember(ctx context.Context, room, userID string) (*RoomMember, error) {
return s.store.GetMember(ctx, room, userID)
}
func (s *Service) EnsurePermission(ctx context.Context, room, userID string, perm RoomPermission) error {
if s.store == nil || s.store.db == nil {
return nil
}
r, err := s.store.GetRoom(ctx, room)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("room not found")
}
return err
}
if r.Status == RoomBanned {
return errors.New("room is banned")
}
if r.Status == RoomClosed && perm != PermViewMembers && perm != PermViewChatHistory {
return errors.New("room is closed")
}
m, err := s.store.GetMember(ctx, room, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if r.IsPublic || r.Settings.AllowGuestJoin {
guest := NewMember(room, userID, RoleGuest)
if guest.HasPermission(perm, r.Settings) {
return nil
}
}
return errors.New("not a member of this room")
}
return err
}
if m.Status != MemberActive {
return errors.New("member not active")
}
if !m.HasPermission(perm, r.Settings) {
return errors.New("permission denied: " + perm.String())
}
return nil
}
func (s *Service) CheckPermission(ctx context.Context, room, userID string, perm RoomPermission) bool {
return s.EnsurePermission(ctx, room, userID, perm) == nil
}
func (s *Service) ListMembers(ctx context.Context, room string) ([]*RoomMember, error) {
return s.store.ListMembers(ctx, room)
}
func (s *Service) UpdateMemberRole(ctx context.Context, room, actorID, targetID string, newRole RoomRole) error {
if newRole == RoleCreator {
return errors.New("cannot assign creator role")
}
roomObj, err := s.store.GetRoom(ctx, room)
if err != nil {
return err
}
actor, err := s.store.GetMember(ctx, room, actorID)
if err != nil {
return errors.New("actor not in room")
}
if !actor.HasPermission(PermManageMembers, roomObj.Settings) {
return errors.New("permission denied: manage members")
}
if !actor.Role.CanManage(newRole) {
return errors.New("cannot manage role higher than yours")
}
target, err := s.store.GetMember(ctx, room, targetID)
if err != nil {
return errors.New("target not in room")
}
if target.Role == RoleCreator {
return errors.New("cannot change creator role")
}
if !actor.Role.CanManage(target.Role) {
return errors.New("cannot manage member with higher or equal role")
}
target.Role = newRole
return s.store.UpsertMember(ctx, target)
}
func (s *Service) UpdateMemberPermissions(ctx context.Context, room, actorID, targetID string, added, removed uint64, isAdmin bool) error {
roomObj, err := s.store.GetRoom(ctx, room)
if err != nil {
return err
}
actor, err := s.store.GetMember(ctx, room, actorID)
if err != nil {
return errors.New("actor not in room")
}
if !actor.HasPermission(PermManagePermissions, roomObj.Settings) {
return errors.New("permission denied: manage permissions")
}
target, err := s.store.GetMember(ctx, room, targetID)
if err != nil {
return errors.New("target not in room")
}
if !actor.Role.CanManage(target.Role) {
return errors.New("cannot manage this member")
}
if isAdmin {
if target.Role != RoleAdmin && target.Role != RoleCreator {
return errors.New("admin permissions only for admin/creator")
}
target.AdminAdded = added
target.AdminRemoved = removed
} else {
target.AddedPerms = added
target.RemovedPerms = removed
}
return s.store.UpsertMember(ctx, target)
}
func (s *Service) KickMember(ctx context.Context, room, actorID, targetID string) error {
roomObj, err := s.store.GetRoom(ctx, room)
if err != nil {
return err
}
actor, err := s.store.GetMember(ctx, room, actorID)
if err != nil {
return errors.New("actor not in room")
}
if !actor.HasPermission(PermRemoveMembers, roomObj.Settings) {
return errors.New("permission denied: remove members")
}
target, err := s.store.GetMember(ctx, room, targetID)
if err != nil {
return errors.New("target not in room")
}
if target.Role == RoleCreator {
return errors.New("cannot kick creator")
}
if !actor.Role.CanManage(target.Role) {
return errors.New("cannot kick higher role")
}
return s.store.RemoveMember(ctx, room, targetID)
}
func (s *Service) BanMember(ctx context.Context, room, actorID, targetID string) error {
if err := s.KickMember(ctx, room, actorID, targetID); err != nil {
return err
}
banned := NewMember(room, targetID, RoleGuest)
banned.Status = MemberBanned
return s.store.UpsertMember(ctx, banned)
}
func (s *Service) LeaveRoom(ctx context.Context, room, userID string) error {
m, err := s.store.GetMember(ctx, room, userID)
if err != nil {
return err
}
if m.Role == RoleCreator {
return errors.New("creator cannot leave, transfer ownership or delete room")
}
return s.store.RemoveMember(ctx, room, userID)
}
func (s *Service) UpdateRoomSettings(ctx context.Context, room, actorID string, newSettings *RoomSettings) error {
r, err := s.store.GetRoom(ctx, room)
if err != nil {
return err
}
actor, err := s.store.GetMember(ctx, room, actorID)
if err != nil {
return errors.New("actor not in room")
}
if !actor.HasPermission(PermManageRoom, r.Settings) {
return errors.New("permission denied: manage room")
}
return s.store.UpdateRoomSettings(ctx, room, newSettings)
}
func verifyPassword(input, hash string) bool {
if hash == "" {
return true
}
return input == hash
}

415
internal/room/store.go Normal file
View File

@ -0,0 +1,415 @@
package room
import (
"context"
"strings"
"database/sql"
"encoding/json"
"fmt"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) DB() *sql.DB { return s.db }
func InitSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS rooms_v2 (
name TEXT PRIMARY KEY,
display_name TEXT,
description TEXT,
creator TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
is_public INTEGER NOT NULL DEFAULT 1,
password_hash TEXT DEFAULT '',
require_approval INTEGER NOT NULL DEFAULT 0,
max_members INTEGER NOT NULL DEFAULT 100,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER,
settings_json TEXT,
version INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS room_members (
room TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
role INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
added_permissions INTEGER NOT NULL DEFAULT 0,
removed_permissions INTEGER NOT NULL DEFAULT 0,
admin_added_permissions INTEGER NOT NULL DEFAULT 0,
admin_removed_permissions INTEGER NOT NULL DEFAULT 0,
joined_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (room, user_id),
FOREIGN KEY (room) REFERENCES rooms_v2(name) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_room_members_room_role ON room_members(room, role)`,
`CREATE TABLE IF NOT EXISTS room_bans (
room TEXT PRIMARY KEY,
reason TEXT,
banned_at INTEGER,
banned_by TEXT
)`,
`CREATE TABLE IF NOT EXISTS room_join_requests (
room TEXT NOT NULL,
user_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
PRIMARY KEY (room, user_id)
)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("init schema %q: %w", stmt, err)
}
}
return nil
}
func (s *Store) CreateRoom(ctx context.Context, r *Room) error {
if s.db == nil {
return fmt.Errorf("no db")
}
settingsJSON, _ := json.Marshal(r.Settings)
_, err := s.db.ExecContext(ctx, `INSERT INTO rooms_v2 (name, display_name, description, creator, status, is_public, password_hash, require_approval, max_members, created_at, updated_at, settings_json, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Name, r.DisplayName, r.Description, r.Creator, string(r.Status), boolToInt(r.IsPublic), r.PasswordHash, boolToInt(r.RequireApproval), r.MaxMembers, r.CreatedAt.Unix(), r.UpdatedAt.Unix(), string(settingsJSON), r.Version)
return err
}
func (s *Store) GetRoom(ctx context.Context, name string) (*Room, error) {
if s.db == nil {
return nil, sql.ErrNoRows
}
row := s.db.QueryRowContext(ctx, `SELECT name, display_name, description, creator, status, is_public, password_hash, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE name = ?`, name)
var r Room
var status string
var isPublic, requireApproval int
var createdAt, updatedAt int64
var closedAt sql.NullInt64
var settingsJSON sql.NullString
var displayName, description sql.NullString
var passwordHash sql.NullString
if err := row.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &passwordHash, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
return nil, err
}
r.DisplayName = displayName.String
r.Description = description.String
r.PasswordHash = passwordHash.String
r.Status = RoomStatus(status)
r.IsPublic = isPublic != 0
r.RequireApproval = requireApproval != 0
r.CreatedAt = time.Unix(createdAt, 0)
r.UpdatedAt = time.Unix(updatedAt, 0)
if closedAt.Valid {
t := time.Unix(closedAt.Int64, 0)
r.ClosedAt = &t
}
r.Settings = DefaultSettings()
if settingsJSON.Valid && settingsJSON.String != "" {
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
} else {
r.Settings.IsPublic = r.IsPublic
r.Settings.RequireApproval = r.RequireApproval
r.Settings.MaxMembers = r.MaxMembers
}
return &r, nil
}
func (s *Store) ListRooms(ctx context.Context) ([]*Room, error) {
if s.db == nil {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT name, display_name, description, creator, status, is_public, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE status != 'banned' ORDER BY updated_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Room
for rows.Next() {
var r Room
var status string
var isPublic, requireApproval int
var createdAt, updatedAt int64
var closedAt sql.NullInt64
var settingsJSON sql.NullString
var displayName, description sql.NullString
if err := rows.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
return nil, err
}
r.DisplayName = displayName.String
r.Description = description.String
r.Status = RoomStatus(status)
r.IsPublic = isPublic != 0
r.RequireApproval = requireApproval != 0
r.CreatedAt = time.Unix(createdAt, 0)
r.UpdatedAt = time.Unix(updatedAt, 0)
if closedAt.Valid {
t := time.Unix(closedAt.Int64, 0)
r.ClosedAt = &t
}
r.Settings = DefaultSettings()
if settingsJSON.Valid && settingsJSON.String != "" {
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
}
out = append(out, &r)
}
return out, rows.Err()
}
func (s *Store) UpdateRoomSettings(ctx context.Context, name string, settings *RoomSettings) error {
if s.db == nil {
return fmt.Errorf("no db")
}
b, _ := json.Marshal(settings)
_, err := s.db.ExecContext(ctx, `UPDATE rooms_v2 SET settings_json = ?, is_public = ?, require_approval = ?, max_members = ?, updated_at = ?, version = version + 1 WHERE name = ?`, string(b), boolToInt(settings.IsPublic), boolToInt(settings.RequireApproval), settings.MaxMembers, time.Now().Unix(), name)
return err
}
func (s *Store) UpdateRoomStatus(ctx context.Context, name string, status RoomStatus) error {
if s.db == nil {
return fmt.Errorf("no db")
}
var closedAt *int64
if status == RoomClosed {
v := time.Now().Unix()
closedAt = &v
}
_, err := s.db.ExecContext(ctx, `UPDATE rooms_v2 SET status = ?, closed_at = ?, updated_at = ?, version = version + 1 WHERE name = ?`, string(status), closedAt, time.Now().Unix(), name)
return err
}
func (s *Store) DeleteRoom(ctx context.Context, name string) error {
if s.db == nil {
return fmt.Errorf("no db")
}
_, err := s.db.ExecContext(ctx, `DELETE FROM rooms_v2 WHERE name = ?`, name)
if err != nil {
return err
}
_, _ = s.db.ExecContext(ctx, `DELETE FROM room_members WHERE room = ?`, name)
return nil
}
func (s *Store) UpsertMember(ctx context.Context, m *RoomMember) error {
if s.db == nil {
return fmt.Errorf("no db")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO room_members (room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(room, user_id) DO UPDATE SET role=excluded.role, status=excluded.status, added_permissions=excluded.added_permissions, removed_permissions=excluded.removed_permissions, admin_added_permissions=excluded.admin_added_permissions, admin_removed_permissions=excluded.admin_removed_permissions, version=version+1`,
m.RoomName, m.UserID, m.Username, int(m.Role), int(m.Status), int64(m.AddedPerms), int64(m.RemovedPerms), int64(m.AdminAdded), int64(m.AdminRemoved), m.JoinedAt.Unix(), m.Version)
return err
}
func (s *Store) GetMember(ctx context.Context, room, userID string) (*RoomMember, error) {
if s.db == nil {
return nil, sql.ErrNoRows
}
row := s.db.QueryRowContext(ctx, `SELECT room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version FROM room_members WHERE room = ? AND user_id = ?`, room, userID)
var m RoomMember
var role, status int
var joinedAt int64
var added, removed, adminAdded, adminRemoved int64
if err := row.Scan(&m.RoomName, &m.UserID, &m.Username, &role, &status, &added, &removed, &adminAdded, &adminRemoved, &joinedAt, &m.Version); err != nil {
return nil, err
}
m.Role = RoomRole(role)
m.Status = MemberStatus(status)
m.AddedPerms = uint64(added)
m.RemovedPerms = uint64(removed)
m.AdminAdded = uint64(adminAdded)
m.AdminRemoved = uint64(adminRemoved)
m.JoinedAt = time.Unix(joinedAt, 0)
m.RoomID = m.RoomName
return &m, nil
}
func (s *Store) ListMembers(ctx context.Context, room string) ([]*RoomMember, error) {
if s.db == nil {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT room, user_id, username, role, status, added_permissions, removed_permissions, admin_added_permissions, admin_removed_permissions, joined_at, version FROM room_members WHERE room = ? ORDER BY role ASC, joined_at ASC`, room)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*RoomMember
for rows.Next() {
var m RoomMember
var role, status int
var joinedAt int64
var added, removed, adminAdded, adminRemoved int64
if err := rows.Scan(&m.RoomName, &m.UserID, &m.Username, &role, &status, &added, &removed, &adminAdded, &adminRemoved, &joinedAt, &m.Version); err != nil {
return nil, err
}
m.Role = RoomRole(role)
m.Status = MemberStatus(status)
m.AddedPerms = uint64(added)
m.RemovedPerms = uint64(removed)
m.AdminAdded = uint64(adminAdded)
m.AdminRemoved = uint64(adminRemoved)
m.JoinedAt = time.Unix(joinedAt, 0)
m.RoomID = m.RoomName
out = append(out, &m)
}
return out, rows.Err()
}
func (s *Store) RemoveMember(ctx context.Context, room, userID string) error {
if s.db == nil {
return fmt.Errorf("no db")
}
_, err := s.db.ExecContext(ctx, `DELETE FROM room_members WHERE room = ? AND user_id = ?`, room, userID)
return err
}
func (s *Store) CountMembers(ctx context.Context, room string) (int, error) {
if s.db == nil {
return 0, nil
}
var cnt int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room = ? AND status = 1`, room).Scan(&cnt)
return cnt, err
}
func (s *Store) CountRooms(ctx context.Context, q RoomListQuery) (int, error) {
if s.db == nil {
return 0, nil
}
query := `SELECT COUNT(*) FROM rooms_v2 WHERE 1=1`
args := []interface{}{}
if q.Status != nil {
query += ` AND status = ?`
args = append(args, string(*q.Status))
} else if q.IsBanned != nil && !*q.IsBanned {
query += ` AND status != 'banned'`
}
if q.IsPublic != nil {
query += ` AND is_public = ?`
args = append(args, boolToInt(*q.IsPublic))
}
if q.Creator != nil && *q.Creator != "" {
query += ` AND creator = ?`
args = append(args, *q.Creator)
}
if q.Search != nil && *q.Search != "" {
query += ` AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)`
like := "%" + *q.Search + "%"
args = append(args, like, like, like)
}
var cnt int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&cnt)
return cnt, err
}
func (s *Store) QueryRooms(ctx context.Context, q RoomListQuery) ([]*Room, int, error) {
if s.db == nil {
return nil, 0, nil
}
total, err := s.CountRooms(ctx, q)
if err != nil {
return nil, 0, err
}
query := `SELECT name, display_name, description, creator, status, is_public, require_approval, max_members, created_at, updated_at, closed_at, settings_json, version FROM rooms_v2 WHERE 1=1`
args := []interface{}{}
if q.Status != nil {
query += ` AND status = ?`
args = append(args, string(*q.Status))
} else if q.IsBanned != nil && !*q.IsBanned {
query += ` AND status != 'banned'`
}
if q.IsPublic != nil {
query += ` AND is_public = ?`
args = append(args, boolToInt(*q.IsPublic))
}
if q.Creator != nil && *q.Creator != "" {
query += ` AND creator = ?`
args = append(args, *q.Creator)
}
if q.Search != nil && *q.Search != "" {
query += ` AND (name LIKE ? OR display_name LIKE ? OR description LIKE ?)`
like := "%" + *q.Search + "%"
args = append(args, like, like, like)
}
// Sorting
sortCol := q.SortBy.AsSQL()
dir := strings.ToUpper(string(q.SortDirection))
if dir != "ASC" && dir != "DESC" {
dir = "DESC"
}
// whitelist sortCol
allowed := map[string]bool{"created_at": true, "updated_at": true, "name": true, "member_count": true}
if !allowed[sortCol] {
sortCol = "created_at"
}
query += fmt.Sprintf(` ORDER BY %s %s`, sortCol, dir)
// Pagination
limit := q.Pagination.Limit()
offset := q.Pagination.Offset()
query += ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*Room
for rows.Next() {
var r Room
var status string
var isPublic, requireApproval int
var createdAt, updatedAt int64
var closedAt sql.NullInt64
var settingsJSON sql.NullString
var displayName, description sql.NullString
if err := rows.Scan(&r.Name, &displayName, &description, &r.Creator, &status, &isPublic, &requireApproval, &r.MaxMembers, &createdAt, &updatedAt, &closedAt, &settingsJSON, &r.Version); err != nil {
return nil, 0, err
}
r.DisplayName = displayName.String
r.Description = description.String
r.Status = RoomStatus(status)
r.IsPublic = isPublic != 0
r.RequireApproval = requireApproval != 0
r.CreatedAt = time.Unix(createdAt, 0)
r.UpdatedAt = time.Unix(updatedAt, 0)
if closedAt.Valid {
t := time.Unix(closedAt.Int64, 0)
r.ClosedAt = &t
}
r.Settings = DefaultSettings()
if settingsJSON.Valid && settingsJSON.String != "" {
_ = json.Unmarshal([]byte(settingsJSON.String), r.Settings)
}
out = append(out, &r)
}
return out, total, rows.Err()
}
func (s *Store) ListRoomsFiltered(ctx context.Context, q RoomListQuery) ([]*RoomWithCount, int, error) {
rooms, total, err := s.QueryRooms(ctx, q)
if err != nil {
return nil, 0, err
}
var out []*RoomWithCount
for _, r := range rooms {
cnt, _ := s.CountMembers(ctx, r.Name)
out = append(out, &RoomWithCount{Room: *r, MemberCount: cnt})
}
return out, total, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"gospeak-live-sfu-demo/internal/auth" "sync-live/internal/auth"
) )
type authRequest struct { type authRequest struct {
@ -15,10 +15,13 @@ type authRequest struct {
} }
type authResponse struct { type authResponse struct {
Token string `json:"token"` Token string `json:"token"`
Username string `json:"username"` RefreshToken string `json:"refresh_token,omitempty"`
Role string `json:"role"` Username string `json:"username"`
ExpiresAt int64 `json:"expires_at,omitempty"` Role string `json:"role"`
Status string `json:"status,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
RefreshExpiresAt int64 `json:"refresh_expires_at,omitempty"`
} }
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
@ -35,25 +38,101 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "username and password required", http.StatusBadRequest) http.Error(w, "username and password required", http.StatusBadRequest)
return return
} }
token, user, err := s.auth.Login(req.Username, req.Password) access, refresh, user, err := s.auth.LoginWithRefresh(req.Username, req.Password)
if err != nil { if err != nil {
http.Error(w, "login failed: "+err.Error(), http.StatusUnauthorized) // fall back to single token for backwards compat
token, u, err2 := s.auth.Login(req.Username, req.Password)
if err2 != 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: u.Username,
Role: u.Role,
Status: u.Status,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
})
return return
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "token", Name: "token",
Value: token, Value: access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(s.auth.JWT.TTL()),
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refresh,
Path: "/api/auth/refresh",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(s.auth.JWT.RefreshTTL()),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResponse{
Token: access,
RefreshToken: refresh,
Username: user.Username,
Role: user.Role,
Status: user.Status,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
RefreshExpiresAt: time.Now().Add(s.auth.JWT.RefreshTTL()).Unix(),
})
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
http.Error(w, "auth not configured", http.StatusInternalServerError)
return
}
var req struct {
RefreshToken string `json:"refresh_token"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
token := req.RefreshToken
if token == "" {
if c, err := r.Cookie("refresh_token"); err == nil {
token = c.Value
}
}
if token == "" {
token = r.Header.Get("X-Refresh-Token")
}
if token == "" {
http.Error(w, "refresh_token required", http.StatusBadRequest)
return
}
newAccess, claims, err := s.auth.RefreshAccess(token)
if err != nil {
http.Error(w, "refresh failed: "+err.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: newAccess,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(s.auth.JWT.TTL()), Expires: time.Now().Add(s.auth.JWT.TTL()),
}) })
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResponse{ json.NewEncoder(w).Encode(map[string]interface{}{
Token: token, "token": newAccess,
Username: user.Username, "username": claims.Username,
Role: user.Role, "role": claims.Role,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(), "expires_at": claims.ExpiresAt.Unix(),
}) })
} }
@ -79,10 +158,15 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if role == "" { if role == "" {
role = auth.RoleViewer role = auth.RoleViewer
} }
role = auth.NormalizeRole(role)
if role == auth.RoleRoot {
http.Error(w, "cannot register as root", http.StatusForbidden)
return
}
if role == auth.RoleAdmin || role == auth.RolePublisher { if role == auth.RoleAdmin || role == auth.RolePublisher {
token := extractAuthToken(r) token := extractAuthToken(r)
if token != "" { if token != "" {
if claims, _, err := s.auth.VerifyToken(token); err == nil && claims.Role == auth.RoleAdmin { if claims, _, err := s.auth.VerifyToken(token); err == nil && auth.IsAdminOrAbove(claims.Role) {
} else { } else {
role = auth.RoleViewer role = auth.RoleViewer
} }
@ -108,6 +192,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
Token: token, Token: token,
Username: user.Username, Username: user.Username,
Role: user.Role, Role: user.Role,
Status: user.Status,
ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(), ExpiresAt: time.Now().Add(s.auth.JWT.TTL()).Unix(),
}) })
} }
@ -120,6 +205,13 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
HttpOnly: true, HttpOnly: true,
MaxAge: -1, MaxAge: -1,
}) })
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/api/auth/refresh",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "logged out"}) json.NewEncoder(w).Encode(map[string]string{"status": "logged out"})
} }
@ -143,6 +235,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"username": user.Username, "username": user.Username,
"role": user.Role, "role": user.Role,
"status": user.Status,
"expires_at": claims.ExpiresAt.Unix(), "expires_at": claims.ExpiresAt.Unix(),
"issued_at": claims.IssuedAt.Unix(), "issued_at": claims.IssuedAt.Unix(),
}) })
@ -155,7 +248,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
} }
token := extractAuthToken(r) token := extractAuthToken(r)
claims, _, err := s.auth.VerifyToken(token) claims, _, err := s.auth.VerifyToken(token)
if err != nil || claims.Role != auth.RoleAdmin { if err != nil || !auth.IsAdminOrAbove(claims.Role) {
http.Error(w, "forbidden: admin only", http.StatusForbidden) http.Error(w, "forbidden: admin only", http.StatusForbidden)
return return
} }
@ -171,7 +264,7 @@ func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request) {
} }
token := extractAuthToken(r) token := extractAuthToken(r)
claims, _, err := s.auth.VerifyToken(token) claims, _, err := s.auth.VerifyToken(token)
if err != nil || claims.Role != auth.RoleAdmin { if err != nil || !auth.IsAdminOrAbove(claims.Role) {
http.Error(w, "forbidden: admin only", http.StatusForbidden) http.Error(w, "forbidden: admin only", http.StatusForbidden)
return return
} }
@ -187,7 +280,7 @@ func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request) {
http.Error(w, "username and role required", http.StatusBadRequest) http.Error(w, "username and role required", http.StatusBadRequest)
return return
} }
if err := s.auth.UpdateUserRole(req.Username, req.Role); err != nil { if err := s.auth.UpdateUserRoleAs(claims.Username, req.Username, req.Role); err != nil {
http.Error(w, "update failed: "+err.Error(), http.StatusBadRequest) http.Error(w, "update failed: "+err.Error(), http.StatusBadRequest)
return return
} }
@ -195,6 +288,58 @@ func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "username": req.Username, "role": req.Role}) json.NewEncoder(w).Encode(map[string]string{"status": "ok", "username": req.Username, "role": req.Role})
} }
func (s *Server) handleBanUser(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 || !auth.IsAdminOrAbove(claims.Role) {
http.Error(w, "forbidden: admin only", http.StatusForbidden)
return
}
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
if err := s.auth.BanUser(claims.Username, req.Username); err != nil {
http.Error(w, "ban failed: "+err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "banned", "username": req.Username})
}
func (s *Server) handleUnbanUser(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 || !auth.IsAdminOrAbove(claims.Role) {
http.Error(w, "forbidden: admin only", http.StatusForbidden)
return
}
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
if err := s.auth.UnbanUser(claims.Username, req.Username); err != nil {
http.Error(w, "unban failed: "+err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "unbanned", "username": req.Username})
}
func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
if s.auth == nil { if s.auth == nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -223,8 +368,11 @@ func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
{"room:watch", "room", "watch"}, {"room:watch", "room", "watch"},
{"room:stop", "room", "stop"}, {"room:stop", "room", "stop"},
{"room:chat", "room", "chat"}, {"room:chat", "room", "chat"},
{"room:manage", "room", "manage"},
{"room:delete", "room", "delete"},
{"user:list", "user", "list"}, {"user:list", "user", "list"},
{"user:manage", "user", "manage"}, {"user:manage", "user", "manage"},
{"system:manage", "system", "manage"},
{"config:read", "config", "read"}, {"config:read", "config", "read"},
{"srs:streams", "srs", "streams"}, {"srs:streams", "srs", "streams"},
} { } {
@ -237,7 +385,10 @@ func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) {
"authenticated": true, "authenticated": true,
"username": user.Username, "username": user.Username,
"role": user.Role, "role": user.Role,
"status": user.Status,
"permissions": perms, "permissions": perms,
"is_admin": auth.IsAdminOrAbove(user.Role),
"is_root": user.Role == auth.RoleRoot,
}) })
} }

View File

@ -8,7 +8,8 @@ import (
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
"sync-live/internal/room"
) )
func (s *Server) writeProto(w http.ResponseWriter, m proto.Message, err error) { func (s *Server) writeProto(w http.ResponseWriter, m proto.Message, err error) {
@ -158,3 +159,82 @@ func decodeTargets(msg []byte) []*gen.StreamTarget {
} }
return out return out
} }
func (s *Server) handleRoomsQuery(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page := 1
pageSize := 20
if v := q.Get("page"); v != "" {
fmt.Sscanf(v, "%d", &page)
}
if v := q.Get("page_size"); v != "" {
fmt.Sscanf(v, "%d", &pageSize)
}
if v := q.Get("pageSize"); v != "" {
fmt.Sscanf(v, "%d", &pageSize)
}
search := q.Get("search")
var searchPtr *string
if search != "" {
searchPtr = &search
}
statusStr := q.Get("status")
var statusPtr *room.RoomStatus
if statusStr != "" {
if st, err := room.ParseRoomStatus(statusStr); err == nil {
statusPtr = &st
}
}
creator := q.Get("creator")
var creatorPtr *string
if creator != "" {
creatorPtr = &creator
}
var isPublicPtr *bool
if v := q.Get("is_public"); v != "" {
b := v == "true" || v == "1"
isPublicPtr = &b
}
sortBy := room.RoomListSortBy(q.Get("sort_by"))
if sortBy == "" {
sortBy = room.SortByCreatedAt
}
sortDir := room.SortDirection(q.Get("sort_direction"))
if sortDir == "" {
sortDir = room.SortDesc
}
query := room.RoomListQuery{
Pagination: room.PageParams{Page: page, PageSize: pageSize},
Search: searchPtr,
Status: statusPtr,
Creator: creatorPtr,
IsPublic: isPublicPtr,
SortBy: sortBy,
SortDirection: sortDir,
}
// prefer DB-backed query if available
if s.roomStore != nil && s.roomStore.DB() != nil {
rooms, total, err := s.roomStore.QueryRooms(r.Context(), query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"rooms": rooms,
"total": total,
"page": page,
"page_size": pageSize,
})
return
}
// fallback to memory hub
all := s.hub.list()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"rooms": all,
"total": len(all),
"page": 1,
"page_size": len(all),
})
}

View File

@ -3,11 +3,11 @@ package server
import ( import (
"context" "context"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
) )
type grpcServer struct { type grpcServer struct {
gen.UnimplementedLiveSFUServer gen.UnimplementedSyncLiveServer
svc *Service svc *Service
} }
@ -35,7 +35,7 @@ func (g *grpcServer) StopStream(ctx context.Context, req *gen.StopStreamRequest)
return g.svc.StopStream(ctx, req) return g.svc.StopStream(ctx, req)
} }
func (g *grpcServer) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error { func (g *grpcServer) WatchRoom(req *gen.WatchRoomRequest, stream gen.SyncLive_WatchRoomServer) error {
return g.svc.WatchRoom(req, stream) return g.svc.WatchRoom(req, stream)
} }

View File

@ -7,10 +7,10 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
"gospeak-live-sfu-demo/internal/config" "sync-live/internal/config"
"gospeak-live-sfu-demo/internal/sfu/cloudflare" "sync-live/internal/sfu/cloudflare"
"gospeak-live-sfu-demo/internal/sfu/srs" "sync-live/internal/sfu/srs"
) )
// TestGRPCGetConfig 校验 protobuf/gRPC 控制面:生成的服务端 + 生成的客户端能互通。 // TestGRPCGetConfig 校验 protobuf/gRPC 控制面:生成的服务端 + 生成的客户端能互通。
@ -27,7 +27,7 @@ func TestGRPCGetConfig(t *testing.T) {
svc := NewService(cfg, cf, srsP, hub) svc := NewService(cfg, cf, srsP, hub)
gs := grpc.NewServer() gs := grpc.NewServer()
gen.RegisterLiveSFUServer(gs, NewGRPCServer(svc)) gen.RegisterSyncLiveServer(gs, NewGRPCServer(svc))
lis, err := net.Listen("tcp", "127.0.0.1:0") lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
@ -41,7 +41,7 @@ func TestGRPCGetConfig(t *testing.T) {
} }
defer conn.Close() defer conn.Close()
cli := gen.NewLiveSFUClient(conn) cli := gen.NewSyncLiveClient(conn)
resp, err := cli.GetConfig(context.Background(), &gen.GetConfigRequest{}) resp, err := cli.GetConfig(context.Background(), &gen.GetConfigRequest{})
if err != nil { if err != nil {
t.Fatalf("GetConfig: %v", err) t.Fatalf("GetConfig: %v", err)

485
internal/server/manage.go Normal file
View File

@ -0,0 +1,485 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"time"
"sync-live/internal/auth"
"sync-live/internal/room"
)
func (s *Server) handleManageRooms(w http.ResponseWriter, r *http.Request) {
if s.roomSvc == nil || s.roomSvc.Store().DB() == nil {
http.Error(w, "room service not configured", http.StatusServiceUnavailable)
return
}
switch r.Method {
case http.MethodGet:
rooms, err := s.roomSvc.Store().ListRooms(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
enhanced := make([]map[string]interface{}, 0, len(rooms))
for _, rm := range rooms {
targets := s.hub.targets(rm.Name)
members, _ := s.roomSvc.Store().ListMembers(r.Context(), rm.Name)
enhanced = append(enhanced, map[string]interface{}{
"room": rm,
"targets": targets,
"member_count": len(members),
"live": len(targets) > 0,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"rooms": enhanced})
case http.MethodPost:
var body struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
IsPublic *bool `json:"is_public"`
RequireApproval *bool `json:"require_approval"`
MaxMembers *int `json:"max_members"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
http.Error(w, "room name required", http.StatusBadRequest)
return
}
u, _ := auth.FromContext(r.Context())
creator := ""
if u != nil {
creator = u.Username
}
if creator == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
settings := room.DefaultSettings()
if body.IsPublic != nil {
settings.IsPublic = *body.IsPublic
}
if body.RequireApproval != nil {
settings.RequireApproval = *body.RequireApproval
}
if body.MaxMembers != nil && *body.MaxMembers > 0 {
settings.MaxMembers = *body.MaxMembers
}
newRoom := room.NewRoom(name, creator)
newRoom.DisplayName = body.DisplayName
if newRoom.DisplayName == "" {
newRoom.DisplayName = name
}
newRoom.Description = body.Description
newRoom.Settings = settings
newRoom.IsPublic = settings.IsPublic
newRoom.RequireApproval = settings.RequireApproval
newRoom.MaxMembers = settings.MaxMembers
if body.Password != "" {
newRoom.PasswordHash = body.Password
}
created, err := s.roomSvc.CreateRoom(r.Context(), name, creator, settings)
if err != nil {
http.Error(w, "create room: "+err.Error(), http.StatusBadRequest)
return
}
created.Description = body.Description
created.DisplayName = newRoom.DisplayName
if body.Password != "" {
created.PasswordHash = body.Password
}
s.hub.createRoom(name)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "room": created})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleManageRoomDetail(w http.ResponseWriter, r *http.Request) {
if s.roomSvc == nil {
http.Error(w, "room service not configured", http.StatusServiceUnavailable)
return
}
roomName := r.PathValue("room")
if roomName == "" {
roomName = r.URL.Query().Get("room")
}
if roomName == "" {
http.Error(w, "room required", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
rm, err := s.roomSvc.Store().GetRoom(r.Context(), roomName)
if err != nil {
http.Error(w, "room not found: "+err.Error(), http.StatusNotFound)
return
}
members, _ := s.roomSvc.Store().ListMembers(r.Context(), roomName)
targets := s.hub.targets(roomName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"room": rm,
"members": members,
"targets": targets,
})
case http.MethodPatch, http.MethodPut:
var body struct {
DisplayName *string `json:"display_name"`
Description *string `json:"description"`
Status *string `json:"status"`
IsPublic *bool `json:"is_public"`
RequireApproval *bool `json:"require_approval"`
MaxMembers *int `json:"max_members"`
Password *string `json:"password"`
ChatEnabled *bool `json:"chat_enabled"`
AllowGuestJoin *bool `json:"allow_guest_join"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
u, _ := auth.FromContext(r.Context())
actor := ""
if u != nil {
actor = u.Username
}
rm, err := s.roomSvc.Store().GetRoom(r.Context(), roomName)
if err != nil {
http.Error(w, "room not found", http.StatusNotFound)
return
}
if err := s.roomSvc.EnsurePermission(r.Context(), roomName, actor, room.PermManageRoom); err != nil {
http.Error(w, "forbidden: "+err.Error(), http.StatusForbidden)
return
}
settings := rm.Settings
if settings == nil {
settings = room.DefaultSettings()
}
if body.IsPublic != nil {
settings.IsPublic = *body.IsPublic
}
if body.RequireApproval != nil {
settings.RequireApproval = *body.RequireApproval
}
if body.MaxMembers != nil {
settings.MaxMembers = *body.MaxMembers
}
if body.ChatEnabled != nil {
settings.ChatEnabled = *body.ChatEnabled
}
if body.AllowGuestJoin != nil {
settings.AllowGuestJoin = *body.AllowGuestJoin
}
if body.Status != nil {
status := room.RoomStatus(*body.Status)
_ = s.roomSvc.Store().UpdateRoomStatus(r.Context(), roomName, status)
}
if body.DisplayName != nil || body.Description != nil || body.Password != nil || body.IsPublic != nil {
rm.DisplayName = stringOr(rm.DisplayName, body.DisplayName)
rm.Description = stringOr(rm.Description, body.Description)
rm.IsPublic = settings.IsPublic
rm.RequireApproval = settings.RequireApproval
rm.MaxMembers = settings.MaxMembers
if body.Password != nil {
rm.PasswordHash = *body.Password
}
rm.Settings = settings
rm.UpdatedAt = time.Now()
_ = s.roomSvc.Store().UpdateRoomSettings(r.Context(), roomName, settings)
} else {
_ = s.roomSvc.Store().UpdateRoomSettings(r.Context(), roomName, settings)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
case http.MethodDelete:
u, _ := auth.FromContext(r.Context())
actor := ""
if u != nil {
actor = u.Username
}
if err := s.roomSvc.EnsurePermission(r.Context(), roomName, actor, room.PermDeleteRoom); err != nil {
http.Error(w, "forbidden: "+err.Error(), http.StatusForbidden)
return
}
if err := s.roomSvc.Store().DeleteRoom(r.Context(), roomName); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.hub.removeRoom(roomName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleManageMembers(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
if roomName == "" {
http.Error(w, "room required", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
members, err := s.roomSvc.Store().ListMembers(r.Context(), roomName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rm, _ := s.roomSvc.Store().GetRoom(r.Context(), roomName)
var settings *room.RoomSettings
if rm != nil {
settings = rm.Settings
}
out := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
eff := m.EffectivePermissions(settings)
out = append(out, map[string]interface{}{
"username": m.Username,
"role": m.Role.String(),
"status": m.Status.String(),
"joined_at": m.JoinedAt.Unix(),
"effective_permissions": eff.Names(),
"effective_bits": eff.Bits(),
"added": m.AddedPerms,
"removed": m.RemovedPerms,
"admin_added": m.AdminAdded,
"admin_removed": m.AdminRemoved,
"version": m.Version,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"members": out, "room": roomName})
case http.MethodPost:
var body struct {
Username string `json:"username"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
actorID := actorName(r)
role, err := room.ParseRoomRole(body.Role)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.roomSvc.UpdateMemberRole(r.Context(), roomName, actorID, body.Username, role); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleManageMemberRole(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
target := r.PathValue("user")
if roomName == "" || target == "" {
http.Error(w, "room and user required", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodPut, http.MethodPost:
var body struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
role, err := room.ParseRoomRole(body.Role)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.roomSvc.UpdateMemberRole(r.Context(), roomName, actorName(r), target, role); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
case http.MethodDelete:
if err := s.roomSvc.KickMember(r.Context(), roomName, actorName(r), target); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleManageMemberPerms(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
target := r.PathValue("user")
if roomName == "" || target == "" {
http.Error(w, "room and user required", http.StatusBadRequest)
return
}
var body struct {
Added uint64 `json:"added"`
Removed uint64 `json:"removed"`
IsAdmin bool `json:"is_admin"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := s.roomSvc.UpdateMemberPermissions(r.Context(), roomName, actorName(r), target, body.Added, body.Removed, body.IsAdmin); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func (s *Server) handleManagePermissionsMatrix(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
if r.Method == http.MethodGet && roomName == "" {
defs := map[string]interface{}{
"roles": map[string]interface{}{
"creator": map[string]interface{}{"bits": room.PermDefaultCreator.Bits(), "perms": room.PermDefaultCreator.Names()},
"admin": map[string]interface{}{"bits": room.PermDefaultAdmin.Bits(), "perms": room.PermDefaultAdmin.Names()},
"member": map[string]interface{}{"bits": room.PermDefaultMember.Bits(), "perms": room.PermDefaultMember.Names()},
"guest": map[string]interface{}{"bits": room.PermDefaultGuest.Bits(), "perms": room.PermDefaultGuest.Names()},
},
"all_permissions": room.PermAll.Names(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(defs)
return
}
if roomName == "" {
http.Error(w, "room required", http.StatusBadRequest)
return
}
members, err := s.roomSvc.Store().ListMembers(r.Context(), roomName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rm, _ := s.roomSvc.Store().GetRoom(r.Context(), roomName)
settings := room.DefaultSettings()
if rm != nil && rm.Settings != nil {
settings = rm.Settings
}
matrix := make([]map[string]interface{}, 0, len(members))
for _, m := range members {
matrix = append(matrix, map[string]interface{}{
"username": m.Username,
"role": m.Role.String(),
"base": room.DefaultForRole(m.Role).Names(),
"effective": m.EffectivePermissions(settings).Names(),
"added": m.AddedPerms,
"removed": m.RemovedPerms,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"room": roomName, "settings": settings, "matrix": matrix})
}
func (s *Server) handleManageOverview(w http.ResponseWriter, r *http.Request) {
rooms, _ := s.roomSvc.Store().ListRooms(r.Context())
totalRooms := len(rooms)
liveRooms := 0
totalMembers := 0
for _, rm := range rooms {
if len(s.hub.targets(rm.Name)) > 0 {
liveRooms++
}
cnt, _ := s.roomSvc.Store().CountMembers(r.Context(), rm.Name)
totalMembers += cnt
}
users := 0
if s.auth != nil && s.auth.Store != nil {
users = len(s.auth.Store.List())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"rooms_total": totalRooms,
"rooms_live": liveRooms,
"members_total": totalMembers,
"users_total": users,
"backends": s.svc.Backends(),
})
}
func (s *Server) handleManageJoin(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
user := actorName(r)
if user == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
m, err := s.roomSvc.JoinRoom(r.Context(), roomName, user, body.Password)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "member": m})
}
func (s *Server) handleManageLeave(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
user := actorName(r)
if err := s.roomSvc.LeaveRoom(r.Context(), roomName, user); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func (s *Server) handleManageRoomSettings(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
rm, err := s.roomSvc.Store().GetRoom(r.Context(), roomName)
if err != nil {
http.Error(w, "room not found", http.StatusNotFound)
return
}
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(rm.Settings)
return
}
var newSettings room.RoomSettings
if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := s.roomSvc.UpdateRoomSettings(r.Context(), roomName, actorName(r), &newSettings); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func actorName(r *http.Request) string {
u, _ := auth.FromContext(r.Context())
if u != nil {
return u.Username
}
return ""
}
func stringOr(cur string, override *string) string {
if override != nil {
return *override
}
return cur
}

View File

@ -8,13 +8,13 @@ import (
"sync" "sync"
"time" "time"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
"gospeak-live-sfu-demo/internal/db" "sync-live/internal/db"
) )
// roomHub 维护房间 -> 各后端分发目标(StreamTarget)的内存状态, // roomHub 维护房间 -> 各后端分发目标(StreamTarget)的内存状态,
// 并向订阅者广播拓扑变化(实现「分流」状态实时可见)。 // 并向订阅者广播拓扑变化(实现「分流」状态实时可见)。
// 默认由 Turso 嵌入式 (libSQL file) 持久化。 // 默认由嵌入式 libSQL (file) 持久化。
type roomHub struct { type roomHub struct {
mu sync.RWMutex mu sync.RWMutex
rooms map[string]*roomEntry rooms map[string]*roomEntry
@ -30,7 +30,7 @@ func newRoomHub() *roomHub {
return &roomHub{rooms: map[string]*roomEntry{}} return &roomHub{rooms: map[string]*roomEntry{}}
} }
// newRoomHubWithDB 创建带 Turso 嵌入式持久化的 hub,启动时自动从 DB 加载全量房间。 // newRoomHubWithDB 创建带嵌入式 libSQL 持久化的 hub,启动时自动从 DB 加载全量房间。
func newRoomHubWithDB(database *sql.DB) *roomHub { func newRoomHubWithDB(database *sql.DB) *roomHub {
h := &roomHub{rooms: map[string]*roomEntry{}, db: database} h := &roomHub{rooms: map[string]*roomEntry{}, db: database}
if database != nil { if database != nil {
@ -38,7 +38,7 @@ func newRoomHubWithDB(database *sql.DB) *roomHub {
defer cancel() defer cancel()
all, err := db.LoadAll(ctx, database) all, err := db.LoadAll(ctx, database)
if err != nil { if err != nil {
log.Printf("[hub] load from turso embedded failed: %v", err) log.Printf("[hub] load from embedded db failed: %v", err)
} else { } else {
for room, backends := range all { for room, backends := range all {
e := &roomEntry{targets: map[string]*gen.StreamTarget{}, subs: map[chan []byte]struct{}{}} e := &roomEntry{targets: map[string]*gen.StreamTarget{}, subs: map[chan []byte]struct{}{}}
@ -48,7 +48,7 @@ func newRoomHubWithDB(database *sql.DB) *roomHub {
h.rooms[room] = e h.rooms[room] = e
} }
if len(all) > 0 { if len(all) > 0 {
log.Printf("[hub] loaded %d rooms from turso embedded", len(all)) log.Printf("[hub] loaded %d rooms from embedded db", len(all))
} }
} }
} }
@ -86,7 +86,7 @@ func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := db.SaveTarget(ctx, h.db, room, backend, t); err != nil { 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) log.Printf("[hub] save target %s/%s to embedded db: %v", room, backend, err)
} }
} }
} }
@ -178,3 +178,9 @@ func (h *roomHub) broadcast(room string, e *roomEntry) {
} }
} }
} }
func (h *roomHub) removeRoom(name string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.rooms, name)
}

View File

@ -12,12 +12,13 @@ import (
"time" "time"
"google.golang.org/grpc" "google.golang.org/grpc"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
"gospeak-live-sfu-demo/internal/auth" "sync-live/internal/auth"
"gospeak-live-sfu-demo/internal/config" "sync-live/internal/config"
"gospeak-live-sfu-demo/internal/db" "sync-live/internal/db"
"gospeak-live-sfu-demo/internal/sfu/cloudflare" "sync-live/internal/room"
"gospeak-live-sfu-demo/internal/sfu/srs" "sync-live/internal/sfu/cloudflare"
"sync-live/internal/sfu/srs"
) )
//go:embed static //go:embed static
@ -34,17 +35,24 @@ type Server struct {
auth *auth.Manager auth *auth.Manager
chat *chatHub chat *chatHub
chatRL *chatRateLimiter chatRL *chatRateLimiter
roomSvc *room.Service
roomStore *room.Store
} }
func New(cfg *config.Config) *Server { func New(cfg *config.Config) *Server {
var hub *roomHub var hub *roomHub
var rs *room.Store
var rsvc *room.Service
if cfg.DatabaseURL != "" { if cfg.DatabaseURL != "" {
if dbConn, err := db.Open(cfg.DatabaseURL); err != nil { 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) log.Printf("[warn] embedded db open failed (%v), falling back to memory hub: %s", err, cfg.DatabaseURL)
hub = newRoomHub() hub = newRoomHub()
} else { } else {
hub = newRoomHubWithDB(dbConn) hub = newRoomHubWithDB(dbConn)
log.Printf("[db] turso enabled: dsn=%s remote=%v", cfg.DSN(), cfg.IsRemoteTurso()) log.Printf("[db] embedded db enabled: dsn=%s remote=%v", cfg.DSN(), cfg.IsRemoteTurso())
rs = room.NewStore(dbConn)
_ = room.InitSchema(dbConn)
rsvc = room.NewService(rs)
} }
} else { } else {
hub = newRoomHub() hub = newRoomHub()
@ -52,7 +60,7 @@ func New(cfg *config.Config) *Server {
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, roomStore: rs, roomSvc: rsvc}
s.srsProxy = s.srsProxyHandler() s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler() s.cfProxy = s.cfProxyHandler()
s.srsHlsProxy = s.srsHlsProxyHandler() s.srsHlsProxy = s.srsHlsProxyHandler()
@ -73,7 +81,12 @@ func NewWithHub(cfg *config.Config, hub *roomHub) *Server {
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} rs := room.NewStore(nil)
if hub != nil && hub.DB() != nil {
rs = room.NewStore(hub.DB())
_ = room.InitSchema(hub.DB())
}
s := &Server{cfg: cfg, svc: svc, hub: hub, roomStore: rs, roomSvc: room.NewService(rs)}
s.srsProxy = s.srsProxyHandler() s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler() s.cfProxy = s.cfProxyHandler()
s.srsHlsProxy = s.srsHlsProxyHandler() s.srsHlsProxy = s.srsHlsProxyHandler()
@ -102,7 +115,7 @@ func (s *Server) StartGRPC() error {
) )
} }
gs := grpc.NewServer(opts...) gs := grpc.NewServer(opts...)
gen.RegisterLiveSFUServer(gs, NewGRPCServer(s.svc)) gen.RegisterSyncLiveServer(gs, NewGRPCServer(s.svc))
go func() { _ = gs.Serve(lis) }() go func() { _ = gs.Serve(lis) }()
return nil return nil
} }
@ -131,15 +144,22 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /publish", index) mux.Handle("GET /publish", index)
mux.Handle("GET /watch", index) mux.Handle("GET /watch", index)
mux.Handle("GET /login", index) mux.Handle("GET /login", index)
mux.Handle("GET /manage", index)
mux.Handle("GET /manage/", index)
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("POST /api/auth/login", s.handleLogin) mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/register", s.handleRegister) mux.HandleFunc("POST /api/auth/register", s.handleRegister)
mux.HandleFunc("POST /api/auth/logout", s.handleLogout) mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
mux.Handle("GET /api/auth/me", s.authWrap(http.HandlerFunc(s.handleMe), "user", "list", true)) mux.Handle("GET /api/auth/me", s.authWrap(http.HandlerFunc(s.handleMe), "user", "list", true))
mux.Handle("GET /api/auth/users", s.authWrap(http.HandlerFunc(s.handleListUsers), "user", "list", true)) mux.Handle("GET /api/auth/users", s.authWrap(http.HandlerFunc(s.handleListUsers), "user", "list", true))
mux.Handle("POST /api/auth/users/role", s.authWrap(http.HandlerFunc(s.handleUpdateRole), "user", "manage", true)) mux.Handle("POST /api/auth/users/role", s.authWrap(http.HandlerFunc(s.handleUpdateRole), "user", "manage", true))
mux.Handle("POST /api/auth/users/ban", s.authWrap(http.HandlerFunc(s.handleBanUser), "user", "manage", true))
mux.Handle("POST /api/auth/users/unban", s.authWrap(http.HandlerFunc(s.handleUnbanUser), "user", "manage", true))
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false)) mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false)) mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true)) mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
@ -155,6 +175,26 @@ func (s *Server) Handler() http.Handler {
// 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host // 主播独立外部推送接口:仅 publisher/admin(room:publish),供 OBS/机器人/管理工具以 Bearer token 调用,消息标记 host
mux.Handle("POST /api/room/{room}/broadcast", s.authWrap(http.HandlerFunc(s.handleRoomBroadcast), "room", "publish", true)) mux.Handle("POST /api/room/{room}/broadcast", s.authWrap(http.HandlerFunc(s.handleRoomBroadcast), "room", "publish", true))
// ---- Manage 统一管理面 ----
mux.Handle("GET /api/manage/overview", s.authWrap(http.HandlerFunc(s.handleManageOverview), "config", "read", true))
mux.Handle("GET /api/manage/rooms", s.authWrap(http.HandlerFunc(s.handleManageRooms), "room", "list", true))
mux.Handle("POST /api/manage/rooms", s.authWrap(http.HandlerFunc(s.handleManageRooms), "room", "list", true))
mux.Handle("GET /api/manage/rooms/{room}", s.authWrap(http.HandlerFunc(s.handleManageRoomDetail), "room", "list", true))
mux.Handle("PUT /api/manage/rooms/{room}", s.authWrap(http.HandlerFunc(s.handleManageRoomDetail), "room", "list", true))
mux.Handle("PATCH /api/manage/rooms/{room}", s.authWrap(http.HandlerFunc(s.handleManageRoomDetail), "room", "list", true))
mux.Handle("DELETE /api/manage/rooms/{room}", s.authWrap(http.HandlerFunc(s.handleManageRoomDetail), "room", "list", true))
mux.Handle("GET /api/manage/rooms/{room}/members", s.authWrap(http.HandlerFunc(s.handleManageMembers), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/members", s.authWrap(http.HandlerFunc(s.handleManageMembers), "room", "list", true))
mux.Handle("PUT /api/manage/rooms/{room}/members/{user}", s.authWrap(http.HandlerFunc(s.handleManageMemberRole), "room", "list", true))
mux.Handle("DELETE /api/manage/rooms/{room}/members/{user}", s.authWrap(http.HandlerFunc(s.handleManageMemberRole), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/members/{user}/perms", s.authWrap(http.HandlerFunc(s.handleManageMemberPerms), "room", "list", true))
mux.Handle("GET /api/manage/permissions", s.authWrap(http.HandlerFunc(s.handleManagePermissionsMatrix), "config", "read", true))
mux.Handle("GET /api/manage/permissions/{room}", s.authWrap(http.HandlerFunc(s.handleManagePermissionsMatrix), "room", "list", true))
mux.Handle("GET /api/manage/rooms/{room}/settings", s.authWrap(http.HandlerFunc(s.handleManageRoomSettings), "room", "list", true))
mux.Handle("PUT /api/manage/rooms/{room}/settings", s.authWrap(http.HandlerFunc(s.handleManageRoomSettings), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/join", s.authWrap(http.HandlerFunc(s.handleManageJoin), "room", "subscribe", true))
mux.Handle("POST /api/manage/rooms/{room}/leave", s.authWrap(http.HandlerFunc(s.handleManageLeave), "room", "subscribe", true))
mux.Handle("GET /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false)) mux.Handle("GET /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("POST /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false)) mux.Handle("POST /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))
mux.Handle("PUT /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false)) mux.Handle("PUT /rtc/v1/", s.authWrap(s.srsProxy, "room", "publish", false))

View File

@ -5,14 +5,14 @@ import (
"fmt" "fmt"
"time" "time"
"gospeak-live-sfu-demo/gen" "sync-live/gen"
"gospeak-live-sfu-demo/internal/auth" "sync-live/internal/auth"
"gospeak-live-sfu-demo/internal/config" "sync-live/internal/config"
"gospeak-live-sfu-demo/internal/sfu/cloudflare" "sync-live/internal/sfu/cloudflare"
"gospeak-live-sfu-demo/internal/sfu/srs" "sync-live/internal/sfu/srs"
) )
// Service 实现 LiveSFU 控制面逻辑:协调房间在各 SFU 后端的发布/订阅拓扑。 // Service 实现 SyncLive 控制面逻辑:协调房间在各 SFU 后端的发布/订阅拓扑。
type Service struct { type Service struct {
cfg *config.Config cfg *config.Config
cf *cloudflare.Provider cf *cloudflare.Provider
@ -45,6 +45,12 @@ func backendName(k gen.BackendKind) string {
} }
} }
func (s *Service) Backends() []*gen.BackendInfo {
resp, _ := s.GetConfig(context.Background(), &gen.GetConfigRequest{})
if resp != nil { return resp.Backends }
return nil
}
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(),
@ -189,7 +195,7 @@ func (s *Service) StopStream(ctx context.Context, req *gen.StopStreamRequest) (*
return &gen.StopStreamResponse{Ok: true}, nil return &gen.StopStreamResponse{Ok: true}, nil
} }
func (s *Service) WatchRoom(req *gen.WatchRoomRequest, stream gen.LiveSFU_WatchRoomServer) error { func (s *Service) WatchRoom(req *gen.WatchRoomRequest, stream gen.SyncLive_WatchRoomServer) error {
if err := s.checkAuth(stream.Context(), "room", "watch"); err != nil { if err := s.checkAuth(stream.Context(), "room", "watch"); err != nil {
return err return err
} }
@ -313,8 +319,18 @@ func (s *Service) UpdateUserRole(ctx context.Context, req *gen.UpdateUserRoleReq
if req.GetUsername() == "" || req.GetRole() == "" { if req.GetUsername() == "" || req.GetRole() == "" {
return nil, fmt.Errorf("username and role required") return nil, fmt.Errorf("username and role required")
} }
if err := s.auth.UpdateUserRole(req.GetUsername(), req.GetRole()); err != nil { actor := ""
return nil, err if u, ok := authContextUser(ctx); ok {
actor = u.Username
}
if actor != "" {
if err := s.auth.UpdateUserRoleAs(actor, req.GetUsername(), req.GetRole()); err != nil {
return nil, err
}
} else {
if err := s.auth.UpdateUserRole(req.GetUsername(), req.GetRole()); err != nil {
return nil, err
}
} }
u, _ := s.auth.Store.Get(req.GetUsername()) u, _ := s.auth.Store.Get(req.GetUsername())
return &gen.UpdateUserRoleResponse{ return &gen.UpdateUserRoleResponse{
@ -323,6 +339,22 @@ func (s *Service) UpdateUserRole(ctx context.Context, req *gen.UpdateUserRoleReq
}, nil }, nil
} }
func authContextUser(ctx context.Context) (*auth.AuthedUser, bool) {
// helper to extract actor from gRPC/HTTP context (dual auth)
if md, ok := authFromContext(ctx); ok {
return md, true
}
return nil, false
}
func authFromContext(ctx context.Context) (*auth.AuthedUser, bool) {
// try auth package context
if u, ok := ctx.Value(auth.ContextUserKey).(*auth.AuthedUser); ok {
return u, true
}
return nil, false
}
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 {

View File

@ -1 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>t(n,e(i,{iconNode:r,name:`arrow-right`}));export{i as t}; import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>t(n,e(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>t(n,e(i,{iconNode:r,name:`cloud`}));export{i as t};

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>t(n,e(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>t(n,e(r,{iconNode:a,name:`eye`}));export{i as n,o as t};

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>t(n,e(i,{iconNode:r,name:`hash`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]],i=i=>t(n,e(i,{iconNode:r,name:`log-out`}));export{i as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{C as e,G as t,L as n,N as r,O as i,S as a,U as o,_ as s,a as c,d as l,f as u,g as d,h as f,j as p,l as m,m as h,r as g,t as _,x as v}from"./ui-BEZm0Fjb.js";import{t as y}from"./radio-tower-CN-xjSIh.js";import{t as b}from"./index-BjOqv38F.js";var x=[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]],S=e=>n(h,t(e,{iconNode:x,name:`log-in`})),C=i(`<div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8"><div class="flex items-center gap-3"><span class="flex size-10 shrink-0 items-center justify-center border border-acid-lime/70 bg-acid-lime/15 text-acid-lime"></span><div><h1 class="display text-3xl">登录</h1><p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p></div></div><form class="mt-7 space-y-5"><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground"for=login-username>用户名 <span class=opacity-50>username</span></label></div><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground"for=login-password>密码 <span class=opacity-50>password</span></label></div><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground">角色 <span class=opacity-50>role`),w=i(`<div class="border border-line/80 bg-card/50 p-4"><p class="micro-label text-muted-foreground">demo accounts · 演示账号</p><div class="mt-3 grid gap-2 sm:grid-cols-3">`),T=i(`<div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10"><div class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none"aria-hidden=true>SFU</div><div class="relative w-full max-w-md">`),E=i(`<button type=button class="group border border-line/80 bg-background/50 px-3 py-2.5 text-left transition-colors hover:border-acid-lime/60 hover:bg-background"><span class="flex items-baseline justify-between gap-2"><span class="font-mono text-xs font-medium"></span><span class="size-1 shrink-0 rotate-45 bg-line transition-colors group-hover:bg-acid-lime"></span></span><span class="mt-0.5 block text-[11px] text-muted-foreground"></span><span class="mt-1 block font-mono text-[10px] text-muted-foreground/70">`),D=[{name:`admin`,role:`管理员`,pass:`Admin123!`},{name:`publisher`,role:`发布者`,pass:`Publisher123!`},{name:`viewer`,role:`观看者`,pass:`Viewer123!`}],O=()=>{let t=b(),[i,h]=o(``),[v,x]=o(``),[O,k]=o(`viewer`),[A,j]=o(``),[M,N]=o(!1),P=async e=>{e.preventDefault(),j(``),N(!0);try{await s.login(i(),v()),await d(),await f.refresh(),t({to:`/`})}catch(e){j(String(e?.message||e))}finally{N(!1)}};return(()=>{var t=T(),o=t.firstChild.nextSibling;return a(o,n(m,{get children(){var t=C(),o=t.firstChild,s=o.firstChild,d=o.nextSibling,f=d.firstChild;f.firstChild;var p=f.nextSibling;p.firstChild;var m=p.nextSibling;return m.firstChild,a(t,n(c,{}),o),a(s,n(y,{class:`size-5`})),d.addEventListener(`submit`,P),a(f,n(u,{id:`login-username`,get value(){return i()},onInput:h,placeholder:`admin`,autoComplete:`username`,class:`h-10`}),null),a(p,n(u,{id:`login-password`,type:`password`,get value(){return v()},onInput:x,placeholder:`••••••••`,autoComplete:`current-password`,class:`h-10`}),null),a(m,n(l,{get value(){return O()},onChange:e=>k(e),options:[{value:`viewer`,label:`观看者 viewer`},{value:`publisher`,label:`发布者 publisher`}],class:`w-full`}),null),a(d,n(g,{type:`submit`,get disabled(){return M()},class:`h-10 w-full`,get children(){return[n(S,{class:`size-4`}),e(()=>M()?`登录中…`:`登录`)]}}),null),a(t,n(r,{get when(){return A()},get children(){return n(_,{tone:`error`,class:`mt-4`,get children(){return A()}})}}),null),t}}),null),a(o,n(m,{delay:1,class:`mt-4`,get children(){var e=w(),t=e.firstChild.nextSibling;return a(t,n(p,{each:D,children:e=>(()=>{var t=E(),n=t.firstChild,r=n.firstChild,i=n.nextSibling,o=i.nextSibling;return t.$$click=()=>{h(e.name),x(e.pass),k(e.role===`管理员`?`publisher`:`viewer`)},a(r,()=>e.name),a(i,()=>e.role),a(o,()=>e.pass),t})()})),e}}),null),t})()};v([`click`]);export{O as Login};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],i=i=>t(n,e(i,{iconNode:r,name:`message-square`}));export{i as t};

View File

@ -1 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],i=i=>t(n,e(i,{iconNode:r,name:`monitor-play`}));export{i as t}; import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],i=i=>t(n,e(i,{iconNode:r,name:`monitor-play`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>t(n,e(i,{iconNode:r,name:`radio-tower`}));export{i as t}; import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>t(n,e(i,{iconNode:r,name:`radio-tower`}));export{i as t};

View File

@ -1 +0,0 @@
import{G as e,L as t,N as n,O as r,S as i,U as a,V as o,_ as s,a as c,h as l,j as u,l as d,m as f,o as p,r as m,x as h,z as g}from"./ui-BEZm0Fjb.js";import{t as _}from"./arrow-right-CYxcOjf4.js";import{t as v}from"./index-BjOqv38F.js";var y=[[`path`,{d:`M11 20H2`,key:`nlcfvz`}],[`path`,{d:`M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z`,key:`au4z13`}],[`path`,{d:`M11 4H8a2 2 0 0 0-2 2v14`,key:`74r1mk`}],[`path`,{d:`M14 12h.01`,key:`1jfl7z`}],[`path`,{d:`M22 20h-3`,key:`vhrsz`}]],b=n=>t(f,e(n,{iconNode:y,name:`door-open`})),x=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],S=n=>t(f,e(n,{iconNode:x,name:`plus`})),C=r(`<div><p class="mono-label text-muted-foreground">rooms / registry</p><h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1><p class="mt-3 max-w-lg text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。`),w=r(`<div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4"><div class="flex flex-col gap-2 sm:flex-row"><div class="flex h-10 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring"><input class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"placeholder="房间名,如 demo"aria-label=房间名>`),T=r(`<div class="flex items-center justify-between gap-3 border-b border-line pb-3"><h2 class=font-semibold>房间列表</h2><span class="mono-label text-muted-foreground"> rooms`),E=r(`<div>`),D=r(`<div class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10"><section class=space-y-3>`),O=r(`<div class="flex flex-col items-center gap-3 border border-dashed border-line px-6 py-12 text-center"><span class="flex size-10 items-center justify-center border border-line bg-card text-muted-foreground"></span><div><p class=font-medium>暂无房间</p><p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。`),k=r(`<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4"><span class="hidden font-mono text-xs text-muted-foreground/70 sm:block"></span><div class=min-w-0><p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary"></p><p class="mono-label mt-1 text-muted-foreground"> 路分发 · </p></div><div class="col-span-2 sm:col-span-1">`),A=()=>{let e=v(),[r,f]=a([]),[h,y]=a(``),[x,A]=a(!1),j=()=>s.listRooms().then(e=>f(e.rooms||[])).catch(()=>{});g(()=>{l.isAuthed()&&j()});let M=async()=>{let t=h().trim();if(t){A(!0);try{await s.createRoom(t),e({to:`/room`,search:{room:t}})}catch(e){alert(String(e?.message||e))}finally{A(!1)}}},N=t=>e({to:`/room`,search:{room:t}});return(()=>{var e=D(),a=e.firstChild;return i(e,t(d,{get children(){return C()}}),a),i(e,t(d,{delay:1,get children(){var e=w(),n=e.firstChild,r=n.firstChild,a=r.firstChild;return i(e,t(c,{}),n),i(r,t(S,{class:`size-4 shrink-0 text-muted-foreground`}),a),a.$$keydown=e=>{e.key===`Enter`&&M()},a.$$input=e=>y(e.currentTarget.value),i(n,t(m,{class:`h-10`,onClick:M,get disabled(){return x()},get children(){return[t(_,{class:`size-4`}),`创建并进入`]}}),null),o(()=>a.value=h()),e}}),a),i(a,t(d,{get children(){var e=T(),t=e.firstChild.nextSibling,n=t.firstChild;return i(t,()=>r().length,n),e}}),null),i(a,t(n,{get when(){return r().length>0},get fallback(){return(()=>{var e=O(),n=e.firstChild;return i(n,t(b,{class:`size-5`})),e})()},get children(){var e=E();return i(e,t(u,{get each(){return r()},children:(e,n)=>t(d,{get delay(){return Math.min(n()+1,5)},get children(){var r=k(),a=r.firstChild,o=a.nextSibling,s=o.firstChild,c=s.nextSibling,l=c.firstChild,u=o.nextSibling;return i(a,()=>String(n()+1).padStart(2,`0`)),i(r,t(p,{get active(){return(e.targets?.length??0)>0}}),o),i(s,()=>e.name),i(c,()=>e.targets?.length??0,l),i(c,()=>(e.targets?.length??0)>0?`live`:`idle`,null),i(u,t(m,{variant:`outline`,class:`h-9 w-full sm:w-auto`,onClick:()=>N(e.name),get children(){return[`进入房间`,t(_,{class:`size-4`})]}})),r}})})),e}}),null),e})()};h([`input`,`keydown`]);export{A as Rooms};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>t(n,e(i,{iconNode:r,name:`server`}));export{i as t}; import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>t(n,e(i,{iconNode:r,name:`server`}));export{i as t};

View File

@ -1 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-check`}));export{i as t}; import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-check`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>t(n,e(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};

View File

@ -1 +0,0 @@
import{C as e,G as t,L as n,N as r,O as i,S as a,U as o,V as s,_ as c,b as l,d as u,h as d,j as f,l as p,m,n as h,t as g,z as _}from"./ui-BEZm0Fjb.js";import{t as v}from"./shield-check-DH7IfQwC.js";var y=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],b=e=>n(m,t(e,{iconNode:y,name:`shield-alert`})),x=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],S=e=>n(m,t(e,{iconNode:x,name:`user-round`})),C=i(`<div><p class="mono-label text-muted-foreground">users / rbac</p><h1 class="display mt-2 text-4xl sm:text-5xl">用户与角色</h1><p class="mt-3 max-w-lg text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。`),w=i(`<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">暂无用户数据`),T=i(`<div class="border border-line bg-card/50"><div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid"><span class="micro-label text-muted-foreground">username</span><span class="micro-label text-muted-foreground">role</span><span class="micro-label text-right text-muted-foreground">action`),E=i(`<div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">`),D=i(`<span class="flex items-center gap-2">无权限:需要 user:list`),O=i(`<div><div class="flex min-w-0 items-center gap-3"><span></span><div class=min-w-0><p class="truncate font-mono text-sm font-medium"></p><p class="micro-label mt-1 text-muted-foreground"></p></div></div><div class="flex items-center justify-end">`),k=[`viewer`,`publisher`,`admin`],A={admin:{label:`管理员`,badge:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`,avatar:`border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta`},publisher:{label:`发布者`,badge:`border-warning/30 bg-warning/10 text-warning`,avatar:`border-warning/40 bg-warning/10 text-warning`},viewer:{label:`观看者`,badge:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`,avatar:`border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan`},guest:{label:`访客`,badge:`border-line bg-transparent text-muted-foreground`,avatar:`border-line bg-muted text-muted-foreground`}},j=()=>{let[t,i]=o([]),[m,y]=o(``);_(()=>{d.can(`user:list`)&&c.listUsers().then(e=>i(e.users||[])).catch(e=>y(String(e?.message||e)))});let x=async(e,t)=>{try{await c.updateUserRole(e,t),i(n=>n.map(n=>n.username===e?{...n,role:t}:n))}catch(e){y(String(e?.message||e))}};return(()=>{var i=E();return a(i,n(p,{get children(){return C()}}),null),a(i,n(r,{get when(){return d.can(`user:list`)},get fallback(){return n(g,{tone:`warning`,get children(){var e=D(),t=e.firstChild;return a(e,n(b,{class:`size-4`}),t),e}})},get children(){return n(p,{delay:1,get children(){var i=T(),o=i.firstChild;return a(i,n(r,{get when(){return m()},get children(){return n(g,{tone:`error`,class:`border-x-0 border-t-0`,get children(){return m()}})}}),o),a(i,n(f,{get each(){return t()},children:(e,i)=>(()=>{var o=O(),c=o.firstChild,f=c.firstChild,p=f.nextSibling.firstChild,m=p.nextSibling,g=c.nextSibling;return a(f,()=>(e.username[0]??`?`).toUpperCase()),a(p,()=>e.username),a(m,()=>A[e.role].label),a(o,n(h,{get class(){return A[e.role].badge},get children(){return e.role}}),g),a(g,n(r,{get when(){return d.can(`user:manage`)},get fallback(){return n(S,{class:`size-4 text-muted-foreground/50`})},get children(){return n(u,{class:`w-32`,get value(){return e.role},get"aria-label"(){return`调整 ${e.username} 的角色`},onChange:t=>x(e.username,t),get options(){return k.map(e=>({value:e,label:e}))}})}})),s(n=>{var r=`grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 ${i()<t().length-1?`border-b border-line/70`:``}`,a=`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${A[e.role].avatar}`;return r!==n.e&&l(o,n.e=r),a!==n.t&&l(f,n.t=a),n},{e:void 0,t:void 0}),o})()}),null),a(i,n(r,{get when(){return e(()=>t().length===0)()&&!m()},get children(){var e=w(),t=e.firstChild;return a(e,n(v,{class:`size-4 text-success`}),t),e}}),null),i}})}}),null),i})()};export{j as Users};

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>t(n,e(i,{iconNode:r,name:`globe`})),a=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],o=r=>t(n,e(r,{iconNode:a,name:`lock`})),s=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],c=r=>t(n,e(r,{iconNode:s,name:`plus`})),l=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],u=r=>t(n,e(r,{iconNode:l,name:`users`}));export{i,c as n,o as r,u as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>t(n,e(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5`,key:`ftymec`}],[`rect`,{x:`2`,y:`6`,width:`14`,height:`12`,rx:`2`,key:`158x01`}]],o=r=>t(n,e(r,{iconNode:a,name:`video`}));export{i as n,o as t};

View File

@ -0,0 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5`,key:`ftymec`}],[`rect`,{x:`2`,y:`6`,width:`14`,height:`12`,rx:`2`,key:`158x01`}]],i=i=>t(n,e(i,{iconNode:r,name:`video`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一路推流,多端扇出:Cloudflare Realtime 与 SRS 双 SFU 分发控制台" /> <meta name="description" content="SyncLive 同步直播:Cloudflare Realtime 与 SRS 双 SFU 分发控制台,对标 SyncTV" />
<title>直播 SFU 分流控制台</title> <title>SyncLive — 同步直播控制台</title>
<script> <script>
(function () { (function () {
try { try {
var t = localStorage.getItem('live-sfu-theme') var t = localStorage.getItem('sync-live-theme')
if (t !== 'light' && t !== 'dark') { if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} }
@ -19,9 +19,9 @@
} }
})() })()
</script> </script>
<script type="module" crossorigin src="/static/assets/index-BjOqv38F.js"></script> <script type="module" crossorigin src="/static/assets/index-BTRwyDO1.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-BEZm0Fjb.js"> <link rel="modulepreload" crossorigin href="/static/assets/ui-CzB-gjVP.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-iV_C3-n9.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-OrVzMh6m.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -7,7 +7,7 @@ import (
func TestStreamTokenRoundtrip(t *testing.T) { func TestStreamTokenRoundtrip(t *testing.T) {
secret := "s3cr3t" secret := "s3cr3t"
tok, err := signStreamToken(secret, "live-demo", "id1", "publish", time.Hour) tok, err := signStreamToken(secret, "live-test", "id1", "publish", time.Hour)
if err != nil { if err != nil {
t.Fatalf("sign: %v", err) t.Fatalf("sign: %v", err)
} }
@ -15,14 +15,14 @@ func TestStreamTokenRoundtrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("verify: %v", err) t.Fatalf("verify: %v", err)
} }
if claims.Room != "live-demo" || claims.Role != "publish" { if claims.Room != "live-test" || claims.Role != "publish" {
t.Fatalf("claims = %+v", claims) t.Fatalf("claims = %+v", claims)
} }
} }
func TestStreamTokenRejects(t *testing.T) { func TestStreamTokenRejects(t *testing.T) {
secret := "s3cr3t" secret := "s3cr3t"
tok, _ := signStreamToken(secret, "live-demo", "id1", "publish", time.Hour) tok, _ := signStreamToken(secret, "live-test", "id1", "publish", time.Hour)
if _, err := verifyStreamToken("other", tok); err == nil { if _, err := verifyStreamToken("other", tok); err == nil {
t.Fatal("expected reject on wrong secret") t.Fatal("expected reject on wrong secret")

View File

@ -1,6 +1,6 @@
package cloudflare package cloudflare
import "gospeak-live-sfu-demo/gen" import "sync-live/gen"
// Provider 暴露 Cloudflare Realtime 后端的能力信息,并持有底层 REST 客户端。 // Provider 暴露 Cloudflare Realtime 后端的能力信息,并持有底层 REST 客户端。
type Provider struct { type Provider struct {

View File

@ -1,6 +1,6 @@
package srs package srs
import "gospeak-live-sfu-demo/gen" import "sync-live/gen"
// Provider 暴露 SRS 后端能力(本地 docker 默认可达)。 // Provider 暴露 SRS 后端能力(本地 docker 默认可达)。
type Provider struct { type Provider struct {

View File

@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一路推流,多端扇出:Cloudflare Realtime 与 SRS 双 SFU 分发控制台" /> <meta name="description" content="SyncLive 同步直播:Cloudflare Realtime 与 SRS 双 SFU 分发控制台,对标 SyncTV" />
<title>直播 SFU 分流控制台</title> <title>SyncLive — 同步直播控制台</title>
<script> <script>
(function () { (function () {
try { try {
var t = localStorage.getItem('live-sfu-theme') var t = localStorage.getItem('sync-live-theme')
if (t !== 'light' && t !== 'dark') { if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "live-sfu-web", "name": "sync-live-web",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -10,6 +10,7 @@ const RoomRoute = lazy(() => import('./routes/watch').then((m) => ({ default: m.
const Rooms = lazy(() => import('./routes/rooms').then((m) => ({ default: m.Rooms }))) const Rooms = lazy(() => import('./routes/rooms').then((m) => ({ default: m.Rooms })))
const Users = lazy(() => import('./routes/users').then((m) => ({ default: m.Users }))) const Users = lazy(() => import('./routes/users').then((m) => ({ default: m.Users })))
const Login = lazy(() => import('./routes/login').then((m) => ({ default: m.Login }))) const Login = lazy(() => import('./routes/login').then((m) => ({ default: m.Login })))
const Manage = lazy(() => import('./routes/manage').then((m) => ({ default: m.Manage })))
const rootRoute = new RootRoute({ component: Layout }) const rootRoute = new RootRoute({ component: Layout })
@ -18,27 +19,29 @@ const homeRoute = new Route({ getParentRoute: () => rootRoute, path: '/', compon
const publishRoute = new Route({ const publishRoute = new Route({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/publish', path: '/publish',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }), validateSearch: (s: any) => ({ room: typeof s?.room === 'string' && s.room.trim() !== '' ? s.room : '' }),
component: Publish as any, component: Publish as any,
}) })
const watchRoute = new Route({ const watchRoute = new Route({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/watch', path: '/watch',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }), validateSearch: (s: any) => ({ room: typeof s?.room === 'string' && s.room.trim() !== '' ? s.room : '' }),
component: WatchRoute as any, component: WatchRoute as any,
}) })
const roomRoute = new Route({ const roomRoute = new Route({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/room', path: '/room',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }), validateSearch: (s: any) => ({ room: typeof s?.room === 'string' && s.room.trim() !== '' ? s.room : '' }),
component: RoomRoute as any, component: RoomRoute as any,
}) })
const roomsRoute = new Route({ getParentRoute: () => rootRoute, path: '/rooms', component: Rooms as any }) const roomsRoute = new Route({ getParentRoute: () => rootRoute, path: '/rooms', component: Rooms as any })
const usersRoute = new Route({ getParentRoute: () => rootRoute, path: '/users', component: Users as any }) const usersRoute = new Route({ getParentRoute: () => rootRoute, path: '/users', component: Users as any })
const manageRoute = new Route({ getParentRoute: () => rootRoute, path: '/manage', component: Manage as any })
const loginRoute = new Route({ getParentRoute: () => rootRoute, path: '/login', component: Login as any }) const loginRoute = new Route({ getParentRoute: () => rootRoute, path: '/login', component: Login as any })
export const routeTree = rootRoute.addChildren([ export const routeTree = rootRoute.addChildren([
@ -48,5 +51,6 @@ export const routeTree = rootRoute.addChildren([
roomRoute, roomRoute,
roomsRoute, roomsRoute,
usersRoute, usersRoute,
manageRoute,
loginRoute, loginRoute,
]) ])

View File

@ -58,7 +58,7 @@ export const Home: Component = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [backends, setBackends] = createSignal<BackendInfo[]>([]) const [backends, setBackends] = createSignal<BackendInfo[]>([])
const [rooms, setRooms] = createSignal<Room[]>([]) const [rooms, setRooms] = createSignal<Room[]>([])
const [room, setRoom] = createSignal('demo') const [room, setRoom] = createSignal('')
let heroEl: HTMLElement | undefined let heroEl: HTMLElement | undefined
onMount(() => { onMount(() => {
@ -136,7 +136,7 @@ export const Home: Component = () => {
<Reveal> <Reveal>
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<span class="size-2 bg-success pulse" /> <span class="size-2 bg-success pulse" />
<span class="mono-label text-muted-foreground">live-sfu / fan-out console</span> <span class="mono-label text-muted-foreground">SyncLive / 对标 SyncTV · fan-out</span>
</div> </div>
</Reveal> </Reveal>
@ -169,7 +169,7 @@ export const Home: Component = () => {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') go('/room') if (e.key === 'Enter') go('/room')
}} }}
placeholder="房间名,如 demo" placeholder="输入房间名"
aria-label="房间名" aria-label="房间名"
/> />
</div> </div>

View File

@ -1,7 +1,7 @@
import { Component, For, onMount, Show } from 'solid-js' import { Component, For, onMount, Show } from 'solid-js'
import { Link, Outlet, useLocation } from '@tanstack/solid-router' import { Link, Outlet, useLocation } from '@tanstack/solid-router'
import { RadioTower } from 'lucide-solid' import { RadioTower, Crown, Ban } from 'lucide-solid'
import { refreshMe } from '../../lib/auth' import { refreshMe, auth } from '../../lib/auth'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { ThemeToggle } from '../../components/ui' import { ThemeToggle } from '../../components/ui'
@ -19,6 +19,7 @@ const NAV: NavItem[] = [
{ to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] }, { to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] },
{ to: '/publish', idx: '04', label: '推流' }, { to: '/publish', idx: '04', label: '推流' },
{ to: '/users', idx: '05', label: '用户', adminOnly: true }, { to: '/users', idx: '05', label: '用户', adminOnly: true },
{ to: '/manage', idx: '06', label: '管理', adminOnly: true },
] ]
export const Layout: Component = () => { export const Layout: Component = () => {
@ -30,12 +31,13 @@ export const Layout: Component = () => {
}) })
const isActive = (item: NavItem) => (item.activePaths ?? [item.to]).includes(location().pathname) const isActive = (item: NavItem) => (item.activePaths ?? [item.to]).includes(location().pathname)
const isBanned = () => permissions.isBanned()
const navLinks = (h: string) => ( const navLinks = (h: string) => (
<> <>
<For each={NAV}> <For each={NAV}>
{(item) => ( {(item) => (
<Show when={!item.adminOnly || permissions.can('user:manage')}> <Show when={!item.adminOnly || permissions.isAdminOrAbove()}>
<Link <Link
to={item.to} to={item.to}
class={`nav-link flex ${h} shrink-0 items-center gap-1.5 font-mono text-[11px] tracking-[0.16em] uppercase whitespace-nowrap transition-colors ${ class={`nav-link flex ${h} shrink-0 items-center gap-1.5 font-mono text-[11px] tracking-[0.16em] uppercase whitespace-nowrap transition-colors ${
@ -46,6 +48,9 @@ export const Layout: Component = () => {
> >
<span class="text-[9px] opacity-50">{item.idx}</span> <span class="text-[9px] opacity-50">{item.idx}</span>
{item.label} {item.label}
<Show when={item.adminOnly && permissions.isRoot()}>
<Crown class="size-3 text-amber-500" />
</Show>
</Link> </Link>
</Show> </Show>
)} )}
@ -53,6 +58,14 @@ export const Layout: Component = () => {
</> </>
) )
const roleColor = () => {
const r = permissions.role()
if (r === 'root') return 'border-amber-500/30 bg-amber-500/10 text-amber-600'
if (isBanned()) return 'border-destructive/30 bg-destructive/10 text-destructive'
if (permissions.isAdmin()) return 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta'
return 'border-line bg-card/60 text-muted-foreground'
}
return ( return (
<div class="relative min-h-screen"> <div class="relative min-h-screen">
<div class="atmo-grid" aria-hidden="true" /> <div class="atmo-grid" aria-hidden="true" />
@ -61,15 +74,19 @@ export const Layout: Component = () => {
<div class="scanline-sweep" aria-hidden="true" /> <div class="scanline-sweep" aria-hidden="true" />
<div class="noise" aria-hidden="true" /> <div class="noise" aria-hidden="true" />
<Show when={isBanned()}>
<div class="sticky top-12 z-30 flex items-center justify-center gap-2 bg-destructive px-4 py-1.5 text-xs text-destructive-foreground">
<Ban class="size-3.5" />
账号已被封禁(banned),部分功能受限,请联系超管解封
</div>
</Show>
<header class="sticky top-0 z-40 border-b border-line/80 bg-background/85 backdrop-blur-md"> <header class="sticky top-0 z-40 border-b border-line/80 bg-background/85 backdrop-blur-md">
<div <div class="absolute inset-x-0 top-0 h-[3px]" style="background: linear-gradient(90deg, hsl(var(--acid-lime)), hsl(var(--acid-cyan)) 38%, hsl(var(--acid-violet)) 70%, hsl(var(--acid-magenta)))" />
class="absolute inset-x-0 top-0 h-[3px]"
style="background: linear-gradient(90deg, hsl(var(--acid-lime)), hsl(var(--acid-cyan)) 38%, hsl(var(--acid-violet)) 70%, hsl(var(--acid-magenta)))"
/>
<div class="mx-auto flex h-12 max-w-[1400px] items-center gap-4 px-4 sm:px-6"> <div class="mx-auto flex h-12 max-w-[1400px] items-center gap-4 px-4 sm:px-6">
<Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页"> <Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页">
<span class="size-2 shrink-0 bg-acid-lime pulse-acid" /> <span class="size-2 shrink-0 bg-acid-lime pulse-acid" />
<span class="display text-base whitespace-nowrap">LIVE·SFU</span> <span class="display text-base whitespace-nowrap">SYNC·LIVE</span>
<span class="micro-label hidden text-muted-foreground md:inline">fan-out console</span> <span class="micro-label hidden text-muted-foreground md:inline">fan-out console</span>
</Link> </Link>
@ -79,12 +96,17 @@ export const Layout: Component = () => {
<div class="ml-auto flex shrink-0 items-center gap-2 md:ml-0"> <div class="ml-auto flex shrink-0 items-center gap-2 md:ml-0">
<Show when={permissions.isAuthed()}> <Show when={permissions.isAuthed()}>
<span class="hidden items-center gap-1.5 border border-line bg-card/60 px-2 py-1 sm:flex"> <span class={`hidden items-center gap-1.5 border px-2 py-1 sm:flex ${roleColor()}`}>
<span class="size-1.5 bg-acid-cyan" /> <span class={`size-1.5 ${isBanned() ? 'bg-destructive' : permissions.isRoot() ? 'bg-amber-500' : 'bg-acid-cyan'}`} />
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-muted-foreground"> <span class="font-mono text-[10px] tracking-[0.14em] uppercase">
{permissions.role()} {permissions.role()}
<Show when={isBanned()}> · banned</Show>
<Show when={permissions.isRoot()}> · root</Show>
</span> </span>
</span> </span>
<Show when={auth.user()}>
<span class="hidden font-mono text-xs text-muted-foreground sm:inline">@{auth.user()?.username}</span>
</Show>
</Show> </Show>
<ThemeToggle /> <ThemeToggle />
<Show when={!permissions.isAuthed()}> <Show when={!permissions.isAuthed()}>
@ -92,6 +114,11 @@ export const Layout: Component = () => {
登录 登录
</Link> </Link>
</Show> </Show>
<Show when={permissions.isAuthed()}>
<Link to="/login" class="hidden text-xs text-muted-foreground hover:text-foreground sm:inline">
切换
</Link>
</Show>
</div> </div>
</div> </div>
@ -109,7 +136,7 @@ export const Layout: Component = () => {
<footer class="relative z-10 mt-16 border-t border-line/80 bg-background/70 backdrop-blur-sm"> <footer class="relative z-10 mt-16 border-t border-line/80 bg-background/70 backdrop-blur-sm">
<div class="mx-auto flex max-w-[1400px] flex-col gap-1.5 px-4 py-4 font-mono text-[10px] tracking-[0.18em] uppercase text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6"> <div class="mx-auto flex max-w-[1400px] flex-col gap-1.5 px-4 py-4 font-mono text-[10px] tracking-[0.18em] uppercase text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS / FLV</span> <span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS / FLV</span>
<span>live-sfu fan-out console — 一路推流 多端扇出</span> <span>SyncLive · 对标 SyncTV — 一路推流 多端同步</span>
</div> </div>
</footer> </footer>
</div> </div>

View File

@ -1,145 +1,189 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, Show, createEffect } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router' import { useNavigate } from '@tanstack/solid-router'
import { LogIn, RadioTower } from 'lucide-solid' import { LogIn, UserPlus, RadioTower, ShieldCheck, Ban, LogOut } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { refreshMe } from '../../lib/auth' import { auth, refreshMe } from '../../lib/auth'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Button, Corners, Reveal, Select, TextInput } from '../../components/ui' import { Alert, Button, Corners, Reveal, Select, TextInput, Badge } from '../../components/ui'
const DEMO_USERS = [
{ name: 'admin', role: '管理员', pass: 'Admin123!' },
{ name: 'publisher', role: '发布者', pass: 'Publisher123!' },
{ name: 'viewer', role: '观看者', pass: 'Viewer123!' },
] as const
export const Login: Component = () => { export const Login: Component = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [mode, setMode] = createSignal<'login' | 'register'>('login')
const [username, setUsername] = createSignal('') const [username, setUsername] = createSignal('')
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [role, setRole] = createSignal<'viewer' | 'publisher'>('viewer') const [role, setRole] = createSignal<'viewer' | 'publisher' | 'admin'>('viewer')
const [err, setErr] = createSignal('') const [err, setErr] = createSignal('')
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const isRegister = () => mode() === 'register'
const canRegisterAdmin = () => permissions.isAdminOrAbove()
createEffect(() => {
// if already authed, prefill?
if (auth.user() && !auth.user()?.username) refreshMe()
})
const submit = async (e: Event) => { const submit = async (e: Event) => {
e.preventDefault() e.preventDefault()
setErr('') setErr('')
if (!username().trim() || !password().trim()) {
setErr('用户名与密码必填')
return
}
setBusy(true) setBusy(true)
try { try {
await api.login(username(), password()) if (isRegister()) {
const r = role()
const allowed = canRegisterAdmin() ? r : 'viewer'
if (r !== allowed) setRole('viewer')
await api.register(username().trim(), password(), allowed as any)
} else {
await api.login(username().trim(), password())
}
await refreshMe() await refreshMe()
await permissions.refresh() await permissions.refresh()
// check banned
if (permissions.isBanned() || auth.status() === 'banned') {
setErr('账号已被封禁,请联系管理员解封')
return
}
navigate({ to: '/' }) navigate({ to: '/' })
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message || e)) const msg = String(e?.message || e)
if (msg.toLowerCase().includes('banned') || msg.includes('封禁')) {
setErr('账号已被封禁,无法登录')
} else if (msg.includes('invalid credentials') || msg.includes('login failed')) {
setErr('用户名或密码错误')
} else {
setErr(msg)
}
} finally { } finally {
setBusy(false) setBusy(false)
} }
} }
const doLogout = async () => {
try { await api.logout() } catch {}
auth.setUser(null)
permissions.setCheck(null)
await refreshMe()
await permissions.refresh()
}
return ( return (
<div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10"> <div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10">
<div <div class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none" aria-hidden="true">
class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none" SYNC
aria-hidden="true"
>
SFU
</div> </div>
<div class="relative w-full max-w-md"> <div class="relative w-full max-w-md">
<Reveal> <Reveal>
<div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8"> <div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8">
<Corners /> <Corners />
<div class="flex items-center gap-3"> <div class="flex items-start justify-between gap-3">
<span class="flex size-10 shrink-0 items-center justify-center border border-acid-lime/70 bg-acid-lime/15 text-acid-lime"> <div class="flex items-center gap-3">
<RadioTower class="size-5" /> <span class="flex size-10 shrink-0 items-center justify-center border border-acid-lime/70 bg-acid-lime/15 text-acid-lime">
</span> <RadioTower class="size-5" />
<div> </span>
<h1 class="display text-3xl">登录</h1> <div>
<p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p> <h1 class="display text-3xl">{isRegister() ? '注册' : '登录'}</h1>
<p class="micro-label mt-1.5 text-muted-foreground">SyncLive / auth · 双令牌 + Casbin</p>
</div>
</div> </div>
<Show when={permissions.isAuthed()}>
<Badge variant="outline" class="gap-1">
<ShieldCheck class="size-3" />
{auth.user()?.username} · {permissions.role()}
</Badge>
</Show>
</div> </div>
<form class="mt-7 space-y-5" onSubmit={submit}> <Show when={permissions.isAuthed()}>
<Alert tone="success" class="mt-4 flex items-center justify-between gap-3">
<span class="text-xs">已登录为 {auth.user()?.username}({permissions.role()})</span>
<Button variant="ghost" class="h-7 text-xs" onClick={doLogout}>
<LogOut class="size-3" />
退出
</Button>
</Alert>
</Show>
<div class="mt-5 flex gap-2 border-b border-line">
<button class={`micro-label h-8 border-b-2 px-3 transition-colors ${!isRegister() ? 'border-acid-lime text-foreground' : 'border-transparent text-muted-foreground'}`} onClick={() => setMode('login')}>登录</button>
<button class={`micro-label h-8 border-b-2 px-3 transition-colors ${isRegister() ? 'border-acid-lime text-foreground' : 'border-transparent text-muted-foreground'}`} onClick={() => setMode('register')}>注册</button>
</div>
<form class="mt-5 space-y-5" onSubmit={submit}>
<div class="space-y-2"> <div class="space-y-2">
<label class="micro-label flex items-center justify-between text-muted-foreground" for="login-username"> <label class="micro-label flex items-center justify-between text-muted-foreground" for="login-username">
用户名 <span class="opacity-50">username</span> 用户名 <span class="opacity-50">username</span>
</label> </label>
<TextInput <TextInput id="login-username" value={username()} onInput={setUsername} placeholder="admin" autoComplete="username" class="h-10" />
id="login-username"
value={username()}
onInput={setUsername}
placeholder="admin"
autoComplete="username"
class="h-10"
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="micro-label flex items-center justify-between text-muted-foreground" for="login-password"> <label class="micro-label flex items-center justify-between text-muted-foreground" for="login-password">
密码 <span class="opacity-50">password</span> 密码 <span class="opacity-50">password · ≥6 位</span>
</label> </label>
<TextInput <TextInput id="login-password" type="password" value={password()} onInput={setPassword} placeholder="••••••••" autoComplete={isRegister() ? 'new-password' : 'current-password'} class="h-10" />
id="login-password"
type="password"
value={password()}
onInput={setPassword}
placeholder="••••••••"
autoComplete="current-password"
class="h-10"
/>
</div>
<div class="space-y-2">
<label class="micro-label flex items-center justify-between text-muted-foreground">
角色 <span class="opacity-50">role</span>
</label>
<Select
value={role()}
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
options={[
{ value: 'viewer', label: '观看者 viewer' },
{ value: 'publisher', label: '发布者 publisher' },
]}
class="w-full"
/>
</div> </div>
<Show when={isRegister()}>
<div class="space-y-2">
<label class="micro-label flex items-center justify-between text-muted-foreground">
角色 <span class="opacity-50">role</span>
<Show when={!canRegisterAdmin()}>
<span class="text-[10px] text-warning">游客仅可注册观看者</span>
</Show>
</label>
<Select
value={role()}
onChange={(v) => setRole(v as any)}
options={
canRegisterAdmin()
? [
{ value: 'viewer', label: '观看者 viewer' },
{ value: 'publisher', label: '发布者 publisher' },
{ value: 'admin', label: '管理员 admin' },
]
: [{ value: 'viewer', label: '观看者 viewer' }]
}
class="w-full"
/>
<p class="text-[11px] leading-relaxed text-muted-foreground">
{canRegisterAdmin()
? '管理员可直接创建 publisher/admin 账号;普通游客仅可注册 viewer。root 不可通过注册获得。'
: '注册即得 viewer;如需发布权限,请联系管理员分配。'}
</p>
</div>
</Show>
<Button type="submit" disabled={busy()} class="h-10 w-full"> <Button type="submit" disabled={busy()} class="h-10 w-full">
<LogIn class="size-4" /> <Show when={isRegister()} fallback={<LogIn class="size-4" />}>
{busy() ? '登录中…' : '登录'} <UserPlus class="size-4" />
</Show>
{busy() ? (isRegister() ? '注册中…' : '登录中…') : isRegister() ? '注册' : '登录'}
</Button> </Button>
<Show when={!isRegister()}>
<p class="text-center text-xs text-muted-foreground">
首次部署?配置 <code class="font-mono">BOOTSTRAP_ADMIN_USER/PASS</code> 自动创建超管,或通过注册创建 viewer 后由超管提权。
</p>
</Show>
</form> </form>
<Show when={err()}> <Show when={err()}>
<Alert tone="error" class="mt-4"> <Alert tone={err().includes('封禁') ? 'warning' : 'error'} class="mt-4 flex items-start gap-2">
{err()} <Show when={err().includes('封禁')} fallback={<ShieldCheck class="size-4 shrink-0" />}>
<Ban class="size-4 shrink-0" />
</Show>
<span class="text-sm">{err()}</span>
</Alert> </Alert>
</Show> </Show>
</div>
</Reveal>
<Reveal delay={1} class="mt-4"> <Show when={permissions.isBanned()}>
<div class="border border-line/80 bg-card/50 p-4"> <Alert tone="error" class="mt-4">
<p class="micro-label text-muted-foreground">demo accounts · 演示账号</p> <span class="flex items-center gap-2 text-xs">
<div class="mt-3 grid gap-2 sm:grid-cols-3"> <Ban class="size-4" />
<For each={DEMO_USERS}> 当前账号状态为 banned,登录后部分接口将返回 403,刷新令牌亦会失效。
{(u) => ( </span>
<button </Alert>
type="button" </Show>
class="group border border-line/80 bg-background/50 px-3 py-2.5 text-left transition-colors hover:border-acid-lime/60 hover:bg-background"
onClick={() => {
setUsername(u.name)
setPassword(u.pass)
setRole(u.role === '管理员' ? 'publisher' : 'viewer')
}}
>
<span class="flex items-baseline justify-between gap-2">
<span class="font-mono text-xs font-medium">{u.name}</span>
<span class="size-1 shrink-0 rotate-45 bg-line transition-colors group-hover:bg-acid-lime" />
</span>
<span class="mt-0.5 block text-[11px] text-muted-foreground">{u.role}</span>
<span class="mt-1 block font-mono text-[10px] text-muted-foreground/70">{u.pass}</span>
</button>
)}
</For>
</div>
</div> </div>
</Reveal> </Reveal>
</div> </div>

View File

@ -0,0 +1,919 @@
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
import {
Users as UsersIcon,
ShieldCheck,
Layers,
Settings,
KeyRound,
Plus,
Trash2,
ShieldAlert,
RadioTower,
Eye,
EyeOff,
Crown,
UserRound,
MessageSquare,
Video,
Lock,
Globe,
Hash,
Save,
RefreshCw,
LogOut,
Ban,
SlidersHorizontal,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { auth } from '../../lib/auth'
import { permissions } from '../../lib/permissions'
import {
Alert,
Badge,
Button,
Corners,
LiveDot,
Reveal,
Select,
Segmented,
} from '../../components/ui'
type Tab = 'overview' | 'rooms' | 'permissions' | 'users' | 'system'
const ALL_PERMS = [
'send_chat',
'view_chat_history',
'view_members',
'subscribe',
'watch_room',
'publish',
'stop_stream',
'broadcast',
'manage_members',
'add_members',
'remove_members',
'manage_permissions',
'manage_room',
'delete_room',
'delete_chat_messages',
'control_stream',
] as const
const PERM_LABEL: Record<string, string> = {
send_chat: '发弹幕',
view_chat_history: '看记录',
view_members: '看成员',
subscribe: '拉流',
watch_room: '监听',
publish: '推流',
stop_stream: '停播',
broadcast: '广播',
manage_members: '管成员',
add_members: '邀成员',
remove_members: '踢人',
manage_permissions: '管权限',
manage_room: '管房间',
delete_room: '删房间',
delete_chat_messages: '删消息',
control_stream: '控流',
}
const ROLE_META: Record<string, { label: string; color: string }> = {
creator: { label: '房主', color: 'border-amber-400/40 bg-amber-400/10 text-amber-600' },
admin: { label: '管理员', color: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta' },
member: { label: '成员', color: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan' },
guest: { label: '访客', color: 'border-line bg-muted text-muted-foreground' },
}
function StatCard(props: { label: string; value: string | number; hint?: string; icon: any }) {
const Icon = props.icon
return (
<div class="relative border border-line bg-card/60 p-4">
<Corners />
<div class="flex items-start justify-between gap-3">
<div>
<p class="micro-label text-muted-foreground">{props.label}</p>
<p class="display mt-2 text-3xl">{props.value}</p>
<Show when={props.hint}>
<p class="mt-1 text-xs text-muted-foreground">{props.hint}</p>
</Show>
</div>
<span class="flex size-8 items-center justify-center border border-line bg-background text-muted-foreground">
<Icon class="size-4" />
</span>
</div>
</div>
)
}
export const Manage: Component = () => {
const [tab, setTab] = createSignal<Tab>('overview')
const [overview, setOverview] = createSignal<any>(null)
const [rooms, setRooms] = createSignal<any[]>([])
const [permDefs, setPermDefs] = createSignal<any>(null)
const [selectedRoom, setSelectedRoom] = createSignal<string>('')
const [members, setMembers] = createSignal<any[]>([])
const [users, setUsers] = createSignal<any[]>([])
const [err, setErr] = createSignal('')
const [loading, setLoading] = createSignal(false)
// rooms create form
const [newRoomName, setNewRoomName] = createSignal('')
const [newRoomDesc, setNewRoomDesc] = createSignal('')
const [newRoomPublic, setNewRoomPublic] = createSignal(true)
const [newRoomMax, setNewRoomMax] = createSignal('100')
const [newRoomPwd, setNewRoomPwd] = createSignal('')
const [showPwd, setShowPwd] = createSignal(false)
// room detail edit
const [editingRoom, setEditingRoom] = createSignal<any>(null)
const [editDesc, setEditDesc] = createSignal('')
const [editMax, setEditMax] = createSignal('')
// settings panel per room
const [roomSettings, setRoomSettings] = createSignal<any>(null)
const refreshOverview = async () => {
try {
const o = await api.manageOverview()
setOverview(o)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const refreshRooms = async () => {
try {
const r = await api.manageRooms()
setRooms(r.rooms || [])
if (!selectedRoom() && r.rooms?.length) setSelectedRoom(r.rooms[0].room.name)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const refreshPerms = async () => {
try {
const p = await api.managePermMatrix()
setPermDefs(p)
} catch {}
}
const refreshUsers = async () => {
try {
const u = await api.listUsers()
setUsers(u.users || [])
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const refreshMembers = async (room: string) => {
if (!room) return
try {
const m = await api.manageMembers(room)
setMembers(m.members || [])
const det = await api.manageRoomDetail(room)
setEditingRoom(det.room)
setEditDesc(det.room.description || '')
setEditMax(String(det.room.max_members || 100))
setRoomSettings(det.room.settings || null)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
createEffect(() => {
refreshOverview()
refreshRooms()
refreshPerms()
refreshUsers()
})
createEffect(() => {
const r = selectedRoom()
if (r && (tab() === 'rooms' || tab() === 'permissions')) refreshMembers(r)
})
const createRoom = async () => {
const name = newRoomName().trim()
if (!name) return
setLoading(true)
try {
await api.manageCreateRoom({
name,
description: newRoomDesc().trim() || undefined,
is_public: newRoomPublic(),
max_members: parseInt(newRoomMax()) || 100,
password: newRoomPwd().trim() || undefined,
})
setNewRoomName('')
setNewRoomDesc('')
setNewRoomPwd('')
await refreshRooms()
await refreshOverview()
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setLoading(false)
}
}
const saveRoom = async () => {
const r = selectedRoom()
if (!r || !editingRoom()) return
try {
await api.manageUpdateRoom(r, {
description: editDesc(),
max_members: parseInt(editMax()) || undefined,
is_public: editingRoom().is_public,
})
await refreshRooms()
await refreshMembers(r)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const delRoom = async (name: string) => {
if (!confirm(`确定删除房间 ${name} ?该操作不可恢复。`)) return
try {
await api.manageDeleteRoom(name)
await refreshRooms()
await refreshOverview()
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const changeRole = async (room: string, username: string, role: string) => {
try {
await api.manageUpdateMemberRole(room, username, role)
await refreshMembers(room)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const kick = async (room: string, username: string) => {
if (!confirm(`踢出 ${username} ?`)) return
try {
await api.manageKickMember(room, username)
await refreshMembers(room)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const togglePerm = async (room: string, user: string, perm: string, had: boolean) => {
const bit = 1 << ALL_PERMS.indexOf(perm as any)
// simple: if had then move to removed, else to added
// For demo we recompute added/removed based on member's current
const m = members().find((x) => x.username === user)
if (!m) return
let added = Number(m.added || 0)
let removed = Number(m.removed || 0)
if (had) {
added &= ~bit
removed |= bit
} else {
added |= bit
removed &= ~bit
}
try {
await api.manageUpdateMemberPerms(room, user, added, removed, false)
await refreshMembers(room)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
const saveSettings = async () => {
const r = selectedRoom()
if (!r || !roomSettings()) return
try {
await api.manageUpdateRoomSettings(r, roomSettings())
await refreshMembers(r)
} catch (e: any) {
setErr(String(e?.message || e))
}
}
return (
<div class="mx-auto max-w-[1400px] space-y-6 px-4 py-8 sm:px-6 sm:py-10">
<Reveal>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="mono-label text-muted-foreground">manage / console</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">管理控制台</h1>
<p class="mt-3 max-w-2xl text-sm text-muted-foreground">
统一管理房间、角色与权限。参考 <span class="font-mono text-acid-cyan">SyncTV</span> 的位掩码与 Allow/Deny 覆盖模型,
房主拥有全部权限,管理员/成员/访客基于角色默认值 + 个人覆盖计算生效权限。
</p>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline" class="gap-1.5">
<ShieldCheck class="size-3.5" />
{auth.user()?.username || 'guest'} · {permissions.role()}
</Badge>
<Button variant="ghost" class="h-9 gap-1.5" onClick={() => { refreshOverview(); refreshRooms(); refreshUsers(); refreshPerms(); if (selectedRoom()) refreshMembers(selectedRoom()) }}>
<RefreshCw class="size-4" />
刷新
</Button>
</div>
</div>
</Reveal>
<Show when={err()}>
<Alert tone="error" class="flex items-center justify-between gap-3">
<span class="flex items-center gap-2">
<ShieldAlert class="size-4" />
{err()}
</span>
<button class="text-xs underline" onClick={() => setErr('')}>
关闭
</button>
</Alert>
</Show>
<Reveal delay={1}>
<Segmented
value={tab()}
onChange={setTab as any}
options={[
{ value: 'overview', label: '概览', icon: <Layers class="size-3.5" /> },
{ value: 'rooms', label: '房间', icon: <RadioTower class="size-3.5" /> },
{ value: 'permissions', label: '权限', icon: <KeyRound class="size-3.5" /> },
{ value: 'users', label: '用户', icon: <UsersIcon class="size-3.5" /> },
{ value: 'system', label: '系统', icon: <Settings class="size-3.5" /> },
]}
/>
</Reveal>
{/* Overview */}
<Show when={tab() === 'overview'}>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="房间总数" value={overview()?.rooms_total ?? '-'} hint={`${overview()?.rooms_live ?? 0} 个正在直播`} icon={Layers} />
<StatCard label="在线房间" value={overview()?.rooms_live ?? '-'} hint="有推流目标的房间" icon={Video} />
<StatCard label="房间成员" value={overview()?.members_total ?? '-'} hint="所有房间成员汇总" icon={UsersIcon} />
<StatCard label="系统用户" value={overview()?.users_total ?? '-'} hint="已注册账号" icon={ShieldCheck} />
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="border border-line bg-card/50 p-4 lg:col-span-2">
<div class="flex items-center justify-between gap-3">
<h3 class="font-semibold">房间分发状态</h3>
<span class="mono-label text-muted-foreground">{rooms().length} rooms</span>
</div>
<div class="mt-4 space-y-2">
<For each={rooms().slice(0, 6)}>
{(r) => (
<div class="flex items-center justify-between gap-3 border border-line/60 bg-background/50 px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2.5">
<LiveDot active={r.live} />
<span class="truncate font-mono text-sm">{r.room.name}</span>
<Badge variant={r.live ? 'success' : 'muted'} class="hidden sm:inline-flex">
{r.live ? 'LIVE' : 'IDLE'}
</Badge>
</div>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span class="hidden sm:inline">{r.member_count} 成员</span>
<span>{r.targets?.length ?? 0} targets</span>
</div>
</div>
)}
</For>
<Show when={rooms().length === 0}>
<p class="py-6 text-center text-sm text-muted-foreground">暂无房间数据</p>
</Show>
</div>
</div>
<div class="border border-line bg-card/50 p-4">
<h3 class="font-semibold">后端能力</h3>
<div class="mt-4 space-y-2">
<For each={overview()?.backends || []}>
{(b: any) => (
<div class="flex items-center justify-between border border-line/60 px-3 py-2.5">
<span class="font-mono text-xs uppercase">{b.name || b.kind}</span>
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
</div>
)}
</For>
</div>
<div class="mt-6 rounded border border-acid-lime/20 bg-acid-lime/5 p-3">
<p class="micro-label text-muted-foreground">权限模型</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
角色默认值来自 <span class="font-mono text-acid-cyan">RoomSettings</span>,个人覆盖分
<span class="font-mono text-foreground"> added / removed </span>
且访客仅可分配 Guest 可用位。生效权限 = base | added & ~removed。
</p>
</div>
</div>
</div>
</Show>
{/* Rooms */}
<Show when={tab() === 'rooms'}>
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-1">
<div class="relative border border-line bg-card/60 p-4">
<Corners />
<h3 class="font-semibold">创建房间</h3>
<p class="mt-1 text-xs text-muted-foreground">房主将自动成为 Creator,拥有全部权限。</p>
<div class="mt-4 space-y-3">
<div class="space-y-1.5">
<label class="label">房间名 *</label>
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
<Hash class="size-4 shrink-0 text-muted-foreground" />
<input
class="w-full bg-transparent font-mono text-sm outline-none placeholder:text-muted-foreground/50"
placeholder="如 live-101"
value={newRoomName()}
onInput={(e) => setNewRoomName((e.currentTarget as HTMLInputElement).value)}
/>
</div>
</div>
<div class="space-y-1.5">
<label class="label">描述</label>
<input
class="field"
placeholder="一句话介绍"
value={newRoomDesc()}
onInput={(e) => setNewRoomDesc((e.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="label">可见性</label>
<Select
value={newRoomPublic() ? 'public' : 'private'}
onChange={(v) => setNewRoomPublic(v === 'public')}
options={[
{ value: 'public', label: '公开' },
{ value: 'private', label: '私密' },
]}
/>
</div>
<div class="space-y-1.5">
<label class="label">人数上限</label>
<input class="field field-mono" value={newRoomMax()} onInput={(e) => setNewRoomMax((e.currentTarget as HTMLInputElement).value)} />
</div>
</div>
<div class="space-y-1.5">
<label class="label">房间密码(可选)</label>
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
<Lock class="size-4 shrink-0 text-muted-foreground" />
<input
class="w-full bg-transparent font-mono text-sm outline-none"
type={showPwd() ? 'text' : 'password'}
placeholder="留空为无密码"
value={newRoomPwd()}
onInput={(e) => setNewRoomPwd((e.currentTarget as HTMLInputElement).value)}
/>
<button type="button" class="text-muted-foreground" onClick={() => setShowPwd((v) => !v)}>
<Show when={showPwd()} fallback={<Eye class="size-4" />}>
<EyeOff class="size-4" />
</Show>
</button>
</div>
</div>
<Button class="w-full" onClick={createRoom} disabled={loading() || !newRoomName().trim()}>
<Plus class="size-4" />
创建房间
</Button>
</div>
</div>
<div class="mt-4 border border-line bg-card/50">
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
<h3 class="font-semibold">房间列表</h3>
<span class="mono-label text-muted-foreground">{rooms().length}</span>
</div>
<div class="max-h-[480px] overflow-auto">
<For each={rooms()}>
{(r) => (
<button
type="button"
class={`flex w-full items-center justify-between gap-3 border-b border-line/60 px-4 py-3 text-left transition-colors last:border-0 hover:bg-accent/40 ${selectedRoom() === r.room.name ? 'bg-acid-lime/10 dark:bg-primary/10' : ''}`}
onClick={() => setSelectedRoom(r.room.name)}
>
<div class="min-w-0">
<p class="truncate font-mono text-sm font-medium">{r.room.name}</p>
<p class="truncate text-xs text-muted-foreground">{r.room.description || '—'}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<LiveDot active={r.live} />
<span class="micro-label text-muted-foreground">{r.member_count} 成员</span>
</div>
</button>
)}
</For>
<Show when={rooms().length === 0}>
<p class="px-4 py-8 text-center text-sm text-muted-foreground">暂无房间</p>
</Show>
</div>
</div>
</div>
<div class="space-y-4 lg:col-span-2">
<Show when={selectedRoom()} fallback={<div class="border border-dashed border-line p-8 text-center text-sm text-muted-foreground">选择一个房间以管理</div>}>
<div class="relative border border-line bg-card/60 p-4">
<Corners />
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="mono-label text-muted-foreground">room / {selectedRoom()}</p>
<h3 class="display mt-1 truncate text-2xl">{editingRoom()?.display_name || selectedRoom()}</h3>
<p class="mt-1 text-xs text-muted-foreground">创建者 {editingRoom()?.creator || '—'} · {new Date((editingRoom()?.created_at || 0) * 1000).toLocaleString() || ''}</p>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant={editingRoom()?.is_public ? 'success' : 'warning'} class="gap-1">
<Show when={editingRoom()?.is_public} fallback={<Lock class="size-3" />}>
<Globe class="size-3" />
</Show>
{editingRoom()?.is_public ? '公开' : '私密'}
</Badge>
<Badge variant="outline">{editingRoom()?.status || 'active'}</Badge>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<label class="label">描述</label>
<input class="field" value={editDesc()} onInput={(e) => setEditDesc((e.currentTarget as HTMLInputElement).value)} placeholder="房间描述" />
</div>
<div class="space-y-1.5">
<label class="label">人数上限</label>
<input class="field field-mono" value={editMax()} onInput={(e) => setEditMax((e.currentTarget as HTMLInputElement).value)} />
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button class="gap-1.5" onClick={saveRoom}>
<Save class="size-4" />
保存
</Button>
<Button variant="danger" class="gap-1.5" onClick={() => delRoom(selectedRoom())}>
<Trash2 class="size-4" />
删除房间
</Button>
</div>
</div>
<div class="border border-line bg-card/50">
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
<h4 class="flex items-center gap-2 font-semibold">
<UsersIcon class="size-4" />
成员 {members().length}
</h4>
<span class="micro-label text-muted-foreground">点击角色可切换 · 权限可在“权限”页批量编辑</span>
</div>
<div class="divide-y divide-line/60">
<For each={members()}>
{(m) => (
<div class="flex flex-wrap items-center gap-3 px-4 py-3">
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[m.role]?.color || ''}`}>
{(m.username[0] || '?').toUpperCase()}
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm font-medium">{m.username}</p>
<p class="micro-label truncate text-muted-foreground">{m.effective_permissions?.join(', ') || ''}</p>
</div>
<Select
class="w-28"
value={m.role}
onChange={(v) => changeRole(selectedRoom(), m.username, v)}
options={[
{ value: 'creator', label: '房主' },
{ value: 'admin', label: '管理员' },
{ value: 'member', label: '成员' },
{ value: 'guest', label: '访客' },
]}
/>
<Badge class={ROLE_META[m.role]?.color}>{ROLE_META[m.role]?.label || m.role}</Badge>
<Show when={m.role !== 'creator'}>
<Button variant="ghost" class="h-8 px-2 text-destructive hover:text-destructive" onClick={() => kick(selectedRoom(), m.username)}>
<Ban class="size-4" />
</Button>
</Show>
</div>
)}
</For>
<Show when={members().length === 0}>
<p class="px-4 py-6 text-center text-sm text-muted-foreground">暂无成员</p>
</Show>
</div>
</div>
<div class="border border-line bg-card/50 p-4">
<h4 class="flex items-center gap-2 font-semibold">
<SlidersHorizontal class="size-4" />
房间设置
</h4>
<Show when={roomSettings()} fallback={<p class="mt-3 text-sm text-muted-foreground">加载中…</p>}>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
<span class="flex items-center gap-2 text-sm">
<MessageSquare class="size-4 text-muted-foreground" />
聊天启用
</span>
<input
type="checkbox"
checked={!!roomSettings()?.chat_enabled}
onInput={(e) => setRoomSettings({ ...roomSettings(), chat_enabled: (e.currentTarget as HTMLInputElement).checked })}
/>
</label>
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
<span class="flex items-center gap-2 text-sm">
<UserRound class="size-4 text-muted-foreground" />
允许访客加入
</span>
<input
type="checkbox"
checked={!!roomSettings()?.allow_guest_join}
onInput={(e) => setRoomSettings({ ...roomSettings(), allow_guest_join: (e.currentTarget as HTMLInputElement).checked })}
/>
</label>
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
<span class="flex items-center gap-2 text-sm">
<ShieldAlert class="size-4 text-muted-foreground" />
需审批入房
</span>
<input
type="checkbox"
checked={!!roomSettings()?.require_approval}
onInput={(e) => setRoomSettings({ ...roomSettings(), require_approval: (e.currentTarget as HTMLInputElement).checked })}
/>
</label>
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
<span class="flex items-center gap-2 text-sm">
<Globe class="size-4 text-muted-foreground" />
公开可见
</span>
<input
type="checkbox"
checked={!!roomSettings()?.is_public}
onInput={(e) => setRoomSettings({ ...roomSettings(), is_public: (e.currentTarget as HTMLInputElement).checked })}
/>
</label>
</div>
<Button class="mt-4 gap-1.5" onClick={saveSettings}>
<Save class="size-4" />
保存设置
</Button>
</Show>
</div>
</Show>
</div>
</div>
</Show>
{/* Permissions */}
<Show when={tab() === 'permissions'}>
<div class="space-y-4">
<div class="border border-line bg-card/60 p-4">
<h3 class="font-semibold">角色默认权限(参考 SyncTV)</h3>
<p class="mt-1 text-xs text-muted-foreground">Creator = 全部 · Admin 缺 delete_room · Member 为发布订阅开箱即用 · Guest 仅只读,额外仅可被授予 send_chat。</p>
<div class="mt-4 overflow-auto">
<table class="w-full min-w-[720px] border-collapse text-xs">
<thead>
<tr class="border-b border-line">
<th class="px-2 py-2 text-left font-mono text-muted-foreground">permission</th>
<For each={['creator', 'admin', 'member', 'guest']}>
{(role) => <th class="px-2 py-2 text-center font-mono text-muted-foreground">{role}</th>}
</For>
</tr>
</thead>
<tbody>
<For each={ALL_PERMS}>
{(perm) => (
<tr class="border-b border-line/60">
<td class="px-2 py-2 font-mono">{PERM_LABEL[perm] || perm}</td>
<For each={['creator', 'admin', 'member', 'guest']}>
{(role) => {
const has = permDefs()?.roles?.[role]?.perms?.includes(perm)
return <td class="px-2 py-2 text-center">{has ? <span class="inline-flex size-2 bg-acid-lime" /> : <span class="inline-flex size-2 border border-line" />}</td>
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
<div class="border border-line bg-card/50">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line/80 px-4 py-3">
<h3 class="font-semibold">成员生效权限 · {selectedRoom() || '未选择房间'}</h3>
<Select
value={selectedRoom()}
onChange={setSelectedRoom}
options={rooms().map((r) => ({ value: r.room.name, label: r.room.name }))}
class="w-48"
/>
</div>
<Show when={selectedRoom()} fallback={<p class="px-4 py-8 text-center text-sm text-muted-foreground">请选择房间</p>}>
<div class="overflow-auto">
<table class="w-full min-w-[900px] border-collapse">
<thead>
<tr class="border-b border-line bg-muted/20">
<th class="px-3 py-2 text-left text-xs font-mono text-muted-foreground">成员 / 角色</th>
<For each={ALL_PERMS}>
{(perm) => <th class="px-1 py-2 text-center text-[10px] font-mono text-muted-foreground">{PERM_LABEL[perm] || perm}</th>}
</For>
</tr>
</thead>
<tbody>
<For each={members()}>
{(m) => (
<tr class="border-b border-line/40">
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<span class="font-mono text-xs font-medium">{m.username}</span>
<Badge class={`text-[10px] ${ROLE_META[m.role]?.color || ''}`}>{m.role}</Badge>
</div>
</td>
<For each={ALL_PERMS}>
{(perm) => {
const has = m.effective_permissions?.includes(perm)
const isCreator = m.role === 'creator'
return (
<td class="px-1 py-1 text-center">
<button
type="button"
disabled={isCreator}
class={`inline-flex size-6 items-center justify-center border text-[10px] transition-colors ${has ? 'border-acid-lime bg-acid-lime text-black' : 'border-line bg-background text-muted-foreground hover:border-ring'} ${isCreator ? 'opacity-50' : ''}`}
title={`${m.username} · ${perm} · ${has ? '已授予' : '未授予'}`}
onClick={() => togglePerm(selectedRoom(), m.username, perm, !!has)}
>
{has ? '✓' : ''}
</button>
</td>
)
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={members().length === 0}>
<p class="px-4 py-6 text-center text-sm text-muted-foreground">该房间暂无成员</p>
</Show>
</div>
<p class="px-4 py-3 text-xs text-muted-foreground">点击格子切换该成员的 added/removed 覆盖位。房主始终拥有全部权限,不可修改。</p>
</Show>
</div>
</div>
</Show>
{/* Users */}
<Show when={tab() === 'users'}>
<div class="border border-line bg-card/50">
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
<h3 class="flex items-center gap-2 font-semibold">
<UsersIcon class="size-4" />
系统用户与全局角色
</h3>
<span class="mono-label text-muted-foreground">{users().length} users</span>
</div>
<Show when={!permissions.can('user:list')} fallback={
<div>
<div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid">
<span class="micro-label text-muted-foreground">username</span>
<span class="micro-label text-muted-foreground">global role</span>
<span class="micro-label text-right text-muted-foreground">切换角色</span>
</div>
<For each={users()}>
{(u) => (
<div class="grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 border-b border-line/60 last:border-0">
<div class="flex items-center gap-3">
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[u.role]?.color || 'border-line bg-muted'}`}>
{(u.username[0] || '?').toUpperCase()}
</span>
<div class="min-w-0">
<p class="truncate font-mono text-sm font-medium">{u.username}</p>
<p class="micro-label text-muted-foreground">{new Date((u.created_at || 0) * 1000).toLocaleDateString()}</p>
</div>
</div>
<Badge class={ROLE_META[u.role]?.color || ''}>{u.role}</Badge>
<div class="flex justify-end">
<Select
value={u.role}
onChange={async (v) => {
try {
await api.updateUserRole(u.username, v as any)
setUsers((p) => p.map((x) => (x.username === u.username ? { ...x, role: v } : x)))
} catch (e: any) {
setErr(String(e?.message || e))
}
}}
options={[
{ value: 'viewer', label: 'viewer' },
{ value: 'publisher', label: 'publisher' },
{ value: 'admin', label: 'admin' },
]}
/>
</div>
</div>
)}
</For>
<Show when={users().length === 0}>
<p class="px-5 py-8 text-sm text-muted-foreground">暂无用户</p>
</Show>
</div>
}>
<Alert tone="warning" class="m-4">
无权限查看:需要 user:list(管理员)
</Alert>
</Show>
</div>
<Alert tone="info">
<span class="text-xs leading-relaxed">
全局角色(viewer/publisher/admin)由 Casbin 控制接口准入;房间内角色(creator/admin/member/guest)由房间位掩码控制具体动作。二者正交:全局 admin 可管理所有房间,但房间内权限仍以 effective 计算结果为准。
</span>
</Alert>
</Show>
{/* System */}
<Show when={tab() === 'system'}>
<div class="grid gap-4 lg:grid-cols-2">
<div class="relative border border-line bg-card/60 p-4">
<Corners />
<h3 class="flex items-center gap-2 font-semibold">
<Settings class="size-4" />
系统与 SFU
</h3>
<div class="mt-4 space-y-3 text-sm">
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
<span class="text-muted-foreground">HTTP 端口</span>
<span class="font-mono">{overview()?.backends ? '8088' : '-'}</span>
</div>
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
<span class="text-muted-foreground">gRPC</span>
<span class="font-mono">9090</span>
</div>
<For each={overview()?.backends || []}>
{(b: any) => (
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
<span class="flex items-center gap-2">
<RadioTower class="size-4 text-muted-foreground" />
{b.name || b.kind}
</span>
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
</div>
)}
</For>
</div>
</div>
<div class="border border-line bg-card/60 p-4">
<h3 class="flex items-center gap-2 font-semibold">
<KeyRound class="size-4" />
安全与默认策略
</h3>
<div class="mt-4 space-y-2 text-xs leading-relaxed text-muted-foreground">
<p>
<span class="font-mono text-foreground">JWT_SECRET</span> 必须显式配置;推流 token 使用
<span class="font-mono text-foreground"> HS256 </span>
签发,TTL 默认 2h。
</p>
<p>
私密房间支持密码;公开房间受 <span class="font-mono text-foreground">is_public</span> 与{' '}
<span class="font-mono text-foreground">allow_guest_join</span> 联动控制访客可见性。
</p>
<p>
权限存储为 <span class="font-mono text-foreground">u64 bits</span>(兼容 Postgres BIGINT),
访客可分配集合受 <span class="font-mono text-foreground">guest_assignable</span> 约束。
</p>
<div class="mt-3 border border-dashed border-line bg-background px-3 py-3">
<p class="micro-label">设计参考</p>
<p class="mt-1">
本系统在 <code>internal/room</code> 实现 SyncTV 风格的四角色(creator/admin/member/guest)与四列覆盖位
(added/removed/admin_added/admin_removed),并通过 RoomSettings 维护角色默认值,可平滑扩展至更多权限位而不改表结构。
</p>
</div>
</div>
</div>
</div>
<div class="border border-line bg-card/50 p-4">
<h3 class="font-semibold">快捷操作</h3>
<div class="mt-4 flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setTab('rooms')}>
<Plus class="size-4" />
去创建房间
</Button>
<Button variant="outline" onClick={() => setTab('permissions')}>
<KeyRound class="size-4" />
查看权限矩阵
</Button>
<Button variant="ghost" onClick={() => window.open('/api/config', '_blank')}>
<Globe class="size-4" />
查看 /api/config
</Button>
<Button variant="ghost" onClick={() => { localStorage.clear(); location.reload() }}>
<LogOut class="size-4" />
清理本地缓存
</Button>
</div>
</div>
</Show>
</div>
)
}

View File

@ -1,85 +1,248 @@
import { Component, createSignal, createEffect, For, Show } from 'solid-js' import { Component, createSignal, createEffect, For, Show, onMount } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router' import { useNavigate } from '@tanstack/solid-router'
import { ArrowRight, DoorOpen, Plus } from 'lucide-solid' import { ArrowRight, DoorOpen, Plus, Search, Lock, Globe, Users, RadioTower, Crown } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Button, Corners, LiveDot, Reveal } from '../../components/ui' import { Button, Corners, LiveDot, Reveal, Alert, Badge } from '../../components/ui'
import type { Room } from '../../lib/types'
export const Rooms: Component = () => { export const Rooms: Component = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [rooms, setRooms] = createSignal<Room[]>([]) const [rooms, setRooms] = createSignal<any[]>([])
const [total, setTotal] = createSignal(0)
const [page, setPage] = createSignal(1)
const pageSize = 12
const [search, setSearch] = createSignal('')
const [searchInput, setSearchInput] = createSignal('')
const [name, setName] = createSignal('') const [name, setName] = createSignal('')
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [err, setErr] = createSignal('')
const [loading, setLoading] = createSignal(false)
const [desc, setDesc] = createSignal('')
const [isPublic, setIsPublic] = createSignal(true)
const [showAdvanced, setShowAdvanced] = createSignal(false)
const load = () => const load = async () => {
api setLoading(true)
.listRooms() setErr('')
.then((r) => setRooms(r.rooms || [])) try {
.catch(() => {}) if (permissions.isAdminOrAbove()) {
try {
const r = await api.manageRooms()
let list = (r.rooms || []).map((x: any) => ({
name: x.room.name,
display_name: x.room.display_name,
description: x.room.description,
creator: x.room.creator,
is_public: x.room.is_public,
status: x.room.status,
member_count: x.member_count,
targets: x.targets,
live: x.live,
created_at: x.room.created_at,
}))
const s = search().trim().toLowerCase()
if (s) list = list.filter((x: any) => x.name.toLowerCase().includes(s) || (x.display_name && x.display_name.toLowerCase().includes(s)))
setTotal(list.length)
const start = (page() - 1) * pageSize
setRooms(list.slice(start, start + pageSize))
return
} catch {}
}
try {
const q = await api.queryRooms({ page: page(), page_size: pageSize, search: search().trim() || undefined } as any)
const list = (q.rooms || []).map((r: any) => ({
name: r.name,
display_name: r.display_name,
description: r.description,
creator: r.creator,
is_public: r.is_public,
status: r.status,
member_count: r.member_count ?? 0,
targets: [],
live: false,
created_at: r.created_at,
}))
try {
const legacy = await api.listRooms()
const liveMap = new Map((legacy.rooms || []).map((x: any) => [x.name, (x.targets?.length ?? 0) > 0 ? x.targets : []]))
for (const it of list) {
const t = liveMap.get(it.name)
if (t) { it.targets = t; it.live = t.length > 0 }
}
} catch {}
setRooms(list)
setTotal(q.total ?? list.length)
return
} catch {}
const r = await api.listRooms()
let list: any[] = (r.rooms || []).map((x: any) => ({
name: x.name,
display_name: x.name,
description: '',
creator: '',
is_public: true,
status: 'active',
member_count: 0,
targets: x.targets || [],
live: (x.targets?.length ?? 0) > 0,
}))
const s = search().trim().toLowerCase()
if (s) list = list.filter((x: any) => x.name.toLowerCase().includes(s))
setTotal(list.length)
const start = (page() - 1) * pageSize
setRooms(list.slice(start, start + pageSize))
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setLoading(false)
}
}
createEffect(() => { createEffect(() => {
if (permissions.isAuthed()) load() if (permissions.isAuthed()) load()
}) })
createEffect(() => {
page(); search(); permissions.isAuthed() && load()
})
onMount(() => permissions.isAuthed() && load())
const doSearch = () => {
setSearch(searchInput().trim())
setPage(1)
}
const create = async () => { const create = async () => {
const n = name().trim() const n = name().trim()
if (!n) return if (!n) return
setBusy(true) setBusy(true)
setErr('')
try { try {
await api.createRoom(n) if (permissions.isAdminOrAbove() && showAdvanced()) {
await api.manageCreateRoom({
name: n,
display_name: n,
description: desc().trim() || undefined,
is_public: isPublic(),
require_approval: false,
max_members: 100,
})
} else {
try {
await api.manageCreateRoom({ name: n, display_name: n, description: desc().trim() || undefined, is_public: isPublic() })
} catch {
await api.createRoom(n)
}
}
setName('')
setDesc('')
await load()
navigate({ to: '/room', search: { room: n } }) navigate({ to: '/room', search: { room: n } })
} catch (e: any) { } catch (e: any) {
alert(String(e?.message || e)) setErr(String(e?.message || e))
} finally { } finally {
setBusy(false) setBusy(false)
} }
} }
const enter = (room: string) => navigate({ to: '/room', search: { room } }) const enter = (room: string) => navigate({ to: '/room', search: { room } })
const totalPages = () => Math.max(1, Math.ceil(total() / pageSize))
return ( return (
<div class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10"> <div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">
<Reveal> <Reveal>
<div> <div>
<p class="mono-label text-muted-foreground">rooms / registry</p> <p class="mono-label text-muted-foreground">rooms / registry</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1> <h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground"> <p class="mt-3 max-w-xl text-sm text-muted-foreground">
创建或进入房间,房间内可观看、发弹幕与查看分发状态。 创建或进入房间。已接入新分页查询(<code class="font-mono">/api/rooms/query</code>)与管理面创建(<code class="font-mono">/api/manage/rooms</code>),支持搜索、公开度与成员统计。
</p> </p>
</div> </div>
</Reveal> </Reveal>
<Show when={err()}>
<Alert tone="error">{err()}</Alert>
</Show>
<Reveal delay={1}> <Reveal delay={1}>
<div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4"> <div class="relative max-w-3xl border border-line bg-card/60 p-3 sm:p-4">
<Corners /> <Corners />
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-3">
<div class="flex h-10 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring"> <div class="flex flex-col gap-2 sm:flex-row">
<Plus class="size-4 shrink-0 text-muted-foreground" /> <div class="flex h-10 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring">
<input <Plus class="size-4 shrink-0 text-muted-foreground" />
class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50" <input
placeholder="房间名,如 demo" class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
value={name()} placeholder="新房间名,如 live-demo"
onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)} value={name()}
onKeyDown={(e) => { onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)}
if (e.key === 'Enter') create() onKeyDown={(e) => { if (e.key === 'Enter') create() }}
}} aria-label="房间名"
aria-label="房间名" />
/> </div>
<Button class="h-10 shrink-0" onClick={create} disabled={busy() || !name().trim()}>
<ArrowRight class="size-4" />
创建并进入
</Button>
</div> </div>
<Button class="h-10" onClick={create} disabled={busy()}> <Show when={permissions.isAdminOrAbove()}>
<ArrowRight class="size-4" /> <div class="flex flex-wrap items-center gap-3 border-t border-line/50 pt-3">
创建并进入 <label class="flex items-center gap-2 text-xs">
</Button> <input type="checkbox" checked={showAdvanced()} onChange={(e) => setShowAdvanced((e.target as HTMLInputElement).checked)} />
高级
</label>
<Show when={showAdvanced()}>
<input
class="h-8 flex-1 border border-line bg-background px-2 text-xs outline-none"
placeholder="描述(可选)"
value={desc()}
onInput={(e) => setDesc((e.currentTarget as HTMLInputElement).value)}
/>
<label class="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={isPublic()} onChange={(e) => setIsPublic((e.target as HTMLInputElement).checked)} />
{isPublic() ? <Globe class="size-3" /> : <Lock class="size-3" />}
{isPublic() ? '公开' : '私密'}
</label>
</Show>
</div>
</Show>
</div> </div>
</div> </div>
</Reveal> </Reveal>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 items-center gap-2">
<div class="flex h-9 flex-1 max-w-sm items-center gap-2 border border-line bg-background px-3">
<Search class="size-4 text-muted-foreground" />
<input
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
placeholder="搜索房间名 / 展示名"
value={searchInput()}
onInput={(e) => setSearchInput((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') doSearch() }}
/>
</div>
<Button variant="outline" class="h-9" onClick={doSearch}>搜索</Button>
<Show when={search()}>
<Button variant="ghost" class="h-9" onClick={() => { setSearchInput(''); setSearch(''); setPage(1) }}>清除</Button>
</Show>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="font-mono">{total()} rooms · 第 {page()}/{totalPages()} 页</span>
<Button variant="outline" class="h-7 px-2 text-xs" disabled={page() <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>上一页</Button>
<Button variant="outline" class="h-7 px-2 text-xs" disabled={page() >= totalPages()} onClick={() => setPage((p) => p + 1)}>下一页</Button>
</div>
</div>
<section class="space-y-3"> <section class="space-y-3">
<Reveal> <Reveal>
<div class="flex items-center justify-between gap-3 border-b border-line pb-3"> <div class="flex items-center justify-between gap-3 border-b border-line pb-3">
<h2 class="font-semibold">房间列表</h2> <h2 class="flex items-center gap-2 font-semibold">
<span class="mono-label text-muted-foreground">{rooms().length} rooms</span> <RadioTower class="size-4 text-muted-foreground" />
房间列表
</h2>
<Show when={loading()}>
<span class="mono-label animate-pulse text-muted-foreground">loading…</span>
</Show>
</div> </div>
</Reveal> </Reveal>
@ -92,33 +255,53 @@ export const Rooms: Component = () => {
</span> </span>
<div> <div>
<p class="font-medium">暂无房间</p> <p class="font-medium">暂无房间</p>
<p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。</p> <p class="mt-1 text-sm text-muted-foreground">
{search() ? `无匹配 "${search()}" 的房间` : '创建一个房间后,分发拓扑会在这里出现。'}
</p>
</div> </div>
</div> </div>
} }
> >
<div> <div class="space-y-2">
<For each={rooms()}> <For each={rooms()}>
{(r, i) => ( {(r, i) => (
<Reveal delay={Math.min(i() + 1, 5)}> <Reveal delay={Math.min(i() + 1, 6)}>
<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4"> <div class="group relative border border-line/70 bg-card/40 px-4 py-4 transition-colors hover:border-line hover:bg-accent/30 sm:px-5">
<span class="hidden font-mono text-xs text-muted-foreground/70 sm:block"> <Corners />
{String(i() + 1).padStart(2, '0')} <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
</span> <div class="flex min-w-0 flex-1 items-start gap-3">
<LiveDot active={(r.targets?.length ?? 0) > 0} /> <LiveDot active={!!r.live} />
<div class="min-w-0"> <div class="min-w-0 flex-1">
<p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary"> <div class="flex flex-wrap items-center gap-2">
{r.name} <p class="display truncate text-xl sm:text-2xl">{r.display_name || r.name}</p>
</p> <Show when={r.name !== r.display_name}>
<p class="mono-label mt-1 text-muted-foreground"> <span class="font-mono text-xs text-muted-foreground">/{r.name}</span>
{r.targets?.length ?? 0} 路分发 · {(r.targets?.length ?? 0) > 0 ? 'live' : 'idle'} </Show>
</p> <Badge variant={r.live ? 'success' : 'muted'} class="text-[10px]">{r.live ? 'LIVE' : 'IDLE'}</Badge>
</div> <Badge variant="outline" class="gap-1 text-[10px]">
<div class="col-span-2 sm:col-span-1"> {r.is_public === false ? <Lock class="size-3" /> : <Globe class="size-3" />}
<Button variant="outline" class="h-9 w-full sm:w-auto" onClick={() => enter(r.name)}> {r.is_public === false ? '私密' : '公开'}
进入房间 </Badge>
<ArrowRight class="size-4" /> <Show when={r.status && r.status !== 'active'}>
</Button> <Badge variant="outline" class="border-destructive/40 text-destructive text-[10px]">{r.status}</Badge>
</Show>
</div>
<div class="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1"><Crown class="size-3" />{r.creator || '-'}</span>
<span class="flex items-center gap-1"><Users class="size-3" />{r.member_count ?? 0} 成员</span>
<span class="font-mono">{r.targets?.length ?? 0} 路分发</span>
<Show when={r.description}>
<span class="truncate">· {r.description}</span>
</Show>
</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button variant="outline" class="h-9" onClick={() => enter(r.name)}>
进入房间
<ArrowRight class="size-4" />
</Button>
</div>
</div> </div>
</div> </div>
</Reveal> </Reveal>

View File

@ -1,13 +1,17 @@
import { Component, createSignal, For, Show, createEffect } from 'solid-js' import { Component, createSignal, For, Show, createEffect, onMount } from 'solid-js'
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid' import { ShieldAlert, ShieldCheck, UserRound, Ban, Shield } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { auth } from '../../lib/auth'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Badge, Reveal, Select } from '../../components/ui' import { Alert, Badge, Reveal, Select, Button } from '../../components/ui'
import type { Role, UserInfo } from '../../lib/types' import type { Role, UserInfo } from '../../lib/types'
const ROLES: Role[] = ['viewer', 'publisher', 'admin'] const ROLE_META: Record<string, { label: string; badge: string; avatar: string }> = {
root: {
const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }> = { label: '超管',
badge: 'border-amber-500/40 bg-amber-500/10 text-amber-600',
avatar: 'border-amber-500/40 bg-amber-500/10 text-amber-600',
},
admin: { admin: {
label: '管理员', label: '管理员',
badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta', badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
@ -30,46 +34,110 @@ const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }>
}, },
} }
function availableRoles(actorRole: string): Role[] {
if (actorRole === 'root') return ['viewer', 'publisher', 'admin', 'root']
if (actorRole === 'admin') return ['viewer', 'publisher']
return []
}
export const Users: Component = () => { export const Users: Component = () => {
const [users, setUsers] = createSignal<UserInfo[]>([]) const [users, setUsers] = createSignal<UserInfo[]>([])
const [err, setErr] = createSignal('') const [err, setErr] = createSignal('')
const [busy, setBusy] = createSignal<string>('')
createEffect(() => { const load = async () => {
if (!permissions.can('user:list')) return
api
.listUsers()
.then((r) => setUsers(r.users || []))
.catch((e: any) => setErr(String(e?.message || e)))
})
const change = async (username: string, role: Role) => {
try { try {
await api.updateUserRole(username, role) const r = await api.listUsers()
setUsers((p) => p.map((u) => (u.username === username ? { ...u, role } : u))) setUsers(r.users || [])
setErr('')
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message || e)) setErr(String(e?.message || e))
} }
} }
createEffect(() => {
if (permissions.can('user:list')) load()
})
onMount(() => {
if (permissions.isAuthed()) load()
})
const change = async (username: string, role: Role) => {
setBusy(username)
try {
await api.updateUserRole(username, role)
setUsers((p) => p.map((u) => (u.username === username ? { ...u, role } : u)))
setErr('')
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setBusy('')
}
}
const toggleBan = async (u: UserInfo) => {
const isBanned = (u as any).status === 'banned'
if (!confirm(isBanned ? `解封 ${u.username} ?` : `封禁 ${u.username} ? 封禁后该用户将无法登录`)) return
setBusy(u.username)
try {
if (isBanned) await api.unbanUser(u.username)
else await api.banUser(u.username)
await load()
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setBusy('')
}
}
const actorRole = () => permissions.role()
const canBanTarget = (target: UserInfo) => {
const ar = actorRole()
if (ar === 'root') return target.role !== 'root'
if (ar === 'admin') return target.role === 'viewer' || target.role === 'publisher'
return false
}
return ( return (
<div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10"> <div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">
<Reveal> <Reveal>
<div> <div class="flex flex-wrap items-start justify-between gap-4">
<p class="mono-label text-muted-foreground">users / rbac</p> <div>
<h1 class="display mt-2 text-4xl sm:text-5xl">用户与角色</h1> <p class="mono-label text-muted-foreground">users / rbac</p>
<p class="mt-3 max-w-lg text-sm text-muted-foreground"> <h1 class="display mt-2 text-4xl sm:text-5xl">用户与角色</h1>
基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。 <p class="mt-3 max-w-xl text-sm text-muted-foreground">
</p> 基于 Casbin RBAC + 状态管理。对标 SyncTV Root/Admin 分层:仅 Root 可授予 Root;Admin 仅可管理 Viewer/Publisher;封禁用户无法登录与刷新。
</p>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline" class="gap-1">
<Shield class="size-3" />
{auth.user()?.username || 'guest'} · {actorRole()}
</Badge>
<Button variant="outline" class="h-8" onClick={load} disabled={!!busy()}>
刷新
</Button>
</div>
</div> </div>
</Reveal> </Reveal>
<Show when={permissions.isBanned()}>
<Alert tone="error">
<span class="flex items-center gap-2">
<Ban class="size-4" />
当前账号已被封禁,部分操作将受限。请联系超管解封。
</span>
</Alert>
</Show>
<Show <Show
when={permissions.can('user:list')} when={permissions.can('user:list')}
fallback={ fallback={
<Alert tone="warning"> <Alert tone="warning">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<ShieldAlert class="size-4" /> <ShieldAlert class="size-4" />
无权限:需要 user:list 无权限:需要 user:list(管理员及以上)
</span> </span>
</Alert> </Alert>
} }
@ -81,46 +149,74 @@ export const Users: Component = () => {
{err()} {err()}
</Alert> </Alert>
</Show> </Show>
<div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid"> <div class="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_16rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid">
<span class="micro-label text-muted-foreground">username</span> <span class="micro-label text-muted-foreground">username</span>
<span class="micro-label text-muted-foreground">role</span> <span class="micro-label text-muted-foreground">role</span>
<span class="micro-label text-muted-foreground">status</span>
<span class="micro-label text-right text-muted-foreground">action</span> <span class="micro-label text-right text-muted-foreground">action</span>
</div> </div>
<For each={users()}> <For each={users()}>
{(u, i) => ( {(u, i) => {
<div const meta = (ROLE_META as any)[u.role] || ROLE_META.guest
class={`grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 ${ const isBanned = (u as any).status === 'banned'
i() < users().length - 1 ? 'border-b border-line/70' : '' const opts = availableRoles(actorRole())
}`} const canEdit = permissions.can('user:manage') && permissions.canManage(u.role) && opts.length > 0
> return (
<div class="flex min-w-0 items-center gap-3"> <div
<span class={`grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_16rem] sm:gap-4 ${
class={`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[u.role].avatar}`} i() < users().length - 1 ? 'border-b border-line/70' : ''
> } ${isBanned ? 'bg-destructive/5' : ''}`}
{(u.username[0] ?? '?').toUpperCase()} >
</span> <div class="flex min-w-0 items-center gap-3">
<div class="min-w-0"> <span class={`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${meta.avatar}`}>
<p class="truncate font-mono text-sm font-medium">{u.username}</p> {(u.username[0] ?? '?').toUpperCase()}
<p class="micro-label mt-1 text-muted-foreground">{ROLE_META[u.role].label}</p> </span>
<div class="min-w-0">
<p class="flex items-center gap-2 truncate font-mono text-sm font-medium">
{u.username}
<Show when={u.role === 'root'}>
<span class="inline-flex size-1.5 rounded-full bg-amber-500" />
</Show>
</p>
<p class="micro-label mt-1 text-muted-foreground">{meta.label}</p>
</div>
</div>
<Badge class={meta.badge}>{u.role}</Badge>
<div>
<Show when={isBanned} fallback={<Badge variant="outline" class="border-success/30 text-success">active</Badge>}>
<Badge variant="outline" class="border-destructive/40 bg-destructive/10 text-destructive">banned</Badge>
</Show>
</div>
<div class="flex items-center justify-end gap-2">
<Show when={canEdit} fallback={
<span class="flex items-center gap-1 text-xs text-muted-foreground/60">
<UserRound class="size-3.5" />
无权操作
</span>
}>
<Select
class="w-28"
value={u.role}
aria-label={`调整 ${u.username} 的角色`}
onChange={(v) => change(u.username, v as Role)}
options={opts.map((r) => ({ value: r, label: r }))}
disabled={!!busy() || isBanned}
/>
</Show>
<Show when={permissions.can('user:manage') && canBanTarget(u)}>
<Button
variant={isBanned ? 'outline' : 'ghost'}
class={`h-7 px-2 text-xs ${isBanned ? 'border-success/30 text-success hover:bg-success/10' : 'text-destructive hover:bg-destructive/10'}`}
disabled={busy() === u.username}
onClick={() => toggleBan(u)}
>
{isBanned ? '解封' : '封禁'}
</Button>
</Show>
</div> </div>
</div> </div>
<Badge class={ROLE_META[u.role].badge}>{u.role}</Badge> )
<div class="flex items-center justify-end"> }}
<Show
when={permissions.can('user:manage')}
fallback={<UserRound class="size-4 text-muted-foreground/50" />}
>
<Select
class="w-32"
value={u.role}
aria-label={`调整 ${u.username} 的角色`}
onChange={(v) => change(u.username, v as Role)}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Show>
</div>
</div>
)}
</For> </For>
<Show when={users().length === 0 && !err()}> <Show when={users().length === 0 && !err()}>
<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground"> <div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">
@ -131,6 +227,9 @@ export const Users: Component = () => {
</div> </div>
</Reveal> </Reveal>
</Show> </Show>
<Alert tone="info" class="text-xs leading-relaxed">
提示:Root 拥有全部权限且可封禁/解封任意非 Root 用户;Admin 仅可调整 viewer/publisher 并封禁同级以下用户;被封禁者即使持有有效 JWT 也会在鉴权校验时被拒绝(后端 VerifyToken 拦截)。
</Alert>
</div> </div>
) )
} }

View File

@ -5,11 +5,17 @@ import type {
SubscribeResponse, SubscribeResponse,
UserInfo, UserInfo,
Role, Role,
RoomQueryParams,
RoomQueryResponse,
ManageRoomsResponse,
AuthResponse,
} from './types' } from './types'
const BASE = '' const BASE = ''
async function req<T>(method: string, path: string, body?: unknown): Promise<T> { let refreshing: Promise<void> | null = null
async function rawReq<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, { const res = await fetch(BASE + path, {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
@ -18,10 +24,51 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
}) })
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(text || res.statusText || String(res.status)) const msg = text || res.statusText || String(res.status)
const err = new Error(msg) as Error & { status?: number; body?: string }
err.status = res.status
err.body = text
throw err
} }
if (res.status === 204) return undefined as T if (res.status === 204) return undefined as T
return (await res.json()) as T const ct = res.headers.get('content-type') || ''
if (ct.includes('application/json')) {
return (await res.json()) as T
}
return (await res.json().catch(() => undefined)) as T
}
async function doRefresh(): Promise<boolean> {
try {
await rawReq('POST', '/api/auth/refresh', {})
return true
} catch {
return false
}
}
async function req<T>(method: string, path: string, body?: unknown, retried = false): Promise<T> {
try {
return await rawReq<T>(method, path, body)
} catch (e: any) {
const status = e?.status
const isAuthPath = path.startsWith('/api/auth/login') || path.startsWith('/api/auth/refresh') || path.startsWith('/api/auth/register')
if (!retried && (status === 401 || status === 403) && !isAuthPath) {
if (!refreshing) {
refreshing = doRefresh().then(() => {}).finally(() => {
refreshing = null
})
}
await refreshing
// refetch once after refresh attempt
try {
return await rawReq<T>(method, path, body)
} catch (e2) {
throw e2
}
}
throw e
}
} }
export const api = { export const api = {
@ -34,23 +81,65 @@ export const api = {
stop: (room: string, backend: string) => stop: (room: string, backend: string) =>
req<{ ok: boolean }>('POST', '/api/stop', { room, backend }), req<{ ok: boolean }>('POST', '/api/stop', { room, backend }),
login: (username: string, password: string) => login: (username: string, password: string) =>
req<{ token: string; user: UserInfo; expires_at: number }>('POST', '/api/auth/login', { req<AuthResponse>('POST', '/api/auth/login', {
username, username,
password, password,
}), }),
register: (username: string, password: string, role: Role) => register: (username: string, password: string, role: Role) =>
req<{ token: string; user: UserInfo; expires_at: number }>('POST', '/api/auth/register', { req<AuthResponse>('POST', '/api/auth/register', {
username, username,
password, password,
role, role,
}), }),
logout: () => req<{ status: string }>('POST', '/api/auth/logout'),
me: () => me: () =>
req<{ username: string; role: Role; expires_at: number; issued_at: number }>('GET', '/api/auth/me'), req<{ username: string; role: Role; status?: string; expires_at: number; issued_at: number }>('GET', '/api/auth/me'),
srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'), srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'),
authCheck: () => req<any>('GET', '/api/auth/check'), authCheck: () => req<import('./types').AuthCheckResponse>('GET', '/api/auth/check'),
listUsers: () => req<{ users: UserInfo[] }>('GET', '/api/auth/users'), listUsers: () => req<{ users: UserInfo[] }>('GET', '/api/auth/users'),
updateUserRole: (username: string, role: Role) => updateUserRole: (username: string, role: Role) =>
req<{ status: string; username: string; role: string }>('POST', '/api/auth/users/role', { username, role }), req<{ status: string; username: string; role: string }>('POST', '/api/auth/users/role', { username, role }),
banUser: (username: string) =>
req<{ status: string; username: string }>('POST', '/api/auth/users/ban', { username }),
unbanUser: (username: string) =>
req<{ status: string; username: string }>('POST', '/api/auth/users/unban', { username }),
refresh: (refresh_token?: string) =>
req<{ token: string; username: string; role: string; expires_at: number }>('POST', '/api/auth/refresh', refresh_token ? { refresh_token } : {}),
queryRooms: (params: RoomQueryParams = {}) => {
const usp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue
usp.set(k, String(v))
}
const q = usp.toString()
return req<RoomQueryResponse>('GET', `/api/rooms/query${q ? `?${q}` : ''}`)
},
createRoom: (name: string) => createRoom: (name: string) =>
req<{ ok: boolean; room: string }>('POST', '/api/rooms', { name }), req<{ ok: boolean; room: string }>('POST', '/api/rooms', { name }),
// ---- Manage 统一管理面 ----
manageOverview: () => req<{ rooms_total: number; rooms_live: number; members_total: number; users_total: number; backends: any[] }>('GET', '/api/manage/overview'),
manageRooms: () => req<{ rooms: { room: any; member_count: number; live: boolean; targets: any[] }[] }>('GET', '/api/manage/rooms'),
manageCreateRoom: (payload: { name: string; display_name?: string; description?: string; is_public?: boolean; require_approval?: boolean; max_members?: number; password?: string }) =>
req<{ ok: boolean; room: any }>('POST', '/api/manage/rooms', payload),
manageRoomDetail: (room: string) => req<{ room: any; members: any[]; targets: any[] }>('GET', `/api/manage/rooms/${encodeURIComponent(room)}`),
manageUpdateRoom: (room: string, payload: any) =>
req<{ ok: boolean }>('PATCH', `/api/manage/rooms/${encodeURIComponent(room)}`, payload),
manageDeleteRoom: (room: string) =>
req<{ ok: boolean }>('DELETE', `/api/manage/rooms/${encodeURIComponent(room)}`),
manageMembers: (room: string) => req<{ members: any[]; room: string }>('GET', `/api/manage/rooms/${encodeURIComponent(room)}/members`),
manageUpdateMemberRole: (room: string, user: string, role: string) =>
req<{ ok: boolean }>('PUT', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}`, { role }),
manageKickMember: (room: string, user: string) =>
req<{ ok: boolean }>('DELETE', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}`),
manageUpdateMemberPerms: (room: string, user: string, added: number, removed: number, is_admin = false) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}/perms`, { added, removed, is_admin }),
managePermMatrix: (room?: string) =>
room ? req<any>('GET', `/api/manage/permissions/${encodeURIComponent(room)}`) : req<any>('GET', '/api/manage/permissions'),
manageRoomSettings: (room: string) => req<any>('GET', `/api/manage/rooms/${encodeURIComponent(room)}/settings`),
manageUpdateRoomSettings: (room: string, payload: any) =>
req<{ ok: boolean }>('PUT', `/api/manage/rooms/${encodeURIComponent(room)}/settings`, payload),
manageJoinRoom: (room: string, password?: string) =>
req<{ ok: boolean; member?: any }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/join`, { password }),
manageLeaveRoom: (room: string) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/leave`),
} }

View File

@ -8,13 +8,23 @@ export const auth = {
user, user,
setUser, setUser,
isAuthed: () => user() != null, isAuthed: () => user() != null,
isRoot: () => user()?.role === 'root',
isAdminOrAbove: () => { const r=user()?.role; return r==='root'||r==='admin' },
status: () => (user() as any)?.status ?? 'active',
isBanned: () => (user() as any)?.status === 'banned',
isGuest: () => !user() || user()?.role === 'guest',
} }
export async function refreshMe() { export async function refreshMe() {
try { try {
const m = await api.me() const m = await api.me()
setUser(m) setUser(m as any)
} catch { } catch {
setUser(null) setUser(null)
} }
} }
export async function logout() {
try { await api.logout() } catch {}
setUser(null)
}

View File

@ -3,6 +3,10 @@ import { api } from './api'
import { auth } from './auth' import { auth } from './auth'
export interface AuthCheck { export interface AuthCheck {
status?: string
is_root?: boolean
is_admin?: boolean
enabled: boolean enabled: boolean
authenticated: boolean authenticated: boolean
username?: string username?: string
@ -27,5 +31,16 @@ export const permissions = {
can: (action: string) => !!check()?.permissions?.[action], can: (action: string) => !!check()?.permissions?.[action],
// 当前角色:优先鉴权接口返回,回退到已登录用户信息 // 当前角色:优先鉴权接口返回,回退到已登录用户信息
role: () => check()?.role ?? auth.user()?.role ?? 'guest', role: () => check()?.role ?? auth.user()?.role ?? 'guest',
status: () => (check() as any)?.status ?? (auth.user() as any)?.status ?? 'active',
isRoot: () => permissions.role() === 'root',
isAdmin: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
isAdminOrAbove: () => { const r = permissions.role(); return r === 'root' || r === 'admin' },
isBanned: () => permissions.status() === 'banned',
canManage: (targetRole: string) => {
const r = permissions.role()
if (r === 'root') return true
if (r === 'admin') return targetRole === 'viewer' || targetRole === 'publisher'
return false
},
isAuthed: () => !!auth.user() || !!check()?.authenticated, isAuthed: () => !!auth.user() || !!check()?.authenticated,
} }

View File

@ -2,7 +2,7 @@ import { createSignal } from 'solid-js'
export type ThemeMode = 'light' | 'dark' export type ThemeMode = 'light' | 'dark'
const THEME_KEY = 'live-sfu-theme' const THEME_KEY = 'sync-live-theme'
const [theme, setTheme] = createSignal<ThemeMode>('light') const [theme, setTheme] = createSignal<ThemeMode>('light')
function preferSystem(): ThemeMode { function preferSystem(): ThemeMode {

View File

@ -31,9 +31,97 @@ export interface StreamTarget {
published_at?: number published_at?: number
} }
export type RoomStatus = 'active' | 'closed' | 'banned'
export type PlayMode = 'sequential' | 'repeat_one' | 'repeat_all' | 'shuffle'
export interface RoomSettings {
is_public: boolean
require_approval: boolean
allow_guest_join: boolean
chat_enabled: boolean
max_members: number
allow_auto_join: boolean
admin_added_permissions?: number
admin_removed_permissions?: number
member_added_permissions?: number
member_removed_permissions?: number
guest_added_permissions?: number
guest_removed_permissions?: number
auto_play_enabled: boolean
play_mode: PlayMode
}
export interface AutoPlaySettings {
enabled: boolean
mode: PlayMode
delay: number
}
export interface Room { export interface Room {
name: string name: string
targets: StreamTarget[] targets: StreamTarget[]
display_name?: string
description?: string
creator?: string
status?: RoomStatus
is_public?: boolean
require_approval?: boolean
max_members?: number
member_count?: number
created_at?: string
updated_at?: string
closed_at?: string | null
settings?: RoomSettings | null
version?: number
category_id?: number | null
label_ids?: number[]
cover_file_id?: string | null
auto_play?: AutoPlaySettings
}
export interface ManagedRoom {
room: Room
targets: StreamTarget[]
member_count: number
live: boolean
}
export interface ManageRoomsResponse {
rooms: ManagedRoom[]
}
export interface RoomQueryParams {
page?: number
page_size?: number
search?: string
status?: RoomStatus
creator?: string
is_public?: boolean
sort_by?: 'created_at' | 'updated_at' | 'name' | 'member_count'
sort_direction?: 'asc' | 'desc'
}
export interface RoomQueryResponse {
rooms: Room[]
total: number
page: number
page_size: number
}
export interface RoomMember {
room_name: string
room_id: string
user_id: string
username: string
role: 'creator' | 'admin' | 'member' | 'guest'
status: 'active' | 'banned' | 'pending'
added_permissions: number
removed_permissions: number
admin_added_permissions: number
admin_removed_permissions: number
effective_permissions?: string[]
joined_at: string
version: number
} }
export interface RoomEvent { export interface RoomEvent {
@ -60,10 +148,34 @@ export interface ListRoomsResponse {
rooms: Room[] rooms: Room[]
} }
export type Role = 'admin' | 'publisher' | 'viewer' | 'guest' export type Role = 'root' | 'admin' | 'publisher' | 'viewer' | 'guest'
export type UserStatus = 'active' | 'banned'
export interface UserInfo { export interface UserInfo {
username: string username: string
role: Role role: Role
status?: UserStatus
created_at?: number created_at?: number
} }
export interface AuthResponse {
token: string
refresh_token?: string
username: string
role: Role
status?: UserStatus
expires_at?: number
refresh_expires_at?: number
}
export interface AuthCheckResponse {
enabled: boolean
authenticated: boolean
username?: string
role?: Role
status?: UserStatus
is_admin?: boolean
is_root?: boolean
permissions?: Record<string, boolean>
error?: string
}