feat: add runtime config, room distributor override, user management & UI polish

This commit adds multiple major features and improvements:
1. Add runtime config API with save/reset capabilities
2. Implement per-room distributor override with global fallback
3. Add user management page and inline user controls
4. Update auth policy with admin/root permissions
5. Refine frontend navigation and error boundary
6. Update static assets and build system
7. Add CDN/SRS/Cloudflare config apply logic
This commit is contained in:
noelorin 2026-08-23 19:48:32 +08:00
parent 3256839d5d
commit 1a54286d69
67 changed files with 2281 additions and 599 deletions

178
Makefile Normal file
View File

@ -0,0 +1,178 @@
# SyncLive — Makefile
# 对标 SyncTV 的 SFU 分流服务(SRS 主干 + Cloudflare/CND 分发)
SHELL := /bin/bash
.DEFAULT_GOAL := help
# ── 变量 ──────────────────────────────────────────────
BIN := server
CMD := ./cmd/server
WEB_DIR := web
STATIC_DIR := internal/server/static
PROTO_DIR := api
GEN_DIR := gen
COMPOSE_FILE := deploy/docker-compose.yml
GO ?= go
PNPM ?= pnpm
# 从 .env 注入端口(若存在),否则用默认值
-include .env
export
HTTP_PORT ?= 8088
GRPC_PORT ?= 9090
GOFLAGS ?= -trimpath
LDFLAGS ?= -s -w
CGO_ENABLED ?= 1
# ── 帮助 ──────────────────────────────────────────────
.PHONY: help
help: ## 显示可用目标
@echo "SyncLive — make 帮助"
@echo ""
@echo "Usage: make [target]"
@echo ""
@grep -h -E '^[a-zA-Z0-9_.-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-18s\033[0m %s\n",$$1,$$2}'
@echo ""
@echo "示例:"
@echo " make env # 首次初始化 .env"
@echo " make srs-up # 启动本地 SRS 主干"
@echo " make dev # 热启动控制面 (go run)"
@echo " make build # 前端 + 后端完整构建"
@echo " make test check # 质量门禁"
# ── 环境 ──────────────────────────────────────────────
.PHONY: env
env: ## 若无 .env 则从 .env.example 复制
@if [ -f .env ]; then \
echo ".env 已存在,跳过"; \
else \
cp .env.example .env; \
echo ".env 已从 .env.example 创建,请按需填写 JWT_SECRET / CF_* / CDN_*"; \
fi
# ── 依赖安装 ──────────────────────────────────────────
.PHONY: install install-go install-web
install: install-go install-web ## 安装 Go 与前端依赖
install-go: ## go mod download
$(GO) mod download
install-web: ## pnpm install (web/)
@if ! command -v $(PNPM) >/dev/null 2>&1; then \
echo "error: pnpm 未安装,请先安装 https://pnpm.io/installation"; exit 1; fi
cd $(WEB_DIR) && $(PNPM) install
# ── 前端 ──────────────────────────────────────────────
.PHONY: web web-build web-dev web-typecheck web-preview web-clean
web: web-build ## alias: web = web-build
web-build: ## 构建前端到 $(STATIC_DIR)(被 Go embed)
@if ! command -v $(PNPM) >/dev/null 2>&1; then \
echo "error: pnpm 未安装"; exit 1; fi
cd $(WEB_DIR) && $(PNPM) run build
@echo "web build -> $(STATIC_DIR)"
web-dev: ## 启动 Vite 开发服务器 (5173,反代到 :$(HTTP_PORT))
@if ! command -v $(PNPM) >/dev/null 2>&1; then \
echo "error: pnpm 未安装"; exit 1; fi
cd $(WEB_DIR) && $(PNPM) run dev
web-typecheck: ## 前端类型检查
cd $(WEB_DIR) && $(PNPM) run typecheck
web-preview: ## 预览前端构建产物
cd $(WEB_DIR) && $(PNPM) run preview
web-clean: ## 清理前端构建产物与依赖
rm -rf $(WEB_DIR)/node_modules
rm -rf $(WEB_DIR)/dist
# ── 后端构建 / 运行 ───────────────────────────────────
.PHONY: build build-go build-all run dev
build: web-build build-go ## 完整构建:前端 + Go 二进制
build-go: ## 仅构建 Go 二进制 -> ./$(BIN)(含前端 embed)
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) $(CMD)
@echo "built -> ./$(BIN) (HTTP :$(HTTP_PORT) gRPC :$(GRPC_PORT))"
build-all: build ## alias: build-all = build
run: ## go run ./cmd/server(不等前端构建)
$(GO) run $(CMD)
dev: ## 开发态运行(等价于 run,打印后端链路信息)
@echo "starting sync-live (HTTP :$(HTTP_PORT) gRPC :$(GRPC_PORT)) ..."
$(GO) run $(CMD)
# ── 协议 / 代码生成 ───────────────────────────────────
.PHONY: proto gen buf
proto: gen ## alias: proto = gen
gen: ## buf generate api(需 buf CLI,已含 go/go-grpc 插件)
@if command -v buf >/dev/null 2>&1; then \
buf generate $(PROTO_DIR); \
echo "proto generated -> $(GEN_DIR)/"; \
elif command -v docker >/dev/null 2>&1; then \
echo "buf 未安装,改用 docker (bufbuild/buf) ..."; \
docker run --rm -v "$$(pwd):/workspace" -w /workspace bufbuild/buf generate $(PROTO_DIR); \
else \
echo "error: 未找到 buf,请安装 https://buf.build/docs/installation"; exit 1; fi
buf: gen ## alias: buf = gen
# ── 质量门禁 ──────────────────────────────────────────
.PHONY: fmt vet lint test tidy check
fmt: ## go fmt ./...
$(GO) fmt ./...
vet: ## go vet ./...
$(GO) vet ./...
lint: ## golangci-lint(若未安装则回退到 go vet)
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run ./...; \
else \
echo "golangci-lint 未安装,跳过。安装: https://golangci-lint.run/welcome/install/"; \
echo "回退到 go vet ..."; \
$(GO) vet ./...; \
fi
test: ## go test ./...(含 -count=1)
$(GO) test ./... -count=1
tidy: ## go mod tidy
$(GO) mod tidy
check: fmt vet test ## 质量门禁:fmt + vet + test
# ── SRS / Docker ──────────────────────────────────────
.PHONY: srs-up srs-down srs-logs srs-restart compose-up compose-down docker-up docker-down
srs-up: ## 启动 SRS 主干(deploy/docker-compose.yml)
@SRS_CANDIDATE=$${SRS_CANDIDATE:-127.0.0.1} docker compose -f $(COMPOSE_FILE) up -d srs
@docker compose -f $(COMPOSE_FILE) ps
srs-down: ## 停止 SRS
docker compose -f $(COMPOSE_FILE) down
srs-logs: ## 查看 SRS 日志
docker compose -f $(COMPOSE_FILE) logs -f srs
srs-restart: ## 重启 SRS
docker compose -f $(COMPOSE_FILE) restart srs
compose-up: srs-up ## alias: compose-up = srs-up
compose-down: srs-down ## alias: compose-down = srs-down
docker-up: srs-up ## alias: docker-up = srs-up
docker-down: srs-down ## alias: docker-down = srs-down
# ── 清理 ──────────────────────────────────────────────
.PHONY: clean clean-all
clean: ## 清理 Go 构建产物与缓存(二进制、/tmp/sync-live)
rm -f $(BIN)
rm -rf /tmp/sync-live
$(GO) clean -cache -testcache 2>/dev/null || true
@echo "clean done."
clean-all: clean web-clean ## 深度清理:clean + 前端产物/依赖
rm -rf $(STATIC_DIR)/assets
@echo "clean-all done."

View File

@ -39,9 +39,13 @@ p, viewer, srs, streams
p, guest, config, read
p, guest, srs, streams
p, guest, room, chat
p, admin, config, write
p, admin, system, manage
p, root, config, write
g, admin, admin
g, root, root
g, publisher, publisher
g, viewer, viewer
g, guest, guest
g, alice, viewer
g, alice, viewer
g, viewer_e2e, viewer
Can't render this file because it has a wrong number of fields in line 45.

View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"sync"
"time"
)
@ -70,6 +71,10 @@ type Config struct {
DatabaseURL string // 嵌入式 DB DSN(TURSO_DATABASE_URL / DATABASE_URL),默认 file:./data/sync-live.db
AuthToken string // 远程 libSQL 凭证,嵌入模式可为空
mu *sync.RWMutex `json:"-"`
overrides map[string]string `json:"-"`
runtimeFile string `json:"-"`
}
func Load() *Config {
@ -133,6 +138,10 @@ func Load() *Config {
DatabaseURL: getenv("TURSO_DATABASE_URL", "file:./data/sync-live.db?cache=shared&_journal_mode=WAL"),
AuthToken: getenv("TURSO_AUTH_TOKEN", ""),
mu: &sync.RWMutex{},
overrides: map[string]string{},
runtimeFile: "data/runtime_config.json",
}
// JWT 密钥优先使用专用配置;否则回退到推流密钥(两者都应显式配置)。
if c.JWTSecret == "" {
@ -161,6 +170,7 @@ func Load() *Config {
if c.OAuthCallbackBase == "" {
c.OAuthCallbackBase = "http://localhost:" + c.HTTPPort
}
_ = c.LoadRuntime()
return c
}
@ -176,11 +186,7 @@ func (c *Config) Validate() error {
}
func (c *Config) ValidateDistributors() error {
for _, d := range c.DistributorList() {
if d == "cdn" && c.CDNRTMPURL == "" {
return fmt.Errorf("CDN 分发已启用但 CDN_RTMP_URL 未配置")
}
}
// 运行时允许 cdn 列表占位,即使未配置 RTMP URL 也仅影响分发就绪状态,不阻断配置保存
return nil
}
@ -239,7 +245,12 @@ func (c *Config) ProviderList() []string {
}
func (c *Config) DistributorList() []string {
parts := strings.Split(c.Distributors, ",")
return ParseDistributors(c.Distributors)
}
// ParseDistributors parses and normalizes distributors string.
func ParseDistributors(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
@ -254,6 +265,10 @@ func (c *Config) DistributorList() []string {
if p == "cloudflare" || p == "cf-sfu" {
p = "cf"
}
// only keep known kinds
if p != "srs-hls" && p != "cf" && p != "cdn" {
continue
}
if _, ok := seen[p]; !ok {
seen[p] = struct{}{}
out = append(out, p)
@ -262,6 +277,42 @@ func (c *Config) DistributorList() []string {
return out
}
// EffectiveDistributorsForRoom returns per-room distributors if override present, else global.
func (c *Config) EffectiveDistributorsForRoom(roomDistributors *string) []string {
if roomDistributors != nil {
return ParseDistributors(*roomDistributors)
}
return c.DistributorList()
}
func NormalizeDistributorsString(raw string) string {
return strings.Join(ParseDistributors(raw), ",")
}
func ValidateDistributorsString(raw string) error {
// use ParseDistributors then re-validate known set; unknown silently dropped, so check if input contains unknown non-empty token
parts := strings.Split(raw, ",")
for _, p := range parts {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
continue
}
normalized := p
if p == "srs" || p == "hls" {
normalized = "srs-hls"
}
if p == "cloudflare" || p == "cf-sfu" {
normalized = "cf"
}
if normalized != "srs-hls" && normalized != "cf" && normalized != "cdn" {
return fmt.Errorf("unsupported distributor %q", p)
}
}
return nil
}
func (c *Config) CDNEnabled() bool {
return c.CDNRTMPURL != ""
}
@ -288,3 +339,15 @@ func (c *Config) SMTPConfigured() bool {
func (c *Config) OAuthConfigured() bool {
return c.OAuthGoogleID != "" || c.OAuthGithubID != "" || c.OAuthGitlabID != ""
}
func (c *Config) RuntimeFilePath() string {
if c.runtimeFile != "" {
return c.runtimeFile
}
return "data/runtime_config.json"
}
func (c *Config) RLock() { c.ensureMu(); c.mu.RLock() }
func (c *Config) RUnlock() { c.mu.RUnlock() }
func (c *Config) Lock() { c.ensureMu(); c.mu.Lock() }
func (c *Config) Unlock() { c.mu.Unlock() }

556
internal/config/runtime.go Normal file
View File

@ -0,0 +1,556 @@
package config
import (
"time"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
var runtimeFields = map[string]bool{
"SFU_PROVIDER": true, "DISTRIBUTORS": true, "CDN_RTMP_URL": true, "CDN_NAME": true,
"SRS_API_BASE": true, "SRS_HTTP_BASE": true, "SRS_APP": true, "SRS_SECRET": true, "SRS_CANDIDATE": true,
"CF_APP_ID": true, "CF_APP_SECRET": true, "CF_BASE_URL": true, "CF_STUN_URL": true,
"SFU_TOKEN_REQUIRED": true, "ALLOW_REGISTER": true, "JWT_TTL": true,
"SMTP_HOST": true, "SMTP_PORT": true, "SMTP_USER": true, "SMTP_PASS": true, "SMTP_FROM": true, "SMTP_FROM_NAME": true, "SMTP_TLS": true, "SMTP_ENABLED": true,
"EMAIL_VERIFY_REQUIRED": true, "EMAIL_VERIFY_TTL": true, "EMAIL_VERIFY_BASE_URL": true,
"OAUTH_ENABLED": true, "OAUTH_CALLBACK_BASE": true,
"OAUTH_GOOGLE_CLIENT_ID": true, "OAUTH_GOOGLE_CLIENT_SECRET": true,
"OAUTH_GITHUB_CLIENT_ID": true, "OAUTH_GITHUB_CLIENT_SECRET": true,
"OAUTH_GITLAB_CLIENT_ID": true, "OAUTH_GITLAB_CLIENT_SECRET": true,
"OAUTH_GENERIC_CLIENT_ID": true, "OAUTH_GENERIC_CLIENT_SECRET": true, "OAUTH_GENERIC_URL": true, "OAUTH_GENERIC_NAME": true,
}
type RuntimeView struct {
Values map[string]string `json:"values"`
Masked map[string]bool `json:"masked"`
Source map[string]string `json:"source"`
}
var secretKeys = map[string]bool{
"CF_APP_SECRET": true, "SRS_SECRET": true, "SMTP_PASS": true,
"OAUTH_GOOGLE_CLIENT_SECRET": true, "OAUTH_GITHUB_CLIENT_SECRET": true, "OAUTH_GITLAB_CLIENT_SECRET": true, "OAUTH_GENERIC_CLIENT_SECRET": true,
}
func (c *Config) ensureMu() {
if c.mu == nil {
c.mu = &sync.RWMutex{}
}
if c.runtimeFile == "" {
c.runtimeFile = "data/runtime_config.json"
}
if c.overrides == nil {
c.overrides = map[string]string{}
}
}
func (c *Config) LoadRuntime() error {
c.ensureMu()
path := c.runtimeFile
if path == "" {
path = "data/runtime_config.json"
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("parse runtime config: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
for k, v := range m {
nk := normalizeRuntimeKey(k)
if !runtimeFields[nk] {
continue
}
c.overrides[nk] = v
}
c.applyOverridesLocked()
return nil
}
func (c *Config) SaveRuntime() error {
c.ensureMu()
c.mu.RLock()
m := make(map[string]string, len(c.overrides))
for k, v := range c.overrides {
m[k] = v
}
path := c.runtimeFile
c.mu.RUnlock()
if path == "" {
path = "data/runtime_config.json"
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, _ := json.MarshalIndent(m, "", " ")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func (c *Config) ViewRuntime(masked bool) RuntimeView {
c.ensureMu()
c.mu.RLock()
defer c.mu.RUnlock()
values := map[string]string{}
maskedMap := map[string]bool{}
source := map[string]string{}
for k := range runtimeFields {
v := c.fieldValue(k)
isSecret := secretKeys[k]
_, fromRuntime := c.overrides[k]
if masked && isSecret && v != "" {
values[k] = maskSecret(v)
maskedMap[k] = true
} else {
values[k] = v
if isSecret && v != "" {
maskedMap[k] = true
}
}
if fromRuntime {
source[k] = "runtime"
} else if os.Getenv(k) != "" {
source[k] = "env"
} else {
source[k] = "default"
}
}
return RuntimeView{Values: values, Masked: maskedMap, Source: source}
}
func (c *Config) ApplyRuntimePatch(patch map[string]string) ([]string, error) {
c.ensureMu()
norm := map[string]string{}
for k, v := range patch {
nk := normalizeRuntimeKey(k)
if !runtimeFields[nk] {
return nil, fmt.Errorf("不支持的运行时配置键: %s", k)
}
if secretKeys[nk] && isMaskPlaceholder(v) {
continue
}
norm[nk] = strings.TrimSpace(v)
}
c.mu.Lock()
defer c.mu.Unlock()
changed := []string{}
old := map[string]string{}
for k, v := range norm {
old[k] = c.overrides[k]
if old[k] != v {
changed = append(changed, k)
}
if v == "" {
delete(c.overrides, k)
// revert field to env/default immediately
c.revertFieldLocked(k)
} else {
c.overrides[k] = v
}
}
c.applyOverridesLocked()
if err := c.ValidateDistributors(); err != nil {
for k := range norm {
if ov, ok := old[k]; ok {
c.overrides[k] = ov
} else {
delete(c.overrides, k)
}
}
c.applyOverridesLocked()
return nil, err
}
if v, ok := norm["CF_STUN_URL"]; ok && v != "" && !strings.Contains(v, ":") {
for k := range norm {
if ov, ok := old[k]; ok {
c.overrides[k] = ov
} else {
delete(c.overrides, k)
}
}
c.applyOverridesLocked()
return nil, fmt.Errorf("CF_STUN_URL 格式错误")
}
return changed, nil
}
func normalizeRuntimeKey(k string) string {
k = strings.TrimSpace(k)
k = strings.ToUpper(k)
k = strings.ReplaceAll(k, "-", "_")
return k
}
func isMaskPlaceholder(s string) bool {
if s == "" {
return false
}
for _, ch := range s {
if ch != '•' && ch != '*' && ch != '·' {
return false
}
}
return true
}
func maskSecret(s string) string {
if s == "" {
return ""
}
return "••••••••"
}
func (c *Config) fieldValue(key string) string {
switch key {
case "SFU_PROVIDER":
return c.ProviderOrder
case "DISTRIBUTORS":
return c.Distributors
case "CDN_RTMP_URL":
return c.CDNRTMPURL
case "CDN_NAME":
return c.CDNName
case "SRS_API_BASE":
return c.SRSBaseURL
case "SRS_HTTP_BASE":
return c.SRSHttpURL
case "SRS_APP":
return c.SRSApp
case "SRS_SECRET":
return c.SRSSecret
case "SRS_CANDIDATE":
return c.SRSCandidate
case "CF_APP_ID":
return c.CFAppID
case "CF_APP_SECRET":
return c.CFAppSecret
case "CF_BASE_URL":
return c.CFBaseURL
case "CF_STUN_URL":
return c.CFStunURL
case "SFU_TOKEN_REQUIRED":
if c.TokenRequired {
return "1"
}
return "0"
case "ALLOW_REGISTER":
if c.AllowRegister {
return "1"
}
return "0"
case "JWT_TTL":
return c.JWTTTL.String()
case "SMTP_HOST":
return c.SMTPHost
case "SMTP_PORT":
return fmt.Sprintf("%d", c.SMTPPort)
case "SMTP_USER":
return c.SMTPUser
case "SMTP_PASS":
return c.SMTPPass
case "SMTP_FROM":
return c.SMTPFrom
case "SMTP_FROM_NAME":
return c.SMTPFromName
case "SMTP_TLS":
if c.SMTPTLS {
return "1"
}
return "0"
case "SMTP_ENABLED":
if c.SMTPEnabled {
return "1"
}
return "0"
case "EMAIL_VERIFY_REQUIRED":
if c.EmailVerifyRequired {
return "1"
}
return "0"
case "EMAIL_VERIFY_TTL":
return c.EmailVerifyTTL.String()
case "EMAIL_VERIFY_BASE_URL":
return c.EmailVerifyBaseURL
case "OAUTH_ENABLED":
if c.OAuthEnabled {
return "1"
}
return "0"
case "OAUTH_CALLBACK_BASE":
return c.OAuthCallbackBase
case "OAUTH_GOOGLE_CLIENT_ID":
return c.OAuthGoogleID
case "OAUTH_GOOGLE_CLIENT_SECRET":
return c.OAuthGoogleSecret
case "OAUTH_GITHUB_CLIENT_ID":
return c.OAuthGithubID
case "OAUTH_GITHUB_CLIENT_SECRET":
return c.OAuthGithubSecret
case "OAUTH_GITLAB_CLIENT_ID":
return c.OAuthGitlabID
case "OAUTH_GITLAB_CLIENT_SECRET":
return c.OAuthGitlabSecret
case "OAUTH_GENERIC_CLIENT_ID":
return c.OAuthGenericID
case "OAUTH_GENERIC_CLIENT_SECRET":
return c.OAuthGenericSecret
case "OAUTH_GENERIC_URL":
return c.OAuthGenericURL
case "OAUTH_GENERIC_NAME":
return c.OAuthGenericName
default:
return ""
}
}
func (c *Config) applyOverridesLocked() {
for k, v := range c.overrides {
switch k {
case "SFU_PROVIDER":
c.ProviderOrder = envOr(v, c.ProviderOrder)
case "DISTRIBUTORS":
c.Distributors = envOr(v, c.Distributors)
case "CDN_RTMP_URL":
c.CDNRTMPURL = v
case "CDN_NAME":
if v != "" {
c.CDNName = v
} else {
c.CDNName = "cdn"
}
case "SRS_API_BASE":
if v != "" {
c.SRSBaseURL = v
}
case "SRS_HTTP_BASE":
if v != "" {
c.SRSHttpURL = v
}
case "SRS_APP":
if v != "" {
c.SRSApp = v
}
case "SRS_SECRET":
c.SRSSecret = v
case "SRS_CANDIDATE":
if v != "" {
c.SRSCandidate = v
}
case "CF_APP_ID":
c.CFAppID = v
case "CF_APP_SECRET":
c.CFAppSecret = v
case "CF_BASE_URL":
if v != "" {
c.CFBaseURL = v
}
case "CF_STUN_URL":
if v != "" {
c.CFStunURL = v
}
case "SFU_TOKEN_REQUIRED":
c.TokenRequired = v == "1" || strings.EqualFold(v, "true")
case "ALLOW_REGISTER":
c.AllowRegister = v == "1" || strings.EqualFold(v, "true")
case "JWT_TTL":
if d := parseDuration(v, 0); d != 0 {
c.JWTTTL = d
}
case "SMTP_HOST":
c.SMTPHost = v
case "SMTP_PORT":
if v != "" {
c.SMTPPort = parseInt(v, c.SMTPPort)
}
case "SMTP_USER":
c.SMTPUser = v
case "SMTP_PASS":
c.SMTPPass = v
case "SMTP_FROM":
c.SMTPFrom = v
case "SMTP_FROM_NAME":
if v != "" {
c.SMTPFromName = v
}
case "SMTP_TLS":
c.SMTPTLS = v == "1" || strings.EqualFold(v, "true")
case "SMTP_ENABLED":
c.SMTPEnabled = v == "1" || strings.EqualFold(v, "true")
case "EMAIL_VERIFY_REQUIRED":
c.EmailVerifyRequired = v == "1" || strings.EqualFold(v, "true")
case "EMAIL_VERIFY_TTL":
if d := parseDuration(v, 0); d != 0 {
c.EmailVerifyTTL = d
}
case "EMAIL_VERIFY_BASE_URL":
if v != "" {
c.EmailVerifyBaseURL = v
}
case "OAUTH_ENABLED":
c.OAuthEnabled = v == "1" || strings.EqualFold(v, "true")
case "OAUTH_CALLBACK_BASE":
if v != "" {
c.OAuthCallbackBase = v
}
case "OAUTH_GOOGLE_CLIENT_ID":
c.OAuthGoogleID = v
case "OAUTH_GOOGLE_CLIENT_SECRET":
c.OAuthGoogleSecret = v
case "OAUTH_GITHUB_CLIENT_ID":
c.OAuthGithubID = v
case "OAUTH_GITHUB_CLIENT_SECRET":
c.OAuthGithubSecret = v
case "OAUTH_GITLAB_CLIENT_ID":
c.OAuthGitlabID = v
case "OAUTH_GITLAB_CLIENT_SECRET":
c.OAuthGitlabSecret = v
case "OAUTH_GENERIC_CLIENT_ID":
c.OAuthGenericID = v
case "OAUTH_GENERIC_CLIENT_SECRET":
c.OAuthGenericSecret = v
case "OAUTH_GENERIC_URL":
c.OAuthGenericURL = v
case "OAUTH_GENERIC_NAME":
if v != "" {
c.OAuthGenericName = 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
}
}
func envOr(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
func revertToEnv(key, fallback string) string {
envKey := key
if v := getenv(envKey, ""); v != "" {
return v
}
// hardcoded defaults for critical keys
switch key {
case "DISTRIBUTORS":
return "srs-hls,cf,cdn"
case "SFU_PROVIDER":
return "cloudflare,srs"
case "SRS_API_BASE":
return "http://localhost:1985"
case "SRS_HTTP_BASE":
return "http://localhost:8080"
case "SRS_APP":
return "live"
case "SRS_CANDIDATE":
return "127.0.0.1"
case "CF_BASE_URL":
return "https://rtc.live.cloudflare.com/v1"
case "CF_STUN_URL":
return "stun:stun.cloudflare.com:3478"
case "CDN_NAME":
return "cdn"
}
return fallback
}
func (c *Config) GetOverrides() map[string]string { c.ensureMu(); return c.overrides }
func (c *Config) revertFieldLocked(key string) {
switch key {
case "SFU_PROVIDER":
c.ProviderOrder = revertToEnv(key, c.ProviderOrder)
case "DISTRIBUTORS":
c.Distributors = revertToEnv(key, c.Distributors)
case "CDN_RTMP_URL":
c.CDNRTMPURL = ""
case "CDN_NAME":
c.CDNName = revertToEnv(key, c.CDNName)
case "SRS_API_BASE":
c.SRSBaseURL = revertToEnv(key, c.SRSBaseURL)
case "SRS_HTTP_BASE":
c.SRSHttpURL = revertToEnv(key, c.SRSHttpURL)
case "SRS_APP":
c.SRSApp = revertToEnv(key, c.SRSApp)
case "SRS_SECRET":
c.SRSSecret = ""
case "SRS_CANDIDATE":
c.SRSCandidate = revertToEnv(key, c.SRSCandidate)
case "CF_APP_ID":
c.CFAppID = ""
case "CF_APP_SECRET":
c.CFAppSecret = ""
case "CF_BASE_URL":
c.CFBaseURL = revertToEnv(key, c.CFBaseURL)
case "CF_STUN_URL":
c.CFStunURL = revertToEnv(key, c.CFStunURL)
case "SFU_TOKEN_REQUIRED":
c.TokenRequired = getenv("SFU_TOKEN_REQUIRED", "0") == "1"
case "ALLOW_REGISTER":
c.AllowRegister = getenv("ALLOW_REGISTER", "1") == "1"
case "JWT_TTL":
c.JWTTTL = parseDuration(getenv("JWT_TTL", "2h"), 2*time.Hour)
case "SMTP_HOST":
c.SMTPHost = ""
case "SMTP_PORT":
c.SMTPPort = parseInt(getenv("SMTP_PORT", "587"), 587)
case "SMTP_USER":
c.SMTPUser = ""
case "SMTP_PASS":
c.SMTPPass = ""
case "SMTP_FROM":
c.SMTPFrom = ""
case "SMTP_FROM_NAME":
c.SMTPFromName = revertToEnv(key, "SyncLive")
case "SMTP_TLS":
c.SMTPTLS = getenv("SMTP_TLS", "1") == "1"
case "SMTP_ENABLED":
c.SMTPEnabled = getenv("SMTP_ENABLED", "0") == "1"
case "EMAIL_VERIFY_REQUIRED":
c.EmailVerifyRequired = getenv("EMAIL_VERIFY_REQUIRED", "0") == "1"
case "EMAIL_VERIFY_TTL":
c.EmailVerifyTTL = parseDuration(getenv("EMAIL_VERIFY_TTL", "24h"), 24*time.Hour)
case "EMAIL_VERIFY_BASE_URL":
c.EmailVerifyBaseURL = revertToEnv(key, "http://localhost:"+c.HTTPPort)
case "OAUTH_ENABLED":
c.OAuthEnabled = getenv("OAUTH_ENABLED", "0") == "1"
case "OAUTH_CALLBACK_BASE":
c.OAuthCallbackBase = revertToEnv(key, "http://localhost:"+c.HTTPPort)
case "OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET", "OAUTH_GITHUB_CLIENT_ID", "OAUTH_GITHUB_CLIENT_SECRET", "OAUTH_GITLAB_CLIENT_ID", "OAUTH_GITLAB_CLIENT_SECRET", "OAUTH_GENERIC_CLIENT_ID", "OAUTH_GENERIC_CLIENT_SECRET", "OAUTH_GENERIC_URL", "OAUTH_GENERIC_NAME":
// generic string clear
switch key {
case "OAUTH_GOOGLE_CLIENT_ID": c.OAuthGoogleID = ""
case "OAUTH_GOOGLE_CLIENT_SECRET": c.OAuthGoogleSecret = ""
case "OAUTH_GITHUB_CLIENT_ID": c.OAuthGithubID = ""
case "OAUTH_GITHUB_CLIENT_SECRET": c.OAuthGithubSecret = ""
case "OAUTH_GITLAB_CLIENT_ID": c.OAuthGitlabID = ""
case "OAUTH_GITLAB_CLIENT_SECRET": c.OAuthGitlabSecret = ""
case "OAUTH_GENERIC_CLIENT_ID": c.OAuthGenericID = ""
case "OAUTH_GENERIC_CLIENT_SECRET": c.OAuthGenericSecret = ""
case "OAUTH_GENERIC_URL": c.OAuthGenericURL = ""
case "OAUTH_GENERIC_NAME": c.OAuthGenericName = revertToEnv(key, "generic")
}
}
}

View File

@ -221,20 +221,87 @@ func NewRoom(name, creator string) *Room {
}
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"`
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"`
Distributors *string `json:"distributors,omitempty"`
}
// Distributors helpers: per-room distribution override with fallback to global.
// HasCustomDistributors returns true when room explicitly sets distributors (even if empty string means disable all).
func (s *RoomSettings) HasCustomDistributors() bool {
return s != nil && s.Distributors != nil
}
// EffectiveDistributors returns parsed per-room distributors if set, otherwise fallback to global.
func (s *RoomSettings) EffectiveDistributors(global []string) []string {
if s != nil && s.Distributors != nil {
return ParseDistributors(*s.Distributors)
}
return global
}
// SetDistributors sets the per-room distributors override. Use nil to clear (fallback to global).
func (s *RoomSettings) SetDistributors(v *string) {
s.Distributors = v
}
// ParseDistributors parses a comma-separated distributors string into normalized list.
// Supported values: srs-hls (alias srs/hls), cf (alias cloudflare/cf-sfu), cdn.
// Empty input yields empty list.
func ParseDistributors(raw string) []string {
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
continue
}
if p == "srs" || p == "hls" {
p = "srs-hls"
}
if p == "cloudflare" || p == "cf-sfu" {
p = "cf"
}
if p != "srs-hls" && p != "cf" && p != "cdn" {
continue
}
if _, ok := seen[p]; !ok {
seen[p] = struct{}{}
out = append(out, p)
}
}
return out
}
func NormalizeDistributorsString(raw string) string {
return strings.Join(ParseDistributors(raw), ",")
}
func ValidateDistributorsList(list []string) error {
for _, d := range list {
if d != "srs-hls" && d != "cf" && d != "cdn" {
return fmt.Errorf("unsupported distributor %q", d)
}
}
return nil
}
func DefaultSettings() *RoomSettings {

View File

@ -5,6 +5,8 @@ import (
"time"
"sync-live/gen"
"sync-live/internal/config"
"sync-live/internal/room"
"sync-live/internal/sfu/cdn"
"sync-live/internal/sfu/cloudflare"
"sync-live/internal/sfu/srs"
@ -12,15 +14,13 @@ import (
// distributionManager handles fanout from SRS trunk to distribution channels.
type distributionManager struct {
hub *roomHub
cf *cloudflare.Provider
srsP *srs.Provider
cdnP *cdn.Provider
cfg distributionConfig
}
type distributionConfig struct {
enabled map[string]bool // kind -> enabled (from DistributorList)
hub *roomHub
cf *cloudflare.Provider
srsP *srs.Provider
cdnP *cdn.Provider
cfg *config.Config
roomStore *room.Store
enabled map[string]bool // global fallback
}
func newDistributionManager(hub *roomHub, cf *cloudflare.Provider, srsP *srs.Provider, cdnP *cdn.Provider, enabledKinds []string) *distributionManager {
@ -28,31 +28,70 @@ func newDistributionManager(hub *roomHub, cf *cloudflare.Provider, srsP *srs.Pro
for _, k := range enabledKinds {
enabled[k] = true
}
return &distributionManager{hub: hub, cf: cf, srsP: srsP, cdnP: cdnP, cfg: distributionConfig{enabled: enabled}}
return &distributionManager{hub: hub, cf: cf, srsP: srsP, cdnP: cdnP, enabled: enabled}
}
// Ensure fans out the trunk stream to all enabled distribution channels.
func newDistributionManagerWithConfig(hub *roomHub, cf *cloudflare.Provider, srsP *srs.Provider, cdnP *cdn.Provider, cfg *config.Config, rs *room.Store) *distributionManager {
enabled := map[string]bool{}
if cfg != nil {
for _, k := range cfg.DistributorList() {
enabled[k] = true
}
}
return &distributionManager{hub: hub, cf: cf, srsP: srsP, cdnP: cdnP, cfg: cfg, roomStore: rs, enabled: enabled}
}
// effectiveEnabled returns per-room enabled map with fallback to global.
func (m *distributionManager) effectiveEnabled(room string) map[string]bool {
// try per-room override
if m.roomStore != nil && m.roomStore.DB() != nil && room != "" {
if rm, err := m.roomStore.GetRoom(context.Background(), room); err == nil && rm != nil && rm.Settings != nil && rm.Settings.HasCustomDistributors() {
eff := rm.Settings.EffectiveDistributors(nil)
// if room has custom distributors, use exactly that list (even empty means none)
// normalize via global parse ensures empty disables all
if rm.Settings.Distributors != nil {
m2 := map[string]bool{}
for _, k := range eff {
m2[k] = true
}
return m2
}
}
}
// fallback to global config or cached enabled
if m.cfg != nil {
m2 := map[string]bool{}
for _, k := range m.cfg.DistributorList() {
m2[k] = true
}
return m2
}
return m.enabled
}
// Ensure fans out the trunk stream to all enabled distribution channels (per-room with global fallback).
func (m *distributionManager) Ensure(ctx context.Context, room, stream string) error {
if m.hub == nil || stream == "" {
return nil
}
enabled := m.effectiveEnabled(room)
now := time.Now().Unix()
// SRS HLS is always ready (remux直出)
if m.cfg.enabled["srs-hls"] {
if enabled["srs-hls"] {
if res, err := m.srsP.Forward(ctx, room, stream); err == nil {
m.hub.setDistribution(room, &gen.DistributionTarget{
Kind: gen.DistributionKind_DISTRIBUTION_KIND_SRS_HLS, Url: res.URL, Status: res.Status, UpdatedAt: now,
})
}
}
if m.cfg.enabled["cf"] && m.cf != nil && m.cf.Configured() {
if enabled["cf"] && m.cf != nil && m.cf.Configured() {
if res, err := m.cf.Forward(ctx, room, stream); err == nil {
m.hub.setDistribution(room, &gen.DistributionTarget{
Kind: gen.DistributionKind_DISTRIBUTION_KIND_CF_SFU, Url: res.URL, Status: res.Status, UpdatedAt: now,
})
}
}
if m.cfg.enabled["cdn"] && m.cdnP != nil && m.cdnP.Configured() {
if enabled["cdn"] && m.cdnP != nil && m.cdnP.Configured() {
if res, err := m.cdnP.Forward(ctx, room, stream); err == nil {
m.hub.setDistribution(room, &gen.DistributionTarget{
Kind: gen.DistributionKind_DISTRIBUTION_KIND_CDN, Url: res.URL, Status: res.Status, UpdatedAt: now,
@ -62,4 +101,14 @@ func (m *distributionManager) Ensure(ctx context.Context, room, stream string) e
return nil
}
func (m *distributionManager) UpdateEnabled(kinds []string) {
m.enabled = map[string]bool{}
for _, k := range kinds {
m.enabled[k] = true
}
}
func (m *distributionManager) SetRoomStore(rs *room.Store) { m.roomStore = rs }
func (m *distributionManager) SetConfig(cfg *config.Config) { m.cfg = cfg }
var _ DistributionManagerIface = (*distributionManager)(nil)

View File

@ -10,6 +10,14 @@ import (
"sync-live/internal/room"
)
func (s *Server) isGlobalAdmin(r *http.Request) bool {
u, _ := auth.FromContext(r.Context())
if u == nil {
return false
}
return u.Role == auth.RoleRoot || u.Role == auth.RoleAdmin
}
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)
@ -466,6 +474,7 @@ func (s *Server) handleManageRoomSettings(w http.ResponseWriter, r *http.Request
return
}
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rm.Settings)
return
}
@ -474,11 +483,27 @@ func (s *Server) handleManageRoomSettings(w http.ResponseWriter, r *http.Request
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
if newSettings.Distributors != nil {
if err := room.ValidateDistributorsList(room.ParseDistributors(*newSettings.Distributors)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
norm := room.NormalizeDistributorsString(*newSettings.Distributors)
newSettings.Distributors = &norm
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
if !s.isGlobalAdmin(r) {
if err := s.roomSvc.UpdateRoomSettings(r.Context(), roomName, actorName(r), &newSettings); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
} else {
if err := s.roomSvc.Store().UpdateRoomSettings(r.Context(), roomName, &newSettings); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "settings": newSettings})
}
func actorName(r *http.Request) string {
@ -539,9 +564,182 @@ func (s *Server) handleManageTransferOwnership(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func (s *Server) handleManageRoomDistribution(w http.ResponseWriter, r *http.Request) {
roomName := r.PathValue("room")
if roomName == "" {
http.Error(w, "room required", http.StatusBadRequest)
return
}
rm, err := s.roomSvc.Store().GetRoom(r.Context(), roomName)
if err != nil {
http.Error(w, "room not found: "+err.Error(), http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
globalList := s.cfg.DistributorList()
if rm.Settings == nil {
rm.Settings = room.DefaultSettings()
}
effective := rm.Settings.EffectiveDistributors(globalList)
hasCustom := rm.Settings.HasCustomDistributors()
var customList []string
var customRaw *string
if hasCustom {
customRaw = rm.Settings.Distributors
customList = room.ParseDistributors(*customRaw)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"room": roomName,
"global": globalList,
"custom": customRaw,
"custom_list": customList,
"effective": effective,
"has_custom": hasCustom,
})
case http.MethodPut, http.MethodPost, http.MethodPatch:
var body struct {
Distributors *string `json:"distributors"`
List []string `json:"list"`
}
rawBody := make([]byte, 0, 4096)
buf := make([]byte, 4096)
for {
n, err := r.Body.Read(buf)
if n > 0 {
rawBody = append(rawBody, buf[:n]...)
}
if err != nil {
break
}
}
if len(rawBody) > 0 {
_ = json.Unmarshal(rawBody, &body)
if body.Distributors == nil && body.List == nil {
var flat map[string]any
if err := json.Unmarshal(rawBody, &flat); err == nil {
if v, ok := flat["distributors"]; ok {
if sv, ok := v.(string); ok {
body.Distributors = &sv
}
}
}
}
}
var toSet *string
if body.List != nil {
joined := strings.Join(body.List, ",")
if err := room.ValidateDistributorsList(room.ParseDistributors(joined)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
norm := room.NormalizeDistributorsString(joined)
toSet = &norm
} else if body.Distributors != nil {
raw := strings.TrimSpace(*body.Distributors)
if raw == "" {
empty := ""
toSet = &empty
} else {
if err := room.ValidateDistributorsList(room.ParseDistributors(raw)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, tok := range strings.Split(raw, ",") {
tok = strings.TrimSpace(strings.ToLower(tok))
if tok == "" {
continue
}
if tok == "srs" || tok == "hls" {
tok = "srs-hls"
}
if tok == "cloudflare" || tok == "cf-sfu" {
tok = "cf"
}
if tok != "srs-hls" && tok != "cf" && tok != "cdn" {
http.Error(w, "unsupported distributor "+tok, http.StatusBadRequest)
return
}
}
norm := room.NormalizeDistributorsString(raw)
toSet = &norm
}
} else {
http.Error(w, "distributors or list required", http.StatusBadRequest)
return
}
if !s.isGlobalAdmin(r) {
if err := s.roomSvc.EnsurePermission(r.Context(), roomName, actorName(r), room.PermManageRoom); err != nil {
http.Error(w, "forbidden: "+err.Error(), http.StatusForbidden)
return
}
}
newSettings := rm.Settings
if newSettings == nil {
newSettings = room.DefaultSettings()
}
newSettings.Distributors = toSet
if err := s.roomSvc.Store().UpdateRoomSettings(r.Context(), roomName, newSettings); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true, "distributors": *toSet, "effective": room.ParseDistributors(*toSet)})
case http.MethodDelete:
if !s.isGlobalAdmin(r) {
if err := s.roomSvc.EnsurePermission(r.Context(), roomName, actorName(r), room.PermManageRoom); err != nil {
http.Error(w, "forbidden: "+err.Error(), http.StatusForbidden)
return
}
}
newSettings := rm.Settings
if newSettings == nil {
newSettings = room.DefaultSettings()
}
newSettings.Distributors = nil
if err := s.roomSvc.Store().UpdateRoomSettings(r.Context(), roomName, newSettings); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true, "cleared": true, "effective": s.cfg.DistributorList()})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func stringOr(cur string, override *string) string {
if override != nil {
return *override
}
return cur
}
func (s *Server) handleManageMemberBan(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
}
if !s.isGlobalAdmin(r) {
if err := s.roomSvc.BanMember(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})
return
}
m, err := s.roomSvc.Store().GetMember(r.Context(), roomName, target)
if err != nil {
m = room.NewMember(roomName, target, room.RoleGuest)
}
m.Status = room.MemberBanned
if err := s.roomSvc.Store().UpsertMember(r.Context(), m); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}

View File

@ -0,0 +1,96 @@
package server
import (
"encoding/json"
"net/http"
)
func (s *Server) handleManageRuntimeConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
raw := r.URL.Query().Get("raw") == "1"
view := s.cfg.ViewRuntime(!raw)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(view)
case http.MethodPut, http.MethodPost, http.MethodPatch:
// read raw bytes first to allow both shapes
bodyBytes := make([]byte, 0, 4096)
buf := make([]byte, 4096)
for {
n, err := r.Body.Read(buf)
if n > 0 {
bodyBytes = append(bodyBytes, buf[:n]...)
}
if err != nil {
break
}
}
var patch map[string]string
var wrapped struct {
Values map[string]string `json:"values"`
}
if err := json.Unmarshal(bodyBytes, &wrapped); err == nil && wrapped.Values != nil && len(wrapped.Values) > 0 {
patch = wrapped.Values
} else {
// flat map fallback
_ = json.Unmarshal(bodyBytes, &patch)
}
if len(patch) == 0 {
http.Error(w, "values required (send {values: {KEY: value}} or flat {KEY: value})", http.StatusBadRequest)
return
}
changed, err := s.cfg.ApplyRuntimePatch(patch)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.cfg.SaveRuntime(); err != nil {
http.Error(w, "save: "+err.Error(), http.StatusInternalServerError)
return
}
s.ApplyRuntimeConfig()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true, "changed": changed, "values": s.cfg.ViewRuntime(false).Values})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleManageRuntimeConfigReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Keys []string `json:"keys"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if len(body.Keys) == 0 {
// reset all: clear overrides via ApplyRuntimePatch for each key to trigger revert
all := s.cfg.ViewRuntime(false).Values
patch := map[string]string{}
for k := range all {
patch[k] = ""
}
if _, err := s.cfg.ApplyRuntimePatch(patch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else {
patch := map[string]string{}
for _, k := range body.Keys {
patch[k] = ""
}
if _, err := s.cfg.ApplyRuntimePatch(patch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if err := s.cfg.SaveRuntime(); err != nil {
http.Error(w, "save: "+err.Error(), http.StatusInternalServerError)
return
}
s.ApplyRuntimeConfig()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

View File

@ -30,6 +30,7 @@ type Server struct {
cfg *config.Config
svc *Service
hub *roomHub
cdnP *cdn.Provider
srsProxy http.Handler
cfProxy http.Handler
srsHlsProxy http.Handler
@ -63,8 +64,9 @@ func New(cfg *config.Config) *Server {
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
cdnP := cdn.NewProvider(cfg.CDNRTMPURL, cfg.CDNName)
svc := NewService(cfg, cf, srsP, hub)
svc.SetDistributionManager(newDistributionManager(hub, cf, srsP, cdnP, cfg.DistributorList()))
s := &Server{cfg: cfg, svc: svc, hub: hub, roomStore: rs, roomSvc: rsvc}
svc.cdnP = cdnP
svc.SetDistributionManager(newDistributionManagerWithConfig(hub, cf, srsP, cdnP, cfg, rs))
s := &Server{cfg: cfg, svc: svc, hub: hub, cdnP: cdnP, roomStore: rs, roomSvc: rsvc}
s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler()
s.srsHlsProxy = s.srsHlsProxyHandler()
@ -82,17 +84,18 @@ func New(cfg *config.Config) *Server {
}
func NewWithHub(cfg *config.Config, hub *roomHub) *Server {
cf := cloudflare.NewProvider(cfg.CFAppID, cfg.CFAppSecret, cfg.CFBaseURL, cfg.CFStunURL)
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
cdnP := cdn.NewProvider(cfg.CDNRTMPURL, cfg.CDNName)
svc := NewService(cfg, cf, srsP, hub)
svc.SetDistributionManager(newDistributionManager(hub, cf, srsP, cdnP, cfg.DistributorList()))
rs := room.NewStore(nil)
if hub != nil && hub.DB() != nil {
rs = room.NewStore(hub.DB())
_ = room.InitSchema(hub.DB())
_ = room.InitPlaylistSchema(hub.DB())
}
cf := cloudflare.NewProvider(cfg.CFAppID, cfg.CFAppSecret, cfg.CFBaseURL, cfg.CFStunURL)
srsP := srs.NewProvider(cfg.SRSBaseURL, cfg.SRSApp, cfg.SRSSecret, cfg.SRSCandidate)
cdnP := cdn.NewProvider(cfg.CDNRTMPURL, cfg.CDNName)
svc := NewService(cfg, cf, srsP, hub)
svc.cdnP = cdnP
svc.SetDistributionManager(newDistributionManagerWithConfig(hub, cf, srsP, cdnP, cfg, rs))
s := &Server{cfg: cfg, svc: svc, hub: hub, roomStore: rs, roomSvc: room.NewService(rs)}
s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler()
@ -107,6 +110,31 @@ func NewWithHub(cfg *config.Config, hub *roomHub) *Server {
return s
}
func (s *Server) ApplyRuntimeConfig() {
if s.svc == nil || s.cfg == nil {
return
}
if s.svc.cf != nil {
s.svc.cf.ApplyConfig(s.cfg.CFAppID, s.cfg.CFAppSecret, s.cfg.CFBaseURL, s.cfg.CFStunURL)
}
if s.svc.srsP != nil {
s.svc.srsP.ApplyConfig(s.cfg.SRSBaseURL, s.cfg.SRSHttpURL, s.cfg.SRSApp, s.cfg.SRSSecret, s.cfg.SRSCandidate)
}
if s.svc.cdnP != nil {
s.svc.cdnP.ApplyConfig(s.cfg.CDNRTMPURL, s.cfg.CDNName)
} else if s.cdnP != nil {
s.cdnP.ApplyConfig(s.cfg.CDNRTMPURL, s.cfg.CDNName)
}
if dm, ok := s.svc.distributionManager.(*distributionManager); ok {
dm.UpdateEnabled(s.cfg.DistributorList())
dm.SetConfig(s.cfg)
dm.SetRoomStore(s.roomStore)
}
s.srsProxy = s.srsProxyHandler()
s.cfProxy = s.cfProxyHandler()
s.srsHlsProxy = s.srsHlsProxyHandler()
}
func (s *Server) Auth() *auth.Manager { return s.auth }
func (s *Server) StartGRPC() error {
@ -202,11 +230,21 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/manage/rooms/{room}/members/{user}/perms", s.authWrap(http.HandlerFunc(s.handleManageMemberPerms), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/members/{user}/approve", s.authWrap(http.HandlerFunc(s.handleManageMemberApprove), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/members/{user}/reject", s.authWrap(http.HandlerFunc(s.handleManageMemberReject), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/members/{user}/ban", s.authWrap(http.HandlerFunc(s.handleManageMemberBan), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/transfer", s.authWrap(http.HandlerFunc(s.handleManageTransferOwnership), "room", "list", true))
mux.Handle("GET /api/manage/config", s.authWrap(http.HandlerFunc(s.handleManageRuntimeConfig), "config", "read", true))
mux.Handle("PUT /api/manage/config", s.authWrap(http.HandlerFunc(s.handleManageRuntimeConfig), "config", "write", true))
mux.Handle("POST /api/manage/config", s.authWrap(http.HandlerFunc(s.handleManageRuntimeConfig), "config", "write", true))
mux.Handle("POST /api/manage/config/reset", s.authWrap(http.HandlerFunc(s.handleManageRuntimeConfigReset), "config", "write", 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("GET /api/manage/rooms/{room}/distribution", s.authWrap(http.HandlerFunc(s.handleManageRoomDistribution), "room", "list", true))
mux.Handle("PUT /api/manage/rooms/{room}/distribution", s.authWrap(http.HandlerFunc(s.handleManageRoomDistribution), "room", "list", true))
mux.Handle("POST /api/manage/rooms/{room}/distribution", s.authWrap(http.HandlerFunc(s.handleManageRoomDistribution), "room", "list", true))
mux.Handle("PATCH /api/manage/rooms/{room}/distribution", s.authWrap(http.HandlerFunc(s.handleManageRoomDistribution), "room", "list", true))
mux.Handle("DELETE /api/manage/rooms/{room}/distribution", s.authWrap(http.HandlerFunc(s.handleManageRoomDistribution), "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))
@ -221,10 +259,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /api/cf/", s.authWrap(s.cfProxy, "room", "publish", false))
// SRS HLS/FLV 静态切片反代(:8080),前端经 /live/* 拉流
mux.Handle("GET /live/", s.authWrap(s.srsHlsProxy, "srs", "streams", false))
mux.Handle("HEAD /live/", s.authWrap(s.srsHlsProxy, "srs", "streams", false))
// CDN HLS 占位(未配置返回 502 JSON,便于前端区分)
mux.Handle("GET /live/cdn/", s.authWrap(s.cdnProxyHandler(), "srs", "streams", false))
mux.Handle("HEAD /live/cdn/", s.authWrap(s.cdnProxyHandler(), "srs", "streams", false))
return mux
}

View File

@ -8,6 +8,7 @@ import (
"sync-live/gen"
"sync-live/internal/auth"
"sync-live/internal/config"
"sync-live/internal/sfu/cdn"
"sync-live/internal/sfu/cloudflare"
"sync-live/internal/sfu/srs"
)
@ -17,6 +18,7 @@ type Service struct {
cfg *config.Config
cf *cloudflare.Provider
srsP *srs.Provider
cdnP *cdn.Provider
hub *roomHub
auth *auth.Manager
distributionManager DistributionManagerIface
@ -33,6 +35,10 @@ func NewService(cfg *config.Config, cf *cloudflare.Provider, srsP *srs.Provider,
return &Service{cfg: cfg, cf: cf, srsP: srsP, hub: hub}
}
func NewServiceWithCDN(cfg *config.Config, cf *cloudflare.Provider, srsP *srs.Provider, cdnP *cdn.Provider, hub *roomHub) *Service {
return &Service{cfg: cfg, cf: cf, srsP: srsP, cdnP: cdnP, hub: hub}
}
func (s *Service) SetAuth(a *auth.Manager) { s.auth = a }
func (s *Service) checkAuth(ctx context.Context, obj, act string) error {

View File

@ -0,0 +1 @@
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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`}));export{i as t};

View File

@ -1 +1 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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};
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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

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{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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};

View File

@ -0,0 +1 @@
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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,_ as n}from"./ui-BjxGELcM.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{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};

View File

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

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 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>t(n,e(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>t(n,e(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>t(n,e(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};

View File

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

View File

@ -1 +0,0 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.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

File diff suppressed because one or more lines are too long

View File

@ -19,9 +19,9 @@
}
})()
</script>
<script type="module" crossorigin src="/static/assets/index-BsK239Bd.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-BjxGELcM.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-Ub-SlVMy.css">
<script type="module" crossorigin src="/static/assets/index-Cfr72k3j.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-CBKBDzcl.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-CRlWlaHg.css">
</head>
<body>
<div id="root"></div>

View File

@ -28,6 +28,13 @@ func (p *Provider) RTMPURL() string { return p.rtmpURL }
// Forward returns the RTMP push URL for the given stream.
// In the current implementation this is a stub that just composes the URL and returns ready status.
// Future: actually trigger SRS forward API or external pusher.
func (p *Provider) ApplyConfig(rtmpURL, name string) {
p.rtmpURL = rtmpURL
if name != "" {
p.name = name
}
}
func (p *Provider) Forward(ctx context.Context, room, stream string) (*ForwardResult, error) {
if !p.Configured() {
return nil, fmt.Errorf("cdn not configured: CDN_RTMP_URL empty")

View File

@ -58,6 +58,16 @@ func (p *Provider) Stun() string {
return p.stun
}
func (p *Provider) ApplyConfig(appID, appSecret, baseURL, stun string) {
p.appID = appID
// recreate client with new credentials/base
p.client = NewClient(appID, appSecret, baseURL)
if stun != "" {
p.stun = stun
}
p.configured = appID != "" && appSecret != ""
}
func (p *Provider) BackendInfo() *gen.BackendInfo {
return &gen.BackendInfo{
Kind: gen.BackendKind_BACKEND_KIND_CLOUDFLARE,

View File

@ -9,16 +9,21 @@ import (
// Provider 暴露 SRS 后端能力(本地 docker 默认可达)。
type Provider struct {
baseURL string
app string
secret string
candidate string
baseURL string
httpBaseURL string
app string
secret string
candidate string
}
func NewProvider(baseURL, app, secret, candidate string) *Provider {
return &Provider{baseURL: baseURL, app: app, secret: secret, candidate: candidate}
}
func NewProviderWithHTTP(baseURL, httpBaseURL, app, secret, candidate string) *Provider {
return &Provider{baseURL: baseURL, httpBaseURL: httpBaseURL, app: app, secret: secret, candidate: candidate}
}
func (p *Provider) Name() string { return "srs" }
func (p *Provider) Primary() bool { return false }
func (p *Provider) Configured() bool { return true }
@ -43,6 +48,30 @@ func (p *Provider) App() string { return p.app }
func (p *Provider) Secret() string { return p.secret }
func (p *Provider) Candidate() string { return p.candidate }
func (p *Provider) ApplyConfig(baseURL, httpURL, app, secret, candidate string) {
if baseURL != "" {
p.baseURL = baseURL
}
if httpURL != "" {
p.httpBaseURL = httpURL
}
if app != "" {
p.app = app
}
p.secret = secret
if candidate != "" {
p.candidate = candidate
}
}
func (p *Provider) ApplyConfigHTTP(httpURL string) {
if httpURL != "" {
p.httpBaseURL = httpURL
}
}
func (p *Provider) HTTPBaseURL() string { return p.httpBaseURL }
func (p *Provider) BackendInfo() *gen.BackendInfo {
return &gen.BackendInfo{
Kind: gen.BackendKind_BACKEND_KIND_SRS,

BIN
web/data/sync-live.db Normal file

Binary file not shown.

View File

@ -4,7 +4,6 @@ import { Layout } from './routes/layout'
// 路由级 code-splitting:hls.js / 各页面按需加载,缩小首屏 chunk
const Home = lazy(() => import('./routes/index').then((m) => ({ default: m.Home })))
const Publish = lazy(() => import('./routes/publish').then((m) => ({ default: m.Publish })))
const WatchRoute = lazy(() => import('./routes/watch').then((m) => ({ default: m.WatchRoute })))
const RoomRoute = lazy(() => import('./routes/watch').then((m) => ({ default: m.RoomRoute })))
const Rooms = lazy(() => import('./routes/rooms').then((m) => ({ default: m.Rooms })))
@ -16,13 +15,6 @@ const rootRoute = new RootRoute({ component: Layout })
const homeRoute = new Route({ getParentRoute: () => rootRoute, path: '/', component: Home as any })
const publishRoute = new Route({
getParentRoute: () => rootRoute,
path: '/publish',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' && s.room.trim() !== '' ? s.room : '' }),
component: Publish as any,
})
const watchRoute = new Route({
getParentRoute: () => rootRoute,
path: '/watch',
@ -46,7 +38,6 @@ const loginRoute = new Route({ getParentRoute: () => rootRoute, path: '/login',
export const routeTree = rootRoute.addChildren([
homeRoute,
publishRoute,
watchRoute,
roomRoute,
roomsRoute,

View File

@ -7,8 +7,7 @@ import {
MonitorPlay,
RadioTower,
Server,
ShieldCheck,
Video,
ShieldCheck
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
@ -177,22 +176,14 @@ export const Home: Component = () => {
进入房间
<ArrowRight class="size-4" />
</Button>
<Button variant="outline" class="h-11" onClick={() => go('/publish')}>
<Video class="size-4" />
开始直播
</Button>
</div>
<Show when={permissions.isAuthed()}>
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/70 pt-3">
<span class="mono-label flex items-center gap-1.5 text-muted-foreground">
role <span class="text-acid-cyan">{permissions.role()}</span>
</span>
<span class="mono-label text-muted-foreground">
publish ·{' '}
<span class={permissions.can('room:publish') ? 'text-success' : 'opacity-50'}>
{permissions.can('room:publish') ? 'ON' : 'OFF'}
</span>
</span>
<span class="mono-label text-muted-foreground">
chat ·{' '}
<span class={permissions.can('room:chat') ? 'text-success' : 'opacity-50'}>
@ -285,13 +276,7 @@ export const Home: Component = () => {
>
进入
</Button>
<Button
size="sm"
class="flex-1 sm:flex-none"
onClick={() => navigate({ to: '/publish', search: { room: r.name } })}
>
直播
</Button>
</div>
</div>
</Reveal>

View File

@ -4,6 +4,7 @@ import { RadioTower, Crown, Ban } from 'lucide-solid'
import { refreshMe, auth } from '../../lib/auth'
import { permissions } from '../../lib/permissions'
import { ThemeToggle } from '../../components/ui'
import { AppErrorBoundary } from '../../components/error-boundary'
interface NavItem {
to: string
@ -16,10 +17,8 @@ interface NavItem {
const NAV: NavItem[] = [
{ to: '/', idx: '01', label: '概览' },
{ to: '/rooms', idx: '02', label: '房间' },
{ to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] },
{ to: '/publish', idx: '04', label: '推流' },
{ to: '/users', idx: '05', label: '用户', adminOnly: true },
{ to: '/manage', idx: '06', label: '管理', adminOnly: true },
{ to: '/users', idx: '03', label: '用户', adminOnly: true },
{ to: '/manage', idx: '04', label: '管理', adminOnly: true },
]
export const Layout: Component = () => {
@ -130,7 +129,9 @@ export const Layout: Component = () => {
</header>
<main class="relative z-10">
<Outlet />
<AppErrorBoundary class="mx-auto mt-6 max-w-[1400px] sm:px-6" onRetry={() => window.location.reload()}>
<Outlet />
</AppErrorBoundary>
</main>
<footer class="relative z-10 mt-16 border-t border-line/80 bg-background/70 backdrop-blur-sm">

View File

@ -10,9 +10,10 @@ import { ManageOverview } from '../../components/manage/ManageOverview'
import { ManageRooms } from '../../components/manage/ManageRooms'
import { ManagePermissions } from '../../components/manage/ManagePermissions'
import { ManageSystem } from '../../components/manage/ManageSystem'
import { ManageUsersInline } from '../../components/manage/ManageUsersInline'
import { ALL_PERMS } from '../../components/manage/constants'
type Tab = 'overview' | 'rooms' | 'permissions' | 'system'
type Tab = 'overview' | 'rooms' | 'permissions' | 'users' | 'system'
export const Manage: Component = () => {
const [tab, setTab] = createSignal<Tab>('overview')
@ -33,15 +34,19 @@ export const Manage: Component = () => {
const [editDesc, setEditDesc] = createSignal('')
const [editMax, setEditMax] = createSignal('')
const [roomSettings, setRoomSettings] = createSignal<any>(null)
const [distribution, setDistribution] = createSignal<any>(null)
const [users, setUsers] = createSignal<any[]>([])
const [usersBusy, setUsersBusy] = createSignal<string>('')
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) { /* guest 无权时静默 */ } }
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)) }
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); try { const d = await api.manageRoomDistribution(room); setDistribution(d) } catch { setDistribution(null) } } catch (e: any) { setErr(String(e?.message || e)) }
}
createEffect(() => { refreshOverview(); refreshRooms(); refreshPerms() })
createEffect(() => { refreshOverview(); refreshRooms(); refreshPerms(); refreshUsers() })
createEffect(() => { const r = selectedRoom(); if (r && (tab() === 'rooms' || tab() === 'permissions')) refreshMembers(r) })
const createRoom = async () => {
@ -52,6 +57,10 @@ export const Manage: Component = () => {
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 banMember = async (room: string, username: string) => { try { await api.manageBanMember(room, username); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) } }
const approve = async (room: string, username: string) => { try { await api.manageApproveMember(room, username); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) } }
const reject = async (room: string, username: string) => { try { await api.manageRejectMember(room, username); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) } }
const transfer = async (room: string, username: string) => { try { await api.manageTransferOwnership(room, username); await refreshMembers(room); await refreshRooms() } 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); const m = members().find((x) => x.username === user); if (!m) return
let added = Number(m.added || 0), removed = Number(m.removed || 0)
@ -59,21 +68,43 @@ export const Manage: Component = () => {
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)) } }
const toggleDistribution = async (kind: string, enabled: boolean) => {
const r = selectedRoom(); if (!r) return
const eff = new Set<string>(distribution()?.effective || [])
if (enabled) eff.add(kind); else eff.delete(kind)
const list = Array.from(eff)
try { const res = await api.manageUpdateRoomDistributionList(r, list); setDistribution((prev: any) => ({ ...(prev || {}), effective: res.effective, custom: res.distributors, has_custom: true, custom_list: res.effective })); await refreshMembers(r) } catch (e: any) { setErr(String(e?.message || e)) }
}
const clearDistribution = async () => {
const r = selectedRoom(); if (!r) return
try { const res = await api.manageClearRoomDistribution(r); setDistribution((prev: any) => ({ ...(prev || {}), effective: res.effective, has_custom: false, custom: null, custom_list: [] })); await refreshMembers(r) } catch (e: any) { setErr(String(e?.message || e)) }
}
const handleUserRoleChange = async (u: any, v: string) => {
setUsersBusy(u.username)
try { await api.updateUserRole(u.username, v as any); setUsers((p) => p.map((x) => x.username === u.username ? { ...x, role: v } : x)); await permissions.refresh() } catch (e: any) { setErr(String(e?.message || e)) } finally { setUsersBusy('') }
}
const handleToggleBan = async (u: any) => {
const isBanned = (u as any).status === 'banned'
if (!confirm(isBanned ? `解封 ${u.username}?` : `封禁 ${u.username}?封禁后无法登录`)) return
setUsersBusy(u.username)
try { if (isBanned) await api.unbanUser(u.username); else await api.banUser(u.username); await refreshUsers() } catch (e: any) { setErr(String(e?.message || e)) } finally { setUsersBusy('') }
}
return (
<ManagePage>
<ManageHeader
title="管理控制台"
description="统一管理房间、角色与权限。房主全量,管理员/成员/访客基于角色默认值 + 个人覆盖计算生效权限。"
actions={<><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(); if (selectedRoom()) refreshMembers(selectedRoom()) }}><RefreshCw class="size-4" />刷新</Button></>}
description="统一管理房间、角色与权限。房主全量,管理员/成员/访客基于角色默认值 + 个人覆盖计算生效权限。全局管理员(admin/root)可跨房间管理,无需加入房间。"
actions={<><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(); if (selectedRoom()) refreshMembers(selectedRoom()) }}><RefreshCw class="size-4" />刷新</Button></>}
/>
<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>
<ManageNav value={tab()} onChange={setTab as any} />
<Show when={tab() === 'overview'}><ManageOverview overview={overview()} rooms={rooms()} /></Show>
<Show when={tab() === 'rooms'}>
<ManageRooms rooms={rooms()} selectedRoom={selectedRoom()} onSelectRoom={setSelectedRoom} members={members()} editingRoom={editingRoom()} roomSettings={roomSettings()} setRoomSettings={setRoomSettings as any} newRoom={{ name: newRoomName(), desc: newRoomDesc(), isPublic: newRoomPublic(), max: newRoomMax(), pwd: newRoomPwd(), showPwd: showPwd() }} setNewRoom={(p) => { if (p.name !== undefined) setNewRoomName(p.name); if (p.desc !== undefined) setNewRoomDesc(p.desc); if (p.isPublic !== undefined) setNewRoomPublic(p.isPublic); if (p.max !== undefined) setNewRoomMax(p.max); if (p.pwd !== undefined) setNewRoomPwd(p.pwd); if (p.showPwd !== undefined) setShowPwd(p.showPwd) }} edit={{ desc: editDesc(), max: editMax() }} setEdit={(p) => { if (p.desc !== undefined) setEditDesc(p.desc); if (p.max !== undefined) setEditMax(p.max) }} loading={loading()} onCreate={createRoom} onSave={saveRoom} onDelete={delRoom} onChangeRole={changeRole} onKick={kick} onSaveSettings={saveSettings} />
<ManageRooms rooms={rooms()} selectedRoom={selectedRoom()} onSelectRoom={setSelectedRoom} members={members()} editingRoom={editingRoom()} roomSettings={roomSettings()} setRoomSettings={setRoomSettings as any} newRoom={{ name: newRoomName(), desc: newRoomDesc(), isPublic: newRoomPublic(), max: newRoomMax(), pwd: newRoomPwd(), showPwd: showPwd() }} setNewRoom={(p) => { if (p.name !== undefined) setNewRoomName(p.name); if (p.desc !== undefined) setNewRoomDesc(p.desc); if (p.isPublic !== undefined) setNewRoomPublic(p.isPublic); if (p.max !== undefined) setNewRoomMax(p.max); if (p.pwd !== undefined) setNewRoomPwd(p.pwd); if (p.showPwd !== undefined) setShowPwd(p.showPwd) }} edit={{ desc: editDesc(), max: editMax() }} setEdit={(p) => { if (p.desc !== undefined) setEditDesc(p.desc); if (p.max !== undefined) setEditMax(p.max) }} loading={loading()} onCreate={createRoom} onSave={saveRoom} onDelete={delRoom} onChangeRole={changeRole} onKick={kick} onBan={banMember} onApprove={approve} onReject={reject} onTransfer={transfer} onSaveSettings={saveSettings} distribution={distribution()} onToggleDistribution={toggleDistribution} onClearDistribution={clearDistribution} />
</Show>
<Show when={tab() === 'permissions'}><ManagePermissions rooms={rooms()} selectedRoom={selectedRoom()} onSelectRoom={setSelectedRoom} members={members()} permDefs={permDefs()} onTogglePerm={togglePerm} /></Show>
<Show when={tab() === 'users'}><ManageUsersInline users={users()} onRoleChange={handleUserRoleChange} onToggleBan={handleToggleBan} canList={permissions.can('user:list')} actorRole={permissions.role()} busy={usersBusy()} /></Show>
<Show when={tab() === 'system'}><ManageSystem overview={overview()} /></Show>
</ManagePage>
)

View File

@ -1,322 +0,0 @@
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch } from '@tanstack/solid-router'
import {
CircleStop,
Cloud,
FileVideo,
Globe,
MessageSquare,
Server,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, CheckboxRow, Corners, LiveDot, Meter, Reveal } from '../../components/ui'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import { subscribeRoom } from '../../lib/sse'
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { whipPublish, toRTC } from '../../lib/webrtc'
import type { DistributorInfo, RoomEvent, StreamTarget } from '../../lib/types'
function distributorLabel(kind: string): string {
if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return 'SRS HLS 直推'
if (kind === 'DISTRIBUTION_KIND_CF_SFU') return 'Cloudflare SFU'
if (kind === 'DISTRIBUTION_KIND_CDN') return '第三方 CDN'
return kind
}
function distributorIcon(kind: string) {
if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return Server
if (kind === 'DISTRIBUTION_KIND_CF_SFU') return Cloud
if (kind === 'DISTRIBUTION_KIND_CDN') return Globe
return Server
}
export const Publish: Component = () => {
const search = useSearch({ from: '/publish' })
const room = () => search().room
const [distributors, setDistributors] = createSignal<DistributorInfo[]>([])
const [trunkName, setTrunkName] = createSignal('SRS')
const [stream, setStream] = createSignal<MediaStream | null>(null)
const [running, setRunning] = createSignal(false)
const [targets, setTargets] = createSignal<StreamTarget[]>([])
const [error, setError] = createSignal('')
const [msgs, setMsgs] = createSignal<ChatMessage[]>([])
const [showDanmaku, setShowDanmaku] = createSignal(true)
let videoEl: HTMLVideoElement | undefined
let pc: RTCPeerConnection | undefined
onMount(() => {
api.getConfig().then((c) => {
setDistributors(c.distributors || [])
if (c.trunk) setTrunkName(c.trunk.name || 'SRS')
}).catch(() => {})
const stopRoom = subscribeRoom(room(), (ev: RoomEvent) => {
setTargets(ev.targets || [])
})
const stopChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
stopRoom()
stopChat()
})
})
const send = (text: string, color?: string) => {
sendChat(room(), text, color).catch(() => {})
}
const start = async () => {
if (!permissions.can('room:publish')) {
setError('当前角色无权发布(需要 room:publish)')
return
}
setError('')
setRunning(true)
try {
if (!stream()) {
const ms = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
setStream(ms)
if (videoEl) videoEl.srcObject = ms
}
const ms = stream()!
// SRS trunk: single WHIP publish
const resp = await api.publish(room(), 'BACKEND_KIND_SRS', '')
pc = await whipPublish({
stream: resp.stream!,
token: resp.publish_token!,
local: ms,
iceServers: (resp.ice_servers || []).map(toRTC),
})
} catch (e: any) {
setError(String(e?.message || e))
setRunning(false)
}
}
const stopAll = async () => {
try { pc?.close() } catch {}
pc = undefined
for (const t of targets()) {
try {
await api.stop(room(), t.backend)
} catch {}
}
if (stream()) {
stream()!.getTracks().forEach((t) => t.stop())
setStream(null)
}
setTargets([])
setRunning(false)
}
onCleanup(stopAll)
return (
<div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
<Reveal>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="mono-label text-muted-foreground">publish / {room()}</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
推流仅进入 SRS 主干(WHIP),分发由服务端按配置扇出到多路。
</p>
</div>
<div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5">
<LiveDot active={running()} />
<span class="mono-label">{running() ? '推流中 / live' : '待机 / standby'}</span>
<Meter active={running()} />
</div>
</div>
</Reveal>
<Show when={!permissions.can('room:publish')}>
<Alert tone="warning">
当前角色({permissions.role()})无权发布,仅 admin / publisher 可推流。可切换到「观看」进入房间发弹幕。
</Alert>
</Show>
<Show when={error()}>
<Alert tone="error">{error()}</Alert>
</Show>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Reveal delay={1} class="min-w-0">
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
<Corners />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Video class="size-4 text-acid-lime" />
<h3 class="font-semibold">本地预览</h3>
<span class="micro-label text-muted-foreground">preview · trunk: {trunkName()}</span>
</div>
<div class="flex items-center gap-3">
<span class="font-mono text-[11px] text-muted-foreground">
{stream()
? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频`
: 'tracks · off'}
</span>
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
</div>
</div>
<div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline muted class="size-full" />
<Show when={showDanmaku()}>
<DanmakuOverlay messages={msgs} />
</Show>
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
<Badge variant={running() ? 'success' : 'outline'} class="bg-black/50 shadow-sm backdrop-blur-sm">
<LiveDot active={running()} />
{running() ? 'LIVE' : 'STANDBY'}
</Badge>
<Badge variant="secondary" class="border-line bg-black/50 text-white/90 shadow-sm backdrop-blur-sm">
SRS WHIP
</Badge>
</div>
<Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60">
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
<Video class="size-5" />
</span>
<span class="micro-label text-white/50">camera off</span>
</div>
</Show>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button class="h-10" onClick={start} disabled={running() || !permissions.can('room:publish')}>
<Video class="size-4" />
开始推流
</Button>
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
<CircleStop class="size-4" />
停止推流
</Button>
</div>
</div>
</Reveal>
<Reveal delay={2} class="min-w-0 self-start">
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Server class="size-4 text-acid-lime" />
<h3 class="font-semibold">分发方式</h3>
<span class="micro-label text-muted-foreground">distributions</span>
</div>
<Badge variant="muted">{distributors().length} 路</Badge>
</div>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">由服务端按 DISTRIBUTORS 配置自动扇出,推流端无需选择后端。</p>
<div class="mt-4 space-y-2.5">
<Show
when={distributors().length > 0}
fallback={<p class="text-sm text-muted-foreground">正在读取分发配置…</p>}
>
<For each={distributors()}>
{(d) => {
const Icon = distributorIcon(d.kind)
return (
<div
class={`flex items-center justify-between gap-3 border p-3 transition-colors ${
d.configured ? 'border-line bg-background/50' : 'border-line/60 bg-background/20 opacity-70'
}`}
>
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center border border-line bg-background text-foreground/80">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<p class="text-sm font-medium">{d.name}</p>
<p class="micro-label mt-1 text-muted-foreground">
{distributorLabel(d.kind)} · {d.enabled ? '已启用' : '未启用'}
</p>
</div>
</div>
<Badge variant={d.configured ? 'success' : 'muted'}>
{d.configured ? '就绪' : '未配置'}
</Badge>
</div>
)
}}
</For>
</Show>
</div>
<p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground">
主干为 SRS,推流凭证仅在服务端会话中生效。
</p>
</div>
</Reveal>
</div>
<Reveal delay={1}>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center gap-2">
<FileVideo class="size-4 text-acid-lime" />
<h3 class="font-semibold">主干状态</h3>
<span class="micro-label text-muted-foreground">trunk status</span>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
尚未开始推流
</div>
}
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="relative border border-line/80 bg-background/40 p-4">
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">SRS 主干</span>
<Badge variant="success">
<LiveDot active />
直播中
</Badge>
</div>
<Show when={t.stream}>
<div class="mt-3 flex flex-wrap gap-2">
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
HLS
</a>
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
FLV
</a>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</Reveal>
<Reveal delay={2}>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-acid-lime" />
<h3 class="font-semibold">房间弹幕 / 聊天</h3>
<span class="micro-label text-muted-foreground">danmaku</span>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="mt-4 h-80 min-h-0">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</div>
</Reveal>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
import { Component, createSignal, createEffect, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch, useNavigate } from '@tanstack/solid-router'
import {
Cloud,
@ -10,7 +10,6 @@ import {
MonitorPlay,
Radio,
Square,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
@ -39,7 +38,6 @@ function distText(kind: string) {
}
function RoomHub(props: { room: string }) {
const navigate = useNavigate()
const room = () => props.room
const [mode, setMode] = createSignal<Mode>('srs-hls')
const [running, setRunning] = createSignal(false)
@ -138,16 +136,7 @@ function RoomHub(props: { room: string }) {
{targets().length} trunk · {dists().length} 分发 · {targets().length > 0 ? 'live' : 'idle'}
</span>
</div>
<Show when={permissions.can('room:publish')}>
<Button
variant="outline"
class="h-10"
onClick={() => navigate({ to: '/publish', search: { room: room() } })}
>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
</div>
</Reveal>
@ -317,10 +306,20 @@ function PlayIcon() {
export const WatchRoute: Component = () => {
const search = useSearch({ from: '/watch' })
return <RoomHub room={search().room} />
const navigate = useNavigate()
const room = () => search().room
createEffect(() => {
if (!room()) navigate({ to: '/rooms' })
})
return <Show when={!!room()} fallback={null}><RoomHub room={room()} /></Show>
}
export const RoomRoute: Component = () => {
const search = useSearch({ from: '/room' })
return <RoomHub room={search().room} />
const navigate = useNavigate()
const room = () => search().room
createEffect(() => {
if (!room()) navigate({ to: '/rooms' })
})
return <Show when={!!room()} fallback={null}><RoomHub room={room()} /></Show>
}

View File

@ -0,0 +1,265 @@
import { ErrorBoundary, Show, createSignal } from 'solid-js'
import type { JSX } from 'solid-js'
import { CircleAlert, LogIn, RotateCw } from 'lucide-solid'
import { normalizeError } from '../lib/errors'
import type { AppError } from '../lib/errors'
export type ErrorSeverity = 'danger' | 'warning' | 'info'
export interface ErrorContent {
title: string
description: string
severity?: ErrorSeverity
actionLabel?: string
actionHref?: string
}
export type ErrorContentMap = Record<string | number, Partial<ErrorContent>>
interface ResolvedErrorContent extends Required<Omit<ErrorContent, 'actionHref'>> {
actionHref?: string
codeLabel: string
}
const DEFAULT_CONTENT: Record<string, ErrorContent> = {
UNAUTHENTICATED: {
title: '登录已过期',
description: '请重新登录后再继续操作。',
severity: 'warning',
actionLabel: '去登录',
actionHref: '/login',
},
TOKEN_EXPIRED: {
title: '登录状态失效',
description: '当前凭证已过期,请重新登录。',
severity: 'warning',
actionLabel: '重新登录',
actionHref: '/login',
},
PERMISSION_DENIED: {
title: '没有访问权限',
description: '你的账号角色无法使用这个功能。',
severity: 'warning',
},
ACCESS_DENIED: {
title: '没有访问权限',
description: '请联系管理员获取所需权限。',
severity: 'warning',
},
ACCOUNT_BANNED: {
title: '账号不可用',
description: '账号已被限制使用,请联系管理员处理。',
severity: 'warning',
},
NOT_FOUND: {
title: '内容不存在',
description: '请求的资源可能已被删除或从未存在。',
severity: 'info',
},
VALIDATION_ERROR: {
title: '提交内容有误',
description: '请检查输入内容后重试。',
severity: 'info',
},
RATE_LIMITED: {
title: '操作过于频繁',
description: '请稍等片刻后再试。',
severity: 'warning',
},
UPSTREAM_ERROR: {
title: '上游服务异常',
description: '依赖的服务暂时不可用,请稍后重试。',
},
INTERNAL_ERROR: {
title: '服务暂时不可用',
description: '服务器处理请求时出现问题,请稍后重试。',
},
NETWORK_ERROR: {
title: '网络连接异常',
description: '请检查网络连接后重试。',
},
RUNTIME_ERROR: {
title: '页面渲染出错',
description: '前端执行时发生异常,可以尝试重新加载该区域。',
severity: 'info',
},
UNKNOWN: {
title: '出现未知错误',
description: '操作未能完成,请重试。',
},
}
const STATUS_CONTENT: Record<number, keyof typeof DEFAULT_CONTENT> = {
400: 'VALIDATION_ERROR',
401: 'UNAUTHENTICATED',
403: 'PERMISSION_DENIED',
404: 'NOT_FOUND',
408: 'NETWORK_ERROR',
429: 'RATE_LIMITED',
500: 'INTERNAL_ERROR',
502: 'UPSTREAM_ERROR',
503: 'UPSTREAM_ERROR',
504: 'UPSTREAM_ERROR',
}
const SEVERITY_CLASS: Record<ErrorSeverity, string> = {
danger: 'border-destructive/40 bg-destructive/10 text-destructive',
warning: 'border-warning/40 bg-warning/10 text-warning',
info: 'border-acid-cyan/40 bg-acid-cyan/10 text-accent-foreground',
}
function resolveContent(
error: AppError,
overrides: ErrorContentMap | undefined,
): ResolvedErrorContent {
const explicitKey = error.code
const upperKey = explicitKey?.toUpperCase()
const override = explicitKey === undefined ? undefined : overrides?.[explicitKey]
const upperOverride = upperKey === undefined ? undefined : overrides?.[upperKey]
const statusName = error.httpStatus === undefined ? undefined : STATUS_CONTENT[error.httpStatus]
const base =
(explicitKey === undefined ? undefined : DEFAULT_CONTENT[explicitKey]) ??
(upperKey === undefined ? undefined : DEFAULT_CONTENT[upperKey]) ??
(statusName === undefined ? undefined : DEFAULT_CONTENT[statusName]) ??
(error.source === 'network' ? DEFAULT_CONTENT.NETWORK_ERROR : undefined) ??
(error.source === 'runtime' ? DEFAULT_CONTENT.RUNTIME_ERROR : undefined) ??
DEFAULT_CONTENT.UNKNOWN
return {
title: upperOverride?.title ?? override?.title ?? base.title,
description: upperOverride?.description ?? override?.description ?? base.description,
severity:
upperOverride?.severity ??
override?.severity ??
base.severity ??
(error.source === 'runtime' ? 'info' : 'danger'),
actionLabel:
upperOverride?.actionLabel ?? override?.actionLabel ?? base.actionLabel ?? '重试',
actionHref: upperOverride?.actionHref ?? override?.actionHref ?? base.actionHref,
codeLabel: explicitKey ?? (error.httpStatus === undefined ? 'RUNTIME' : `HTTP ${error.httpStatus}`),
}
}
function formatDetail(error: AppError): string {
const lines = [`code: ${error.code ?? '-'}`, `source: ${error.source}`, `message: ${error.message}`]
if (error.httpStatus !== undefined) lines.splice(1, 0, `http_status: ${error.httpStatus}`)
if (error.raw instanceof Error && error.raw.stack) lines.push('', error.raw.stack)
else if (error.raw !== undefined && !(error.raw instanceof Error)) {
try {
lines.push('', JSON.stringify(error.raw, null, 2))
} catch {
// 循环引用等序列化失败时不需要阻塞错误展示。
}
}
return lines.join('\n')
}
export function AppErrorView(props: {
error: unknown
onRetry?: () => void
contentOverrides?: ErrorContentMap
class?: string
}) {
const error = normalizeError(props.error)
const content = resolveContent(error, props.contentOverrides)
const Icon = CircleAlert
const retryButton = (
<button
type="button"
class="btn btn-outline btn-sm"
onClick={() => props.onRetry?.()}
disabled={!props.onRetry}
>
<RotateCw class="size-3.5" />
重试
</button>
)
return (
<div
role="alert"
class={`card flex min-h-52 w-full flex-col justify-center gap-4 p-5 sm:p-7 ${
props.class ?? ''
}`}
>
<div class="flex min-w-0 items-start gap-3">
<span class={`flex size-9 shrink-0 items-center justify-center border ${SEVERITY_CLASS[content.severity]}`}>
<Icon class="size-4" />
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 class="display text-lg leading-tight sm:text-xl">{content.title}</h2>
<span class="badge border-line bg-card/70 text-muted-foreground">{content.codeLabel}</span>
</div>
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{content.description}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show
when={content.actionHref}
fallback={retryButton}
>
<a class="btn btn-primary btn-sm" href={content.actionHref}>
<LogIn class="size-3.5" />
{content.actionLabel}
</a>
{retryButton}
</Show>
</div>
<details class="max-w-3xl border border-line/70 bg-background/50">
<summary class="cursor-pointer px-3 py-2 font-mono text-[11px] tracking-[0.14em] text-muted-foreground uppercase select-none hover:text-foreground">
错误详情
</summary>
<pre class="overflow-auto border-t border-line/70 px-3 py-2.5 text-xs whitespace-pre-wrap break-words text-muted-foreground">
{formatDetail(error)}
</pre>
</details>
</div>
)
}
export function AppErrorBoundary(props: {
children: JSX.Element
/** 按后端业务码覆盖标题、描述、动作或严重级别。 */
contentByCode?: ErrorContentMap
/** 重试前可用来清空本地状态或触发 query refetch。 */
onRetry?: (error: AppError) => void
fallback?: (error: AppError, retry: () => void) => JSX.Element
class?: string
}) {
// 改变 key 能让子树重建,避免复用已经处于异常渲染状态的节点。
const [attempt, setAttempt] = createSignal(1)
const retry = (reset: () => void, error: AppError) => {
props.onRetry?.(error)
setAttempt((value) => value + 1)
reset()
}
return (
<ErrorBoundary
onError={(raw: unknown) => props.onError?.(normalizeError(raw))}
fallback={(raw: unknown, reset: () => void) =>
props.fallback?.(normalizeError(raw), () => retry(reset, normalizeError(raw))) ?? (
<AppErrorView
error={raw}
class={props.class}
contentOverrides={props.contentByCode}
onRetry={() => retry(reset, normalizeError(raw))}
/>
)
}
>
<Show when={attempt()} keyed>
{() => props.children}
</Show>
</ErrorBoundary>
)
}

View File

@ -1,6 +1,6 @@
import { For, Show } from 'solid-js'
import { Badge, Button, LiveDot, Select } from '../../components/ui'
import { Hash, Lock, Eye, EyeOff, Plus, Globe, Save, Trash2, Ban, MessageSquare, UserRound, ShieldAlert, SlidersHorizontal } from 'lucide-solid'
import { Hash, Lock, Eye, EyeOff, Plus, Globe, Save, Trash2, Ban, MessageSquare, UserRound, ShieldAlert, SlidersHorizontal, Crown } from 'lucide-solid'
import { ROLE_META } from './constants'
export function ManageRooms(props: {
@ -21,8 +21,17 @@ export function ManageRooms(props: {
onDelete: (name: string) => void
onChangeRole: (room: string, user: string, role: string) => void
onKick: (room: string, user: string) => void
onBan: (room: string, user: string) => void
onApprove: (room: string, user: string) => void
onReject: (room: string, user: string) => void
onTransfer: (room: string, user: string) => void
onSaveSettings: () => void
distribution?: { global: string[]; custom: string | null; effective: string[]; has_custom: boolean; custom_list: string[] } | null
onToggleDistribution?: (kind: string, enabled: boolean) => void
onClearDistribution?: () => void
}) {
const pending = () => props.members.filter((m: any) => m.status === 'pending')
const active = () => props.members.filter((m: any) => m.status !== 'pending')
return (
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-1 space-y-4">
@ -79,7 +88,7 @@ export function ManageRooms(props: {
<button type="button" class={`flex w-full items-center justify-between gap-3 border-b border-line/60 px-4 py-3 text-left hover:bg-accent/40 ${props.selectedRoom === r.room.name ? 'bg-acid-lime/10' : ''}`} onClick={() => props.onSelectRoom(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>
<p class="truncate text-xs text-muted-foreground">{r.room.description || r.room.creator || '—'}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<LiveDot active={r.live} />
@ -98,6 +107,7 @@ export function ManageRooms(props: {
<div class="min-w-0">
<p class="mono-label text-muted-foreground">room / {props.selectedRoom}</p>
<h3 class="display mt-1 truncate text-2xl">{props.editingRoom?.display_name || props.selectedRoom}</h3>
<p class="text-xs text-muted-foreground">创建者 {props.editingRoom?.creator || '-'} · 状态 {props.editingRoom?.status || 'active'}</p>
</div>
<Badge variant={props.editingRoom?.is_public ? 'success' : 'warning'}>{props.editingRoom?.is_public ? '公开' : '私密'}</Badge>
</div>
@ -122,28 +132,59 @@ export function ManageRooms(props: {
</Button>
</div>
</div>
<Show when={pending().length > 0}>
<div class="border border-warning/30 bg-warning/10">
<div class="flex items-center justify-between border-b border-warning/20 px-4 py-3">
<h4 class="flex items-center gap-2 font-semibold text-warning"><ShieldAlert class="size-4" /> 待审批 {pending().length}</h4>
</div>
<div class="divide-y divide-warning/20">
<For each={pending()}>
{(m) => (
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<span class="font-mono text-sm">{m.username} <span class="text-xs text-muted-foreground">· {m.role}</span></span>
<div class="flex gap-2">
<Button class="h-7 px-3" onClick={() => props.onApprove(props.selectedRoom, m.username)}>批准</Button>
<Button variant="ghost" class="h-7 px-3" onClick={() => props.onReject(props.selectedRoom, m.username)}>拒绝</Button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<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="font-semibold">成员 {props.members.length}</h4>
<h4 class="font-semibold">成员 {props.members.length} <span class="text-xs font-normal text-muted-foreground">· 含待审批 {pending().length}</span></h4>
</div>
<div class="divide-y divide-line/60">
<For each={props.members}>
<For each={active()}>
{(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="truncate font-mono text-sm font-medium flex items-center gap-1.5">{m.username} <Show when={m.status==='banned'}><Badge variant="outline" class="border-destructive/30 text-destructive text-[10px]">banned</Badge></Show> <Show when={m.status==='active' && m.role==='creator'}><Crown class="size-3 text-amber-500" /></Show></p>
<p class="micro-label truncate text-muted-foreground">{m.effective_permissions?.join(', ') || ''}</p>
</div>
<Select class="w-28" value={m.role} onChange={(v) => props.onChangeRole(props.selectedRoom, m.username, v)} options={[{ value: 'creator', label: '房主' }, { value: 'admin', label: '管理员' }, { value: 'member', label: '成员' }, { value: 'guest', label: '访客' }]} />
<Show when={m.role !== 'creator'}>
<Button variant="ghost" class="h-8 px-2 text-destructive" onClick={() => props.onKick(props.selectedRoom, m.username)}>
<Ban class="size-4" />
</Button>
</Show>
<div class="flex items-center gap-1">
<Show when={m.role !== 'creator'}>
<Button variant="ghost" class="h-8 px-2 text-muted-foreground" title="转让房主" onClick={() => { if(confirm(`转让房主给 ${m.username}?`)) props.onTransfer(props.selectedRoom, m.username) }}>
<Crown class="size-4" />
</Button>
<Button variant="ghost" class="h-8 px-2 text-destructive" title="踢出" onClick={() => props.onKick(props.selectedRoom, m.username)}>
<Ban class="size-4" />
</Button>
<Button variant="ghost" class="h-8 px-2 text-destructive" title="封禁(房内)" onClick={() => { if(confirm(`房内封禁 ${m.username}?`)) props.onBan(props.selectedRoom, m.username) }}>
<ShieldAlert class="size-4" />
</Button>
</Show>
</div>
</div>
)}
</For>
<Show when={active().length===0}><p class="px-4 py-6 text-sm text-muted-foreground">暂无活跃成员</p></Show>
</div>
</div>
<div class="border border-line bg-card/50 p-4">
@ -184,6 +225,40 @@ export function ManageRooms(props: {
</Button>
</Show>
</div>
<div class="border border-line bg-card/50 p-4">
<div class="flex items-center justify-between gap-2">
<h4 class="flex items-center gap-2 font-semibold">
<Globe class="size-4" />
分发设置
<Show when={props.distribution?.has_custom} fallback={<Badge variant="outline" class="text-[10px]">跟随全局</Badge>}>
<Badge variant="success" class="text-[10px]">房间自定义</Badge>
</Show>
</h4>
<Show when={props.distribution?.has_custom}>
<Button variant="ghost" class="h-7 px-2 text-xs" onClick={() => props.onClearDistribution?.()}>恢复全局</Button>
</Show>
</div>
<p class="mt-1.5 text-xs text-muted-foreground">
全局:{props.distribution?.global?.join(', ') || '—'} · 生效:{props.distribution?.effective?.join(', ') || '(无)'}
<Show when={!props.distribution}> · 加载中…</Show>
</p>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<For each={['srs-hls','cf','cdn'] as const}>
{(kind) => (
<label class="flex items-center justify-between gap-2 border border-line bg-background px-3 py-2.5">
<span class="text-sm font-mono">{kind}</span>
<input
type="checkbox"
checked={!!props.distribution?.effective?.includes(kind)}
onInput={(e) => props.onToggleDistribution?.(kind, (e.currentTarget as HTMLInputElement).checked)}
/>
</label>
)}
</For>
</div>
<p class="mt-2 text-[11px] text-muted-foreground">未设置时自动使用全局分发;勾选即为该房间单独覆盖(可为空表示关闭全部)。</p>
</div>
</Show>
</div>
</div>

View File

@ -1,66 +1,258 @@
import { For, Show } from 'solid-js'
import { Settings, KeyRound, RadioTower, Globe, LogOut, Plus } from 'lucide-solid'
import { Badge, Button } from '../../components/ui'
import { For, Show, createSignal, createEffect, onMount } from 'solid-js'
import { Settings, KeyRound, RadioTower, Globe, Save, RotateCcw, ShieldCheck, Eye, EyeOff, Info } from 'lucide-solid'
import { Badge, Button, TextInput, Alert, Select } from '../../components/ui'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
type RuntimeView = { values: Record<string, string>; masked: Record<string, boolean>; source: Record<string, string> }
const GROUPS: { title: string; icon: any; keys: { key: string; label: string; placeholder?: string; type?: 'text' | 'bool' | 'select'; options?: {value:string,label:string}[]; hint?: string }[] }[] = [
{
title: '分发与 SFU',
icon: RadioTower,
keys: [
{ key: 'SFU_PROVIDER', label: 'SFU_PROVIDER', placeholder: 'cloudflare,srs', hint: '后端顺序,逗号分隔' },
{ key: 'DISTRIBUTORS', label: 'DISTRIBUTORS', placeholder: 'srs-hls,cf,cdn', hint: '启用的分发方式' },
{ key: 'CDN_RTMP_URL', label: 'CDN_RTMP_URL', placeholder: 'rtmp://cdn.example.com/live', hint: '启用 cdn 时必填' },
{ key: 'CDN_NAME', label: 'CDN 名称', placeholder: 'cdn' },
],
},
{
title: 'SRS 主干',
icon: RadioTower,
keys: [
{ key: 'SRS_API_BASE', label: 'SRS_API_BASE', placeholder: 'http://localhost:1985' },
{ key: 'SRS_HTTP_BASE', label: 'SRS_HTTP_BASE', placeholder: 'http://localhost:8080' },
{ key: 'SRS_APP', label: 'SRS_APP', placeholder: 'live' },
{ key: 'SRS_SECRET', label: 'SRS_SECRET', placeholder: '留空则不鉴权', hint: '推流鉴权密钥' },
{ key: 'SRS_CANDIDATE', label: 'SRS_CANDIDATE', placeholder: '127.0.0.1', hint: 'ICE candidate IP' },
{ key: 'SFU_TOKEN_REQUIRED', label: 'SFU_TOKEN_REQUIRED', type: 'bool' },
],
},
{
title: 'Cloudflare Realtime',
icon: Globe,
keys: [
{ key: 'CF_APP_ID', label: 'CF_APP_ID' },
{ key: 'CF_APP_SECRET', label: 'CF_APP_SECRET', hint: '保存后自动脱敏显示' },
{ key: 'CF_BASE_URL', label: 'CF_BASE_URL', placeholder: 'https://rtc.live.cloudflare.com/v1' },
{ key: 'CF_STUN_URL', label: 'CF_STUN_URL', placeholder: 'stun:stun.cloudflare.com:3478' },
],
},
{
title: '鉴权与注册',
icon: ShieldCheck,
keys: [
{ key: 'ALLOW_REGISTER', label: 'ALLOW_REGISTER', type: 'bool', hint: '是否允许自助注册' },
{ key: 'JWT_TTL', label: 'JWT_TTL', placeholder: '2h', hint: '例如 30m / 2h / 24h' },
],
},
{
title: '邮件与验证',
icon: Settings,
keys: [
{ key: 'SMTP_ENABLED', label: 'SMTP_ENABLED', type: 'bool' },
{ key: 'SMTP_HOST', label: 'SMTP_HOST', placeholder: 'smtp.example.com' },
{ key: 'SMTP_PORT', label: 'SMTP_PORT', placeholder: '587' },
{ key: 'SMTP_USER', label: 'SMTP_USER' },
{ key: 'SMTP_PASS', label: 'SMTP_PASS', hint: '脱敏存储' },
{ key: 'SMTP_FROM', label: 'SMTP_FROM' },
{ key: 'SMTP_FROM_NAME', label: 'SMTP_FROM_NAME', placeholder: 'SyncLive' },
{ key: 'SMTP_TLS', label: 'SMTP_TLS', type: 'bool' },
{ key: 'EMAIL_VERIFY_REQUIRED', label: 'EMAIL_VERIFY_REQUIRED', type: 'bool' },
{ key: 'EMAIL_VERIFY_TTL', label: 'EMAIL_VERIFY_TTL', placeholder: '24h' },
{ key: 'EMAIL_VERIFY_BASE_URL', label: 'EMAIL_VERIFY_BASE_URL' },
],
},
{
title: 'OAuth 登录',
icon: KeyRound,
keys: [
{ key: 'OAUTH_ENABLED', label: 'OAUTH_ENABLED', type: 'bool' },
{ key: 'OAUTH_CALLBACK_BASE', label: 'OAUTH_CALLBACK_BASE', placeholder: 'http://localhost:8088' },
{ key: 'OAUTH_GOOGLE_CLIENT_ID', label: 'GOOGLE_CLIENT_ID' },
{ key: 'OAUTH_GOOGLE_CLIENT_SECRET', label: 'GOOGLE_CLIENT_SECRET' },
{ key: 'OAUTH_GITHUB_CLIENT_ID', label: 'GITHUB_CLIENT_ID' },
{ key: 'OAUTH_GITHUB_CLIENT_SECRET', label: 'GITHUB_CLIENT_SECRET' },
{ key: 'OAUTH_GITLAB_CLIENT_ID', label: 'GITLAB_CLIENT_ID' },
{ key: 'OAUTH_GITLAB_CLIENT_SECRET', label: 'GITLAB_CLIENT_SECRET' },
{ key: 'OAUTH_GENERIC_URL', label: 'GENERIC_URL' },
{ key: 'OAUTH_GENERIC_CLIENT_ID', label: 'GENERIC_CLIENT_ID' },
{ key: 'OAUTH_GENERIC_CLIENT_SECRET', label: 'GENERIC_CLIENT_SECRET' },
{ key: 'OAUTH_GENERIC_NAME', label: 'GENERIC_NAME', placeholder: 'generic' },
],
},
]
export function ManageSystem(props: { overview: any }) {
const canWrite = () => permissions.can('config:write') || permissions.can('system:manage') || permissions.isAdminOrAbove()
const [view, setView] = createSignal<RuntimeView | null>(null)
const [draft, setDraft] = createSignal<Record<string, string>>({})
const [saving, setSaving] = createSignal(false)
const [msg, setMsg] = createSignal<{ tone: 'success' | 'error' | 'info'; text: string } | null>(null)
const [revealSecrets, setRevealSecrets] = createSignal(false)
const [activeGroup, setActiveGroup] = createSignal(0)
const load = async () => {
try {
const v = await api.manageRuntimeConfig()
setView(v)
setDraft({ ...v.values })
} catch (e: any) {
setMsg({ tone: 'error', text: String(e?.message || e) })
}
}
onMount(load)
createEffect(() => { void permissions.check() })
const dirty = () => {
const v = view(); if (!v) return false
const d = draft()
return Object.keys(d).some(k => (d[k] ?? '') !== (v.values[k] ?? ''))
}
const setKey = (k: string, val: string) => setDraft(p => ({ ...p, [k]: val }))
const save = async () => {
if (!canWrite()) { setMsg({ tone: 'error', text: '无权限:需要 admin/root' }); return }
const v = view(); if (!v) return
// only send changed keys; empty means clear runtime override (but backend handles delete)
const patch: Record<string, string> = {}
const d = draft()
for (const k of Object.keys(d)) {
if ((d[k] ?? '') !== (v.values[k] ?? '')) {
// if user left masked placeholder unchanged, skip (backend also skips)
if (v.masked[k] && d[k] === '••••••••') continue
patch[k] = d[k] ?? ''
}
}
if (Object.keys(patch).length === 0) { setMsg({ tone: 'info', text: '没有变更' }); return }
setSaving(true)
try {
const res = await api.saveRuntimeConfig(patch)
setMsg({ tone: 'success', text: `已保存 ${res.changed?.length ?? 0} 项,已热生效,无需重启` })
const nv = await api.manageRuntimeConfig()
setView(nv); setDraft({ ...nv.values })
} catch (e: any) {
setMsg({ tone: 'error', text: String(e?.message || e) })
} finally { setSaving(false) }
}
const resetGroup = async () => {
if (!canWrite() || !confirm('将当前分组的运行时覆盖重置为环境/默认值?')) return
const g = GROUPS[activeGroup()]
try { await api.resetRuntimeConfig(g.keys.map(k => k.key)); setMsg({ tone: 'success', text: '已重置,已热生效' }); await load() } catch (e: any) { setMsg({ tone: 'error', text: String(e?.message || e) }) }
}
const resetAll = async () => {
if (!canWrite() || !confirm('重置所有运行时覆盖?将回落至环境变量/默认值并立即生效。')) return
try { await api.resetRuntimeConfig([]); setMsg({ tone: 'success', text: '已重置全部,已热生效' }); await load() } catch (e: any) { setMsg({ tone: 'error', text: String(e?.message || e) }) }
}
return (
<div class="space-y-4">
<div class="grid gap-4 lg:grid-cols-2">
<div class="border border-line bg-card/60 p-4">
<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">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={props.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>
<Show when={msg()}><Alert tone={msg()!.tone === 'error' ? 'error' : msg()!.tone === 'success' ? 'success' : 'info'} class="flex items-center justify-between gap-3"><span class="flex items-center gap-2"><Info class="size-4" />{msg()!.text}</span><button class="text-xs underline" onClick={() => setMsg(null)}>关闭</button></Alert></Show>
<div class="flex flex-wrap items-center justify-between gap-3 border border-line bg-card/60 px-4 py-3">
<div class="min-w-0">
<h3 class="flex items-center gap-2 text-sm font-semibold"><Settings class="size-4" />运行时配置</h3>
<p class="mt-1 text-xs text-muted-foreground">修改后立即热生效并持久化到 <span class="font-mono">data/runtime_config.json</span>,无需重启。优先级:运行时覆盖 &gt; 环境变量 &gt; 默认值。</p>
</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> 签发。
</p>
<p>
私密房间支持密码;<span class="font-mono text-foreground">is_public</span> 与
<span class="font-mono text-foreground"> allow_guest_join</span> 联动。
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Show when={!canWrite()}><Badge variant="warning">只读</Badge></Show>
<Show when={canWrite()}>
<Button variant="ghost" class="h-9 gap-1.5" onClick={() => setRevealSecrets(v => !v)}>{revealSecrets() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}{revealSecrets() ? '隐藏密钥' : '显示密钥'}</Button>
</Show>
<Button variant="outline" class="h-9 gap-1.5" onClick={load} disabled={saving()}>刷新</Button>
<Button variant="ghost" class="h-9 gap-1.5" onClick={resetGroup} disabled={!canWrite() || saving()}><RotateCcw class="size-4" />重置分组</Button>
<Button variant="ghost" class="h-9 gap-1.5" onClick={resetAll} disabled={!canWrite() || saving()}>重置全部</Button>
<Button class="h-9 gap-1.5" onClick={save} disabled={!canWrite() || saving() || !dirty()}><Save class="size-4" />{saving() ? '保存中...' : dirty() ? '保存并生效' : '已是最新'}</Button>
</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="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 class="grid gap-4 xl:grid-cols-[220px_1fr]">
<div class="flex h-fit flex-col gap-1 border border-line bg-card/40 p-2 xl:sticky xl:top-[88px]">
<For each={GROUPS}>
{(g, i) => {
const Icon = g.icon
const active = () => i() === activeGroup()
return <button type="button" onClick={() => setActiveGroup(i())} class={`flex items-center gap-2 border px-3 py-2.5 text-left text-xs font-medium transition-colors ${active() ? 'border-line bg-acid-lime text-black' : 'border-transparent bg-card text-muted-foreground hover:bg-accent hover:text-foreground'}`}><Icon class="size-3.5 shrink-0" /><span class="min-w-0 flex-1 truncate">{g.title}</span></button>
}}
</For>
<div class="mt-2 border-t border-line/60 pt-3">
<div class="space-y-2 px-2 text-[11px] leading-relaxed text-muted-foreground">
<p class="font-medium text-foreground">来源标记</p>
<p><span class="inline-flex items-center gap-1"><span class="size-2 bg-acid-lime" /> runtime</span> 运行时覆盖</p>
<p><span class="inline-flex items-center gap-1"><span class="size-2 bg-acid-cyan" /> env</span> 环境变量</p>
<p><span class="inline-flex items-center gap-1"><span class="size-2 bg-line" /> default</span> 默认值</p>
</div>
</div>
</div>
<div class="min-w-0 space-y-4">
<Show when={view()} fallback={<div class="border border-line bg-card/40 px-4 py-12 text-center text-sm text-muted-foreground">加载中...</div>}>
{(() => {
const g = GROUPS[activeGroup()]
return (
<div class="border border-line bg-card/60">
<div class="flex items-center justify-between gap-3 border-b border-line/80 px-4 py-3">
<span class="text-sm font-semibold">{g.title}</span>
<span class="text-xs text-muted-foreground">{g.keys.length} 项</span>
</div>
<div class="divide-y divide-line/60">
<For each={g.keys}>
{(f) => {
const src = () => view()!.source[f.key] || 'default'
const isSecret = () => !!view()!.masked[f.key]
const val = () => draft()[f.key] ?? ''
const srcColor = () => src() === 'runtime' ? 'bg-acid-lime' : src() === 'env' ? 'bg-acid-cyan' : 'bg-line'
return (
<div class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-start sm:gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-xs font-medium">{f.label}</span>
<span class={`inline-flex items-center gap-1 border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-widest ${src() === 'runtime' ? 'border-acid-lime/40 bg-acid-lime/15 text-foreground' : src() === 'env' ? 'border-acid-cyan/40 bg-acid-cyan/10 text-foreground' : 'border-line bg-muted text-muted-foreground'}`}><span class={`size-1.5 ${srcColor()}`} />{src()}</span>
<Show when={isSecret()}><Badge variant="outline" class="text-[10px]">secret</Badge></Show>
</div>
<Show when={f.hint}><p class="mt-1 text-xs text-muted-foreground">{f.hint}</p></Show>
<Show when={f.placeholder}><p class="mt-0.5 font-mono text-[11px] text-muted-foreground/70">示例:{f.placeholder}</p></Show>
</div>
<div class="w-full shrink-0 sm:w-[min(420px,55%)]">
<Show when={f.type === 'bool'} fallback={
<TextInput value={val()} onInput={(v) => setKey(f.key, v)} placeholder={f.placeholder || ''} type={isSecret() && !revealSecrets() ? 'password' : 'text'} disabled={!canWrite()} class="h-9 font-mono text-xs" />
}>
<Select value={val() || '0'} onChange={(v) => setKey(f.key, v)} options={[{ value: '0', label: '关闭 (0)' }, { value: '1', label: '开启 (1)' }]} disabled={!canWrite()} />
</Show>
</div>
</div>
)
}}
</For>
</div>
</div>
)
})()}
</Show>
<div class="grid gap-4 lg:grid-cols-2">
<div class="border border-line bg-card/40 p-4">
<h4 class="flex items-center gap-2 text-sm font-semibold"><RadioTower class="size-4" />SFU 状态(只读)</h4>
<div class="mt-3 space-y-2 text-sm">
<For each={props.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 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 text-xs">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 text-xs">9090</span></div>
</div>
</div>
<div class="border border-line bg-card/40 p-4">
<h4 class="flex items-center gap-2 text-sm font-semibold"><Info class="size-4" />说明</h4>
<div class="mt-3 space-y-2 text-xs leading-relaxed text-muted-foreground">
<p>保存即热生效:Cloudflare/SRS/CDN/分发开关等无需重启。</p>
<p>密钥字段默认以 <span class="font-mono">••••••••</span> 脱敏回显;不修改则保持原值。</p>
<p>重置会删除运行时覆盖,回落至环境变量或默认值。</p>
<Button variant="ghost" class="mt-2 h-8 gap-1.5 text-xs" onClick={() => window.open('/api/manage/config?raw=1', '_blank')}><Globe class="size-4" />查看原始 JSON</Button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -2,7 +2,28 @@ import { For, Show } from 'solid-js'
import { Badge, Button, Alert, Select } from '../../components/ui'
import { ROLE_META } from './constants'
export function ManageUsersInline(props: { users: any[]; onRoleChange: (u: any, v: string) => Promise<void>; canList: boolean }) {
export function ManageUsersInline(props: {
users: any[]
onRoleChange: (u: any, v: string) => Promise<void>
onToggleBan: (u: any) => Promise<void>
canList: boolean
actorRole: string
busy?: string
}) {
const available = (targetRole: string) => {
if (props.actorRole === 'root') return ['viewer', 'publisher', 'admin', 'root']
if (props.actorRole === 'admin') {
// admin can only grant viewer/publisher; cannot elevate to admin/root
if (targetRole === 'admin' || targetRole === 'root') return []
return ['viewer', 'publisher']
}
return []
}
const canBan = (u: any) => {
if (props.actorRole === 'root') return u.role !== 'root'
if (props.actorRole === 'admin') return u.role === 'viewer' || u.role === 'publisher'
return false
}
return (
<div class="border border-line bg-card/50">
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
@ -12,36 +33,50 @@ export function ManageUsersInline(props: { users: any[]; onRoleChange: (u: any,
<Show when={!props.canList} fallback={
<div>
<For each={props.users}>
{(u) => (
<div class="flex items-center justify-between gap-3 border-b border-line/60 px-5 py-3 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">{u.role}</p>
{(u) => {
const targetRole = u.role as string
const meta = ROLE_META[targetRole] || ROLE_META.viewer
const isBanned = (u as any).status === 'banned'
const opts = available(targetRole)
const canEdit = opts.length > 0
return (
<div class={`flex flex-wrap items-center justify-between gap-3 border-b border-line/60 px-5 py-3 last:border-0 ${isBanned ? 'bg-destructive/5' : ''}`}>
<div class="flex min-w-0 items-center gap-3">
<span class={`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${meta.color}`}>
{(u.username[0] || '?').toUpperCase()}
</span>
<div class="min-w-0">
<p class="truncate font-mono text-sm font-medium flex items-center gap-2">{u.username} <Show when={isBanned}><span class="text-[10px] px-1.5 py-0.5 border border-destructive/30 bg-destructive/10 text-destructive">banned</span></Show></p>
<p class="micro-label text-muted-foreground">{targetRole} · {isBanned ? '已封禁' : 'active'}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge class={meta.color}>{targetRole}</Badge>
<Show when={canEdit} fallback={<span class="text-xs text-muted-foreground/60">无权变更</span>}>
<Select
class="w-28"
value={targetRole}
aria-label={`调整 ${u.username} 的角色`}
disabled={!!props.busy || isBanned}
onChange={async (v) => props.onRoleChange(u, v)}
options={opts.map((r) => ({ value: r, label: r }))}
/>
</Show>
<Show when={canBan(u)}>
<Button variant={isBanned ? 'outline' : 'ghost'} class={`h-7 px-2 text-xs ${isBanned ? 'border-success/30 text-success' : 'text-destructive'}`} disabled={props.busy === u.username} onClick={() => props.onToggleBan(u)}>
{isBanned ? '解封' : '封禁'}
</Button>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<Badge class={ROLE_META[u.role]?.color || ''}>{u.role}</Badge>
<Select
value={u.role}
onChange={async (v) => props.onRoleChange(u, v)}
options={[
{ value: 'viewer', label: 'viewer' },
{ value: 'publisher', label: 'publisher' },
{ value: 'admin', label: 'admin' },
]}
/>
</div>
</div>
)}
)
}}
</For>
<Show when={props.users.length === 0}><p class="px-5 py-6 text-sm text-muted-foreground">暂无用户</p></Show>
</div>
}>
<Alert tone="warning" class="m-4">
无权限查看:需要 user:list
无权限查看:需要 user:list(管理员及以上)
</Alert>
</Show>
</div>

View File

@ -1,9 +1,9 @@
import { useLocation, Link } from '@tanstack/solid-router'
import { Layers, RadioTower, KeyRound, Settings } from 'lucide-solid'
import { Layers, RadioTower, KeyRound, Settings, Users } from 'lucide-solid'
import { createMemo, For } from 'solid-js'
import { permissions } from '../../lib/permissions'
type ManagePath = 'overview' | 'rooms' | 'permissions' | 'system'
type ManagePath = 'overview' | 'rooms' | 'permissions' | 'users' | 'system'
type ManageTab = {
path: ManagePath
@ -16,6 +16,7 @@ export const MANAGE_TABS: ManageTab[] = [
{ path: 'overview', label: '概览', icon: Layers, perms: ['room:list'] },
{ path: 'rooms', label: '房间', icon: RadioTower, perms: ['room:manage'] },
{ path: 'permissions', label: '权限', icon: KeyRound, perms: ['room:manage'] },
{ path: 'users', label: '用户', icon: Users, perms: ['user:list'] },
{ path: 'system', label: '系统', icon: Settings, perms: ['system:manage', 'config:read'] },
]

View File

@ -2,6 +2,7 @@ import { render } from 'solid-js/web'
import { RouterProvider, createRouter } from '@tanstack/solid-router'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { initTheme } from './lib/theme'
import { AppErrorBoundary } from './components/error-boundary'
import './index.css'
import { routeTree } from './app/router'
@ -16,7 +17,9 @@ const router = createRouter({ routeTree, defaultPreload: 'intent' })
render(
() => (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<AppErrorBoundary>
<RouterProvider router={router} />
</AppErrorBoundary>
</QueryClientProvider>
),
document.getElementById('root')!,

View File

@ -1,3 +1,4 @@
import { createHttpError } from './errors'
import type {
GetConfigResponse,
ListRoomsResponse,
@ -24,11 +25,7 @@ async function rawReq<T>(method: string, path: string, body?: unknown): Promise<
})
if (!res.ok) {
const text = await res.text().catch(() => '')
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
throw createHttpError(res.status, text)
}
if (res.status === 204) return undefined as T
const ct = res.headers.get('content-type') || ''
@ -50,8 +47,8 @@ async function doRefresh(): Promise<boolean> {
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
} catch (e) {
const status = (e as { httpStatus?: number })?.httpStatus
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) {
@ -142,4 +139,24 @@ export const api = {
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`),
manageApproveMember: (room: string, user: string) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}/approve`),
manageRejectMember: (room: string, user: string) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}/reject`),
manageBanMember: (room: string, user: string) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/members/${encodeURIComponent(user)}/ban`),
manageTransferOwnership: (room: string, target: string) =>
req<{ ok: boolean }>('POST', `/api/manage/rooms/${encodeURIComponent(room)}/transfer`, { target_user: target }),
manageRoomDistribution: (room: string) =>
req<{ room: string; global: string[]; custom: string | null; custom_list: string[]; effective: string[]; has_custom: boolean }>('GET', `/api/manage/rooms/${encodeURIComponent(room)}/distribution`),
manageUpdateRoomDistribution: (room: string, distributors: string) =>
req<{ ok: boolean; distributors: string; effective: string[] }>('PUT', `/api/manage/rooms/${encodeURIComponent(room)}/distribution`, { distributors }),
manageUpdateRoomDistributionList: (room: string, list: string[]) =>
req<{ ok: boolean; distributors: string; effective: string[] }>('PUT', `/api/manage/rooms/${encodeURIComponent(room)}/distribution`, { list }),
manageClearRoomDistribution: (room: string) =>
req<{ ok: boolean; cleared: boolean; effective: string[] }>('DELETE', `/api/manage/rooms/${encodeURIComponent(room)}/distribution`),
manageRuntimeConfig: () => req<{ values: Record<string, string>; masked: Record<string, boolean>; source: Record<string, string> }>('GET', '/api/manage/config'),
manageRuntimeConfigRaw: () => req<{ values: Record<string, string>; masked: Record<string, boolean>; source: Record<string, string> }>('GET', '/api/manage/config?raw=1'),
saveRuntimeConfig: (values: Record<string, string>) => req<{ ok: boolean; changed: string[]; values: Record<string, string> }>('PUT', '/api/manage/config', { values }),
resetRuntimeConfig: (keys?: string[]) => req<{ ok: boolean }>('POST', '/api/manage/config/reset', keys && keys.length ? { keys } : {}),
}

116
web/src/lib/errors.ts Normal file
View File

@ -0,0 +1,116 @@
export type AppErrorSource = 'backend' | 'network' | 'runtime'
export interface AppError extends Error {
/** 后端显式返回的业务码;HTTP 状态码不会混入这个字段。 */
code?: string
httpStatus?: number
source: AppErrorSource
raw: unknown
}
const CODE_FIELDS = ['code', 'error_code', 'errorCode'] as const
function isEmpty(value: unknown): value is null | undefined | '' {
return value === null || value === undefined || value === ''
}
function readCode(payload: unknown): string | undefined {
if (isEmpty(payload)) return undefined
if (typeof payload === 'number') return Number.isFinite(payload) ? String(payload) : undefined
if (typeof payload !== 'object') return undefined
const record = payload as Record<string, unknown>
for (const field of CODE_FIELDS) {
const value = record[field]
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string' && value.trim()) return value.trim()
}
// 兼容 `{ error: { code, message } }` 这类嵌套结构。
if (record.error && typeof record.error === 'object') return readCode(record.error)
return undefined
}
function readMessage(payload: unknown): string | undefined {
if (isEmpty(payload)) return undefined
if (payload instanceof Error) {
const message = payload.message.trim()
return message || undefined
}
if (typeof payload === 'string') {
const message = payload.trim()
return message || undefined
}
if (typeof payload !== 'object') return String(payload)
const direct = payload as Record<string, unknown>
const candidates = [direct.message, direct.msg, direct.error, direct.detail]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
if (typeof candidate === 'number') return String(candidate)
}
if (direct.error && typeof direct.error === 'object') return readMessage(direct.error)
return undefined
}
function parseJsonObject(body: string): Record<string, unknown> | undefined {
if (!body.trim()) return undefined
try {
const parsed: unknown = JSON.parse(body)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// 后端仍可能返回 http.Error 的纯文本,这里保留原始文本即可。
}
return undefined
}
function truncate(value: string, maxLength = 360): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
/** 将 HTTP 失败响应转换为带业务码的统一错误。 */
export function createHttpError(httpStatus: number, body: string): AppError {
const payload = parseJsonObject(body)
const code = readCode(payload)
const rawMessage = readMessage(payload) ?? (payload ? undefined : truncate(body))
const error = new Error(rawMessage || `请求失败(HTTP ${httpStatus})`) as AppError
error.name = 'AppError'
error.httpStatus = httpStatus
if (code !== undefined) error.code = code
error.source = 'backend'
error.raw = payload ?? body
return error
}
/** 包装网络异常或组件运行时抛出的任意值。 */
export function normalizeError(input: unknown): AppError {
if (isAppError(input)) return input
const isNetwork =
input instanceof TypeError ||
(input instanceof DOMException && input.name === 'AbortError') ||
(typeof input === 'object' &&
input !== null &&
'name' in input &&
(input as { name?: unknown }).name === 'TypeError')
const message = readMessage(input) ?? '应用发生未预期的错误'
const error = new Error(isNetwork ? '网络连接异常,请检查网络后重试' : message) as AppError
error.name = 'AppError'
error.source = isNetwork ? 'network' : 'runtime'
error.raw = input
return error
}
export function isAppError(input: unknown): input is AppError {
return input instanceof Error && (input as Partial<AppError>).source !== undefined
}
export function errorCode(input: unknown): string | undefined {
return normalizeError(input).code
}