Compare commits
5 Commits
fa86c849ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb0baf16e | |||
| 146f05388e | |||
| 98666ab1ea | |||
| 1cce5634b1 | |||
| 6b1c7242c7 |
@@ -1,16 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"hightube/internal/api"
|
||||
"hightube/internal/chat"
|
||||
"hightube/internal/db"
|
||||
"hightube/internal/monitor"
|
||||
"hightube/internal/stream"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println("Starting Hightube Server v1.0.0-Beta3.7...")
|
||||
monitor.Init(2000)
|
||||
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1")
|
||||
|
||||
// Initialize Database and run auto-migrations
|
||||
db.InitDB()
|
||||
@@ -23,15 +23,16 @@ func main() {
|
||||
// Start the API server in a goroutine so it doesn't block the RTMP server
|
||||
go func() {
|
||||
r := api.SetupRouter(srv)
|
||||
log.Println("[INFO] API Server is listening on :8080...")
|
||||
monitor.Infof("API server listening on :8080")
|
||||
monitor.Infof("Web console listening on :8080/admin")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("Failed to start API server: %v", err)
|
||||
monitor.Errorf("Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup and start the RTMP server
|
||||
log.Println("[INFO] Ready to receive RTMP streams from OBS.")
|
||||
monitor.Infof("Ready to receive RTMP streams from OBS")
|
||||
if err := srv.Start(":1935"); err != nil {
|
||||
log.Fatalf("Failed to start RTMP server: %v", err)
|
||||
monitor.Errorf("Failed to start RTMP server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
354
backend/internal/api/admin.go
Normal file
354
backend/internal/api/admin.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hightube/internal/chat"
|
||||
"hightube/internal/db"
|
||||
"hightube/internal/model"
|
||||
"hightube/internal/monitor"
|
||||
"hightube/internal/stream"
|
||||
"hightube/internal/utils"
|
||||
)
|
||||
|
||||
var adminRTMP *stream.RTMPServer
|
||||
|
||||
func BindAdminDependencies(rtmpSrv *stream.RTMPServer) {
|
||||
adminRTMP = rtmpSrv
|
||||
}
|
||||
|
||||
func AdminLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordHash(req.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
|
||||
monitor.Auditf("admin=%s signed in", user.Username)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminLogout(c *gin.Context) {
|
||||
operator, _ := c.Get("username")
|
||||
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
|
||||
monitor.Auditf("admin=%v signed out", operator)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
|
||||
}
|
||||
|
||||
func GetAdminSession(c *gin.Context) {
|
||||
username, _ := c.Get("username")
|
||||
role, _ := c.Get("role")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": username,
|
||||
"role": role,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminOverview(c *gin.Context) {
|
||||
stats := monitor.GetSnapshot()
|
||||
chatStats := chat.StatsSnapshot{}
|
||||
if chat.MainHub != nil {
|
||||
chatStats = chat.MainHub.GetStatsSnapshot()
|
||||
}
|
||||
|
||||
activeCount := 0
|
||||
activePaths := []string{}
|
||||
if adminRTMP != nil {
|
||||
activeCount = adminRTMP.ActiveStreamCount()
|
||||
activePaths = adminRTMP.ActiveStreamPaths()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system": stats,
|
||||
"stream": gin.H{
|
||||
"active_stream_count": activeCount,
|
||||
"active_stream_paths": activePaths,
|
||||
},
|
||||
"chat": chatStats,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminHealth(c *gin.Context) {
|
||||
type dbHealth struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
health := gin.H{
|
||||
"api": true,
|
||||
"rtmp": adminRTMP != nil,
|
||||
}
|
||||
|
||||
dbOK := dbHealth{OK: true}
|
||||
if err := db.DB.Exec("SELECT 1").Error; err != nil {
|
||||
dbOK.OK = false
|
||||
dbOK.Error = err.Error()
|
||||
}
|
||||
health["db"] = dbOK
|
||||
|
||||
c.JSON(http.StatusOK, health)
|
||||
}
|
||||
|
||||
func ListAdminLogs(c *gin.Context) {
|
||||
level := c.Query("level")
|
||||
keyword := c.Query("keyword")
|
||||
page := parseIntWithDefault(c.Query("page"), 1)
|
||||
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
|
||||
|
||||
items, total := monitor.Query(level, keyword, page, pageSize)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func StreamAdminLogs(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
|
||||
return
|
||||
}
|
||||
|
||||
ch := monitor.Subscribe()
|
||||
defer monitor.Unsubscribe(ch)
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry := <-ch:
|
||||
payload, _ := json.Marshal(entry)
|
||||
_, _ = c.Writer.Write([]byte("event: log\n"))
|
||||
_, _ = c.Writer.Write([]byte("data: " + string(payload) + "\n\n"))
|
||||
flusher.Flush()
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type updateRoleRequest struct {
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
func UpdateUserRole(c *gin.Context) {
|
||||
userID, ok := parsePathUint(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "admin" && role != "user" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin or user"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("role", role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
||||
return
|
||||
}
|
||||
|
||||
operator, _ := c.Get("username")
|
||||
monitor.Auditf("admin=%v updated user_id=%d role=%s", operator, userID, role)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
|
||||
}
|
||||
|
||||
type updateEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func UpdateUserEnabled(c *gin.Context) {
|
||||
userID, ok := parsePathUint(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("enabled", req.Enabled).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled status"})
|
||||
return
|
||||
}
|
||||
|
||||
operator, _ := c.Get("username")
|
||||
monitor.Auditf("admin=%v updated user_id=%d enabled=%v", operator, userID, req.Enabled)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "enabled status updated"})
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
func ResetUserPassword(c *gin.Context) {
|
||||
userID, ok := parsePathUint(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := utils.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("password", hash).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||
return
|
||||
}
|
||||
|
||||
operator, _ := c.Get("username")
|
||||
monitor.Auditf("admin=%v reset password for user_id=%d", operator, userID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
userID, ok := parsePathUint(c.Param("id"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
if opID, ok := operatorID.(uint); ok && opID == userID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete current admin account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Where("user_id = ?", userID).Delete(&model.Room{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete rooms"})
|
||||
return
|
||||
}
|
||||
if err := db.DB.Delete(&model.User{}, userID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
|
||||
operator, _ := c.Get("username")
|
||||
monitor.Auditf("admin=%v deleted user_id=%d", operator, userID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||
}
|
||||
|
||||
func ListUsers(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
page := parseIntWithDefault(c.Query("page"), 1)
|
||||
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := db.DB.Model(&model.User{})
|
||||
if keyword != "" {
|
||||
query = query.Where("username LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"})
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query users"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
"enabled": u.Enabled,
|
||||
"created_at": u.CreatedAt,
|
||||
"updated_at": u.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func parseIntWithDefault(v string, def int) int {
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil || i <= 0 {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func parsePathUint(v string) (uint, bool) {
|
||||
u64, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint(u64), true
|
||||
}
|
||||
21
backend/internal/api/admin_ui.go
Normal file
21
backend/internal/api/admin_ui.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed static/admin/*
|
||||
var adminFS embed.FS
|
||||
|
||||
func AdminPage(c *gin.Context) {
|
||||
content, err := fs.ReadFile(adminFS, "static/admin/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "failed to load admin page")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -32,6 +34,12 @@ func Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
var existingUser model.User
|
||||
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||
@@ -50,6 +58,8 @@ func Register(c *gin.Context) {
|
||||
user := model.User{
|
||||
Username: req.Username,
|
||||
Password: hashedPassword,
|
||||
Role: "user",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
@@ -89,7 +99,12 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID)
|
||||
if !user.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
@@ -98,6 +113,8 @@ func Login(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"enabled": user.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,3 +154,11 @@ func ChangePassword(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
||||
}
|
||||
|
||||
func bootstrapAdminUsername() string {
|
||||
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
|
||||
if adminUsername == "" {
|
||||
return "admin"
|
||||
}
|
||||
return adminUsername
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"hightube/internal/chat"
|
||||
"hightube/internal/monitor"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -23,7 +24,7 @@ func WSHandler(c *gin.Context) {
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[WS ERROR] Failed to upgrade: %v\n", err)
|
||||
monitor.Errorf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,4 +49,6 @@ func WSHandler(c *gin.Context) {
|
||||
Content: fmt.Sprintf("%s joined the room", username),
|
||||
RoomID: roomID,
|
||||
})
|
||||
|
||||
monitor.Infof("WebSocket client joined room_id=%s username=%s", roomID, username)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hightube/internal/db"
|
||||
"hightube/internal/model"
|
||||
"hightube/internal/monitor"
|
||||
"hightube/internal/utils"
|
||||
)
|
||||
|
||||
const adminSessionCookieName = "hightube_admin_session"
|
||||
|
||||
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := parts[1]
|
||||
userIDStr, err := utils.ParseToken(tokenStr)
|
||||
user, err := authenticateRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
switch {
|
||||
case errors.Is(err, errMissingToken):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
|
||||
case errors.Is(err, errInvalidToken):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
case errors.Is(err, errUserNotFound):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
case errors.Is(err, errDisabledAccount):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||
default:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := strconv.ParseUint(userIDStr, 10, 32)
|
||||
c.Set("user_id", uint(userID))
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("username", user.Username)
|
||||
c.Set("role", user.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, ok := c.Get("role")
|
||||
if !ok || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequestMetricsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
monitor.IncrementRequestCount()
|
||||
c.Next()
|
||||
if c.Writer.Status() >= http.StatusBadRequest {
|
||||
monitor.IncrementErrorCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errMissingToken = errors.New("missing token")
|
||||
errInvalidToken = errors.New("invalid token")
|
||||
errUserNotFound = errors.New("user not found")
|
||||
errDisabledAccount = errors.New("disabled account")
|
||||
)
|
||||
|
||||
func authenticateRequest(c *gin.Context) (*model.User, error) {
|
||||
tokenStr := extractToken(c)
|
||||
if tokenStr == "" {
|
||||
return nil, errMissingToken
|
||||
}
|
||||
|
||||
claims, err := utils.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, errInvalidToken
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
|
||||
if err != nil {
|
||||
return nil, errInvalidToken
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||
return nil, errUserNotFound
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
return nil, errDisabledAccount
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader != "" {
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
cookieToken, err := c.Cookie(adminSessionCookieName)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(cookieToken)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// CORSMiddleware handles cross-origin requests from web clients
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -48,3 +48,18 @@ func GetActiveRooms(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"active_rooms": result})
|
||||
}
|
||||
|
||||
func GetRoomPlaybackOptions(c *gin.Context) {
|
||||
roomID := c.Param("room_id")
|
||||
qualities := []string{"source"}
|
||||
if adminRTMP != nil {
|
||||
if available := adminRTMP.AvailablePlaybackQualities(roomID); len(available) > 0 {
|
||||
qualities = available
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"room_id": roomID,
|
||||
"qualities": qualities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.Default()
|
||||
BindAdminDependencies(streamServer)
|
||||
|
||||
// Use CORS middleware to allow web access
|
||||
r.Use(CORSMiddleware())
|
||||
r.Use(CORSMiddleware(), RequestMetricsMiddleware())
|
||||
|
||||
// 清除代理信任警告 "[WARNING] You trusted all proxies"
|
||||
r.SetTrustedProxies(nil)
|
||||
@@ -22,18 +23,37 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||
// Public routes
|
||||
r.POST("/api/register", Register)
|
||||
r.POST("/api/login", Login)
|
||||
r.POST("/api/admin/login", AdminLogin)
|
||||
r.GET("/api/rooms/active", GetActiveRooms)
|
||||
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
||||
|
||||
|
||||
// WebSocket endpoint for live chat
|
||||
r.GET("/api/ws/room/:room_id", WSHandler)
|
||||
r.GET("/admin", AdminPage)
|
||||
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
|
||||
|
||||
// Protected routes (require JWT)
|
||||
authGroup := r.Group("/api")
|
||||
authGroup.Use(AuthMiddleware())
|
||||
{
|
||||
authGroup.GET("/room/my", GetMyRoom)
|
||||
authGroup.GET("/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
|
||||
authGroup.POST("/user/change-password", ChangePassword)
|
||||
|
||||
adminGroup := authGroup.Group("/admin")
|
||||
adminGroup.Use(AdminMiddleware())
|
||||
{
|
||||
adminGroup.GET("/session", GetAdminSession)
|
||||
adminGroup.GET("/overview", GetAdminOverview)
|
||||
adminGroup.GET("/health", GetAdminHealth)
|
||||
adminGroup.GET("/logs", ListAdminLogs)
|
||||
adminGroup.POST("/logout", AdminLogout)
|
||||
adminGroup.GET("/users", ListUsers)
|
||||
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
|
||||
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
|
||||
adminGroup.POST("/users/:id/reset-password", ResetUserPassword)
|
||||
adminGroup.DELETE("/users/:id", DeleteUser)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
677
backend/internal/api/static/admin/index.html
Normal file
677
backend/internal/api/static/admin/index.html
Normal file
@@ -0,0 +1,677 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Hightube Admin Console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eef4fb;
|
||||
--bg-accent: #dbe9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fc;
|
||||
--ink: #19324d;
|
||||
--muted: #5a718a;
|
||||
--line: #d7e1eb;
|
||||
--primary: #2f6fed;
|
||||
--primary-strong: #1d4ed8;
|
||||
--primary-soft: #dce8ff;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fee2e2;
|
||||
--success: #15803d;
|
||||
--success-soft: #dcfce7;
|
||||
--warning: #b45309;
|
||||
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
|
||||
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 5vw, 42px);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
border: 1px solid rgba(47,111,237,0.12);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--primary-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255,255,255,0.88);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
grid-column: 4 / span 6;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.full { grid-column: 1 / -1; }
|
||||
.col4 { grid-column: span 4; }
|
||||
.col5 { grid-column: span 5; }
|
||||
.col7 { grid-column: span 7; }
|
||||
.col8 { grid-column: span 8; }
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
color: white;
|
||||
background: linear-gradient(120deg, var(--primary), #5994ff);
|
||||
font-weight: 700;
|
||||
transition: transform 140ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
|
||||
}
|
||||
|
||||
button.subtle {
|
||||
color: var(--ink);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: linear-gradient(120deg, #ef4444, #dc2626);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric .k {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric .v {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.health-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-chip {
|
||||
min-width: 120px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.health-chip .k {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#logs {
|
||||
height: 300px;
|
||||
overflow: auto;
|
||||
background: #f5f9ff;
|
||||
color: #17304d;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--line);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.pill.off {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pill.admin {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.pill.user {
|
||||
background: #edf2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
color: var(--warning);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.login-card, .col4, .col5, .col7, .col8 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.health-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<div class="brand">
|
||||
<div class="badge">Admin Panel</div>
|
||||
<h1>Hightube Control Console</h1>
|
||||
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
|
||||
</div>
|
||||
<div class="session-info" id="sessionInfo">Not signed in</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid" id="loginView">
|
||||
<div class="card login-card">
|
||||
<h2>Admin Sign In</h2>
|
||||
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
|
||||
<div class="stack">
|
||||
<input id="loginUsername" placeholder="Admin username" value="admin" />
|
||||
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
|
||||
<div class="row">
|
||||
<button onclick="login()">Sign In</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
Change the default admin password immediately after first login.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-grid hidden" id="appView">
|
||||
<div class="card full">
|
||||
<div class="row between">
|
||||
<div>
|
||||
<h2 style="margin-bottom:6px;">Session</h2>
|
||||
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
|
||||
<button class="danger" onclick="logout()">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col7">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col5">
|
||||
<h2>Admin Password</h2>
|
||||
<div class="stack">
|
||||
<input id="oldPassword" type="password" placeholder="Current password" />
|
||||
<input id="newPassword" type="password" placeholder="New password" />
|
||||
<div class="row">
|
||||
<button onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col4">
|
||||
<h2>Live Status</h2>
|
||||
<div id="online"></div>
|
||||
<div class="health-strip" id="health" style="margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col8">
|
||||
<div class="row between" style="margin-bottom:10px;">
|
||||
<h2 style="margin:0;">Audit and Runtime Logs</h2>
|
||||
<div class="row">
|
||||
<select id="logLevel">
|
||||
<option value="">All levels</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
<option value="audit">audit</option>
|
||||
</select>
|
||||
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
|
||||
<button class="secondary" onclick="loadHistory()">Load History</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logs"></div>
|
||||
</div>
|
||||
|
||||
<div class="card full">
|
||||
<div class="row between" style="margin-bottom:10px;">
|
||||
<h2 style="margin:0;">User Management</h2>
|
||||
<div class="row">
|
||||
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
|
||||
<button class="secondary" onclick="loadUsers()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let evt = null;
|
||||
let overviewTimer = null;
|
||||
let healthTimer = null;
|
||||
let currentAdmin = null;
|
||||
|
||||
function setSessionText(text) {
|
||||
document.getElementById('sessionInfo').textContent = text;
|
||||
}
|
||||
|
||||
function addLogLine(text) {
|
||||
const box = document.getElementById('logs');
|
||||
box.textContent += text + '\n';
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
let data = null;
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (_) {
|
||||
data = { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function clearPolling() {
|
||||
if (overviewTimer) clearInterval(overviewTimer);
|
||||
if (healthTimer) clearInterval(healthTimer);
|
||||
overviewTimer = null;
|
||||
healthTimer = null;
|
||||
}
|
||||
|
||||
function disconnectLogs() {
|
||||
if (evt) {
|
||||
evt.close();
|
||||
evt = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
clearPolling();
|
||||
disconnectLogs();
|
||||
currentAdmin = null;
|
||||
setSessionText('Not signed in');
|
||||
document.getElementById('loginView').classList.remove('hidden');
|
||||
document.getElementById('appView').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showApp(session) {
|
||||
currentAdmin = session;
|
||||
setSessionText(`Signed in as ${session.username}`);
|
||||
document.getElementById('loginView').classList.add('hidden');
|
||||
document.getElementById('appView').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
try {
|
||||
const session = await api('/api/admin/session');
|
||||
showApp(session);
|
||||
await refreshAll();
|
||||
connectLiveLogs();
|
||||
clearPolling();
|
||||
overviewTimer = setInterval(loadOverview, 1000);
|
||||
healthTimer = setInterval(loadHealth, 1000);
|
||||
} catch (_) {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
try {
|
||||
await api('/api/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
document.getElementById('loginPassword').value = '';
|
||||
await ensureSession();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api('/api/admin/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
} finally {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const data = await api('/api/admin/overview');
|
||||
const sys = data.system || {};
|
||||
const stream = data.stream || {};
|
||||
const chat = data.chat || {};
|
||||
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Errors</div><div class="v">${sys.errors_total ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Alloc (MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
|
||||
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
|
||||
<div class="metric"><div class="k">Disk Free / Total (GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
|
||||
`;
|
||||
|
||||
document.getElementById('online').innerHTML = `
|
||||
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
|
||||
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
|
||||
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
|
||||
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const h = await api('/api/admin/health');
|
||||
const dbOk = h.db && h.db.ok;
|
||||
document.getElementById('health').innerHTML =
|
||||
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
|
||||
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
|
||||
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
||||
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
||||
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
|
||||
const items = data.items || [];
|
||||
const box = document.getElementById('logs');
|
||||
box.textContent = '';
|
||||
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
|
||||
}
|
||||
|
||||
function connectLiveLogs() {
|
||||
disconnectLogs();
|
||||
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
|
||||
evt.onmessage = () => {};
|
||||
evt.addEventListener('log', (e) => {
|
||||
const item = JSON.parse(e.data);
|
||||
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
||||
});
|
||||
evt.onerror = () => {
|
||||
addLogLine('[warn] Live log stream disconnected.');
|
||||
};
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
||||
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
|
||||
const body = document.getElementById('usersBody');
|
||||
body.innerHTML = '';
|
||||
(data.items || []).forEach((u) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${u.id}</td>
|
||||
<td>${u.username}</td>
|
||||
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'user'}">${u.role}</span></td>
|
||||
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
|
||||
<td>${new Date(u.created_at).toLocaleString()}</td>
|
||||
<td class="actions">
|
||||
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
|
||||
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
|
||||
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
|
||||
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleRole(id, role) {
|
||||
const next = role === 'admin' ? 'user' : 'admin';
|
||||
await api(`/api/admin/users/${id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role: next }),
|
||||
});
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function toggleEnabled(id, enabled) {
|
||||
await api(`/api/admin/users/${id}/enabled`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function resetPwd(id) {
|
||||
const newPwd = prompt('Enter a new password for this user');
|
||||
if (!newPwd) return;
|
||||
await api(`/api/admin/users/${id}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPwd }),
|
||||
});
|
||||
addLogLine(`[audit] password reset requested for user ${id}`);
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
if (!confirm('Delete this user account?')) return;
|
||||
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const oldPassword = document.getElementById('oldPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
await api('/api/user/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
document.getElementById('oldPassword').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
alert('Password updated successfully.');
|
||||
}
|
||||
|
||||
window.addEventListener('load', ensureSession);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,6 +40,12 @@ type Hub struct {
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type StatsSnapshot struct {
|
||||
RoomCount int `json:"room_count"`
|
||||
TotalConnectedClient int `json:"total_connected_client"`
|
||||
RoomClients map[string]int `json:"room_clients"`
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
broadcast: make(chan Message),
|
||||
@@ -195,3 +201,22 @@ func InitChat() {
|
||||
MainHub = NewHub()
|
||||
go MainHub.Run()
|
||||
}
|
||||
|
||||
func (h *Hub) GetStatsSnapshot() StatsSnapshot {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
roomClients := make(map[string]int, len(h.rooms))
|
||||
totalClients := 0
|
||||
for roomID, clients := range h.rooms {
|
||||
count := len(clients)
|
||||
roomClients[roomID] = count
|
||||
totalClients += count
|
||||
}
|
||||
|
||||
return StatsSnapshot{
|
||||
RoomCount: len(h.rooms),
|
||||
TotalConnectedClient: totalClients,
|
||||
RoomClients: roomClients,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"hightube/internal/model"
|
||||
"hightube/internal/monitor"
|
||||
"hightube/internal/utils"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
@@ -19,10 +21,10 @@ func InitDB() {
|
||||
newLogger := logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second, // Slow SQL threshold
|
||||
LogLevel: logger.Warn, // Log level
|
||||
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
||||
Colorful: true, // Disable color
|
||||
SlowThreshold: time.Second, // Slow SQL threshold
|
||||
LogLevel: logger.Warn, // Log level
|
||||
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
||||
Colorful: true, // Disable color
|
||||
},
|
||||
)
|
||||
|
||||
@@ -44,5 +46,65 @@ func InitDB() {
|
||||
// Phase 3.5 Fix: Reset all rooms to inactive on startup using explicit map to ensure false is updated
|
||||
DB.Model(&model.Room{}).Where("1 = 1").Updates(map[string]interface{}{"is_active": false})
|
||||
|
||||
log.Println("Database initialized successfully.")
|
||||
ensureAdminUser()
|
||||
|
||||
monitor.Infof("Database initialized successfully")
|
||||
}
|
||||
|
||||
func ensureAdminUser() {
|
||||
adminUsername := os.Getenv("HIGHTUBE_ADMIN_USER")
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
}
|
||||
|
||||
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
|
||||
if adminPassword == "" {
|
||||
adminPassword = "admin"
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err := DB.Where("username = ?", adminUsername).First(&user).Error
|
||||
if err == nil {
|
||||
updates := map[string]interface{}{}
|
||||
if user.Role != "admin" {
|
||||
updates["role"] = "admin"
|
||||
}
|
||||
if !user.Enabled {
|
||||
updates["enabled"] = true
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
DB.Model(&user).Updates(updates)
|
||||
monitor.Warnf("Admin account normalized for username=%s", adminUsername)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hash, hashErr := utils.HashPassword(adminPassword)
|
||||
if hashErr != nil {
|
||||
monitor.Errorf("Failed to hash default admin password: %v", hashErr)
|
||||
return
|
||||
}
|
||||
|
||||
newAdmin := model.User{
|
||||
Username: adminUsername,
|
||||
Password: hash,
|
||||
Role: "admin",
|
||||
Enabled: true,
|
||||
}
|
||||
if createErr := DB.Create(&newAdmin).Error; createErr != nil {
|
||||
monitor.Errorf("Failed to create admin account: %v", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
room := model.Room{
|
||||
UserID: newAdmin.ID,
|
||||
Title: newAdmin.Username + "'s Live Room",
|
||||
StreamKey: utils.GenerateStreamKey(),
|
||||
IsActive: false,
|
||||
}
|
||||
if roomErr := DB.Create(&room).Error; roomErr != nil {
|
||||
monitor.Warnf("Failed to create default admin room: %v", roomErr)
|
||||
}
|
||||
|
||||
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
Password string `gorm:"not null"` // Hashed password
|
||||
Role string `gorm:"type:varchar(20);not null;default:user"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
}
|
||||
|
||||
7
backend/internal/monitor/disk_other.go
Normal file
7
backend/internal/monitor/disk_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package monitor
|
||||
|
||||
func getDiskSpaceGB() (float64, float64) {
|
||||
return 0, 0
|
||||
}
|
||||
38
backend/internal/monitor/disk_windows.go
Normal file
38
backend/internal/monitor/disk_windows.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build windows
|
||||
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func getDiskSpaceGB() (float64, float64) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
vol := filepath.VolumeName(wd)
|
||||
if vol == "" {
|
||||
return 0, 0
|
||||
}
|
||||
root := vol + `\\`
|
||||
|
||||
pathPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var freeBytesAvailable uint64
|
||||
var totalBytes uint64
|
||||
var totalFreeBytes uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalBytes, &totalFreeBytes); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
const gb = 1024.0 * 1024.0 * 1024.0
|
||||
return float64(totalBytes) / gb, float64(totalFreeBytes) / gb
|
||||
}
|
||||
130
backend/internal/monitor/logs.go
Normal file
130
backend/internal/monitor/logs.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type logHub struct {
|
||||
mutex sync.RWMutex
|
||||
entries []LogEntry
|
||||
maxEntries int
|
||||
subscribers map[chan LogEntry]struct{}
|
||||
}
|
||||
|
||||
var hub = &logHub{
|
||||
maxEntries: 1000,
|
||||
subscribers: make(map[chan LogEntry]struct{}),
|
||||
}
|
||||
|
||||
func Init(maxEntries int) {
|
||||
if maxEntries > 0 {
|
||||
hub.maxEntries = maxEntries
|
||||
}
|
||||
}
|
||||
|
||||
func Infof(format string, args ...interface{}) {
|
||||
appendEntry("info", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Warnf(format string, args ...interface{}) {
|
||||
appendEntry("warn", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
appendEntry("error", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Auditf(format string, args ...interface{}) {
|
||||
appendEntry("audit", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func appendEntry(level, message string) {
|
||||
entry := LogEntry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Level: strings.ToLower(level),
|
||||
Message: message,
|
||||
}
|
||||
|
||||
log.Printf("[%s] %s", strings.ToUpper(entry.Level), entry.Message)
|
||||
|
||||
hub.mutex.Lock()
|
||||
hub.entries = append(hub.entries, entry)
|
||||
if len(hub.entries) > hub.maxEntries {
|
||||
hub.entries = hub.entries[len(hub.entries)-hub.maxEntries:]
|
||||
}
|
||||
|
||||
for ch := range hub.subscribers {
|
||||
select {
|
||||
case ch <- entry:
|
||||
default:
|
||||
}
|
||||
}
|
||||
hub.mutex.Unlock()
|
||||
}
|
||||
|
||||
func Subscribe() chan LogEntry {
|
||||
ch := make(chan LogEntry, 100)
|
||||
hub.mutex.Lock()
|
||||
hub.subscribers[ch] = struct{}{}
|
||||
hub.mutex.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func Unsubscribe(ch chan LogEntry) {
|
||||
hub.mutex.Lock()
|
||||
if _, ok := hub.subscribers[ch]; ok {
|
||||
delete(hub.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
hub.mutex.Unlock()
|
||||
}
|
||||
|
||||
func Query(level, keyword string, page, pageSize int) ([]LogEntry, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
level = strings.TrimSpace(strings.ToLower(level))
|
||||
keyword = strings.TrimSpace(strings.ToLower(keyword))
|
||||
|
||||
hub.mutex.RLock()
|
||||
defer hub.mutex.RUnlock()
|
||||
|
||||
filtered := make([]LogEntry, 0, len(hub.entries))
|
||||
for _, e := range hub.entries {
|
||||
if level != "" && e.Level != level {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(e.Message), keyword) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return []LogEntry{}, total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return filtered[start:end], total
|
||||
}
|
||||
55
backend/internal/monitor/metrics.go
Normal file
55
backend/internal/monitor/metrics.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var startedAt = time.Now()
|
||||
|
||||
var totalRequests uint64
|
||||
var totalErrors uint64
|
||||
|
||||
type Snapshot struct {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryAllocMB float64 `json:"memory_alloc_mb"`
|
||||
MemorySysMB float64 `json:"memory_sys_mb"`
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
DiskTotalGB float64 `json:"disk_total_gb"`
|
||||
DiskFreeGB float64 `json:"disk_free_gb"`
|
||||
RequestsTotal uint64 `json:"requests_total"`
|
||||
ErrorsTotal uint64 `json:"errors_total"`
|
||||
}
|
||||
|
||||
func IncrementRequestCount() {
|
||||
atomic.AddUint64(&totalRequests, 1)
|
||||
}
|
||||
|
||||
func IncrementErrorCount() {
|
||||
atomic.AddUint64(&totalErrors, 1)
|
||||
}
|
||||
|
||||
func GetSnapshot() Snapshot {
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
diskTotal, diskFree := getDiskSpaceGB()
|
||||
|
||||
return Snapshot{
|
||||
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
MemoryAllocMB: bytesToMB(mem.Alloc),
|
||||
MemorySysMB: bytesToMB(mem.Sys),
|
||||
CPUCores: runtime.NumCPU(),
|
||||
DiskTotalGB: diskTotal,
|
||||
DiskFreeGB: diskFree,
|
||||
RequestsTotal: atomic.LoadUint64(&totalRequests),
|
||||
ErrorsTotal: atomic.LoadUint64(&totalErrors),
|
||||
}
|
||||
}
|
||||
|
||||
func bytesToMB(v uint64) float64 {
|
||||
return float64(v) / 1024.0 / 1024.0
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nareix/joy4/av/avutil"
|
||||
@@ -17,6 +22,7 @@ import (
|
||||
"hightube/internal/chat"
|
||||
"hightube/internal/db"
|
||||
"hightube/internal/model"
|
||||
"hightube/internal/monitor"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -26,9 +32,38 @@ func init() {
|
||||
|
||||
// RTMPServer manages all active live streams
|
||||
type RTMPServer struct {
|
||||
server *rtmp.Server
|
||||
channels map[string]*pubsub.Queue
|
||||
mutex sync.RWMutex
|
||||
server *rtmp.Server
|
||||
channels map[string]*pubsub.Queue
|
||||
transcoders map[string][]*variantTranscoder
|
||||
internalPublishKey string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type variantTranscoder struct {
|
||||
quality string
|
||||
cancel context.CancelFunc
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
type qualityProfile struct {
|
||||
scale string
|
||||
videoBitrate string
|
||||
audioBitrate string
|
||||
}
|
||||
|
||||
var qualityOrder = []string{"source", "720p", "480p"}
|
||||
|
||||
var supportedQualities = map[string]qualityProfile{
|
||||
"720p": {
|
||||
scale: "1280:-2",
|
||||
videoBitrate: "2500k",
|
||||
audioBitrate: "128k",
|
||||
},
|
||||
"480p": {
|
||||
scale: "854:-2",
|
||||
videoBitrate: "1200k",
|
||||
audioBitrate: "96k",
|
||||
},
|
||||
}
|
||||
|
||||
type writeFlusher struct {
|
||||
@@ -44,64 +79,70 @@ func (w writeFlusher) Flush() error {
|
||||
// NewRTMPServer creates and initializes a new media server
|
||||
func NewRTMPServer() *RTMPServer {
|
||||
s := &RTMPServer{
|
||||
channels: make(map[string]*pubsub.Queue),
|
||||
server: &rtmp.Server{},
|
||||
channels: make(map[string]*pubsub.Queue),
|
||||
transcoders: make(map[string][]*variantTranscoder),
|
||||
internalPublishKey: generateInternalPublishKey(),
|
||||
server: &rtmp.Server{},
|
||||
}
|
||||
|
||||
// Triggered when a broadcaster (e.g., OBS) starts publishing
|
||||
s.server.HandlePublish = func(conn *rtmp.Conn) {
|
||||
streamPath := conn.URL.Path // Expected format: /live/{stream_key}
|
||||
fmt.Printf("[INFO] OBS is attempting to publish to: %s\n", streamPath)
|
||||
streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
|
||||
monitor.Infof("OBS publish attempt: %s", streamPath)
|
||||
|
||||
// Extract stream key from path
|
||||
parts := strings.Split(streamPath, "/")
|
||||
if len(parts) < 3 || parts[1] != "live" {
|
||||
fmt.Printf("[WARN] Invalid publish path format: %s\n", streamPath)
|
||||
if len(parts) < 3 {
|
||||
monitor.Warnf("Invalid publish path format: %s", streamPath)
|
||||
return
|
||||
}
|
||||
streamKey := parts[2]
|
||||
|
||||
// Authenticate stream key
|
||||
var room model.Room
|
||||
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
|
||||
fmt.Printf("[WARN] Authentication failed, invalid stream key: %s\n", streamKey)
|
||||
return // Reject connection
|
||||
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
|
||||
if !ok {
|
||||
monitor.Warnf("Invalid publish key/path: %s", streamPath)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[INFO] Stream authenticated for Room ID: %d\n", room.ID)
|
||||
|
||||
// 1. Get audio/video stream metadata
|
||||
streams, err := conn.Streams()
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] Failed to parse stream headers: %v\n", err)
|
||||
monitor.Errorf("Failed to parse stream headers: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
|
||||
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
|
||||
|
||||
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
|
||||
|
||||
s.mutex.Lock()
|
||||
q := pubsub.NewQueue()
|
||||
q.WriteHeader(streams)
|
||||
s.channels[roomLivePath] = q
|
||||
s.channels[channelPath] = q
|
||||
s.mutex.Unlock()
|
||||
|
||||
// Mark room as active in DB (using map to ensure true/false is correctly updated)
|
||||
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true})
|
||||
if isSource {
|
||||
roomIDUint := parseRoomID(roomID)
|
||||
if roomIDUint != 0 {
|
||||
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
|
||||
}
|
||||
s.startVariantTranscoders(roomID)
|
||||
}
|
||||
|
||||
// 3. Cleanup on end
|
||||
defer func() {
|
||||
s.mutex.Lock()
|
||||
delete(s.channels, roomLivePath)
|
||||
delete(s.channels, channelPath)
|
||||
s.mutex.Unlock()
|
||||
q.Close()
|
||||
// Explicitly set is_active to false using map
|
||||
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false})
|
||||
|
||||
// Clear chat history for this room
|
||||
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
|
||||
|
||||
fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID)
|
||||
|
||||
if isSource {
|
||||
s.stopVariantTranscoders(roomID)
|
||||
roomIDUint := parseRoomID(roomID)
|
||||
if roomIDUint != 0 {
|
||||
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
|
||||
}
|
||||
chat.MainHub.ClearRoomHistory(roomID)
|
||||
monitor.Infof("Publishing ended for room_id=%s", roomID)
|
||||
} else {
|
||||
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// 4. Continuously copy data packets to our broadcast queue
|
||||
@@ -111,7 +152,7 @@ func NewRTMPServer() *RTMPServer {
|
||||
// Triggered when a viewer (e.g., VLC) requests playback
|
||||
s.server.HandlePlay = func(conn *rtmp.Conn) {
|
||||
streamPath := conn.URL.Path // Expected format: /live/{room_id}
|
||||
fmt.Printf("[INFO] VLC is pulling stream from: %s\n", streamPath)
|
||||
monitor.Infof("RTMP play requested: %s", streamPath)
|
||||
|
||||
// 1. Look for the requested room's data queue
|
||||
s.mutex.RLock()
|
||||
@@ -119,7 +160,7 @@ func NewRTMPServer() *RTMPServer {
|
||||
s.mutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
fmt.Printf("[WARN] Stream not found or inactive: %s\n", streamPath)
|
||||
monitor.Warnf("Stream not found or inactive: %s", streamPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,7 +170,7 @@ func NewRTMPServer() *RTMPServer {
|
||||
conn.WriteHeader(streams)
|
||||
|
||||
// 3. Cleanup on end
|
||||
defer fmt.Printf("[INFO] Playback ended: %s\n", streamPath)
|
||||
defer monitor.Infof("Playback ended: %s", streamPath)
|
||||
|
||||
// 4. Continuously copy data packets to the viewer
|
||||
err := avutil.CopyPackets(conn, cursor)
|
||||
@@ -137,9 +178,9 @@ func NewRTMPServer() *RTMPServer {
|
||||
// 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
|
||||
fmt.Printf("[INFO] Viewer disconnected normally: %s\n", streamPath)
|
||||
monitor.Infof("Viewer disconnected: %s", streamPath)
|
||||
} else {
|
||||
fmt.Printf("[ERROR] Error occurred during playback: %v\n", err)
|
||||
monitor.Errorf("Playback error on %s: %v", streamPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,13 +191,16 @@ func NewRTMPServer() *RTMPServer {
|
||||
// Start launches the RTMP server
|
||||
func (s *RTMPServer) Start(addr string) error {
|
||||
s.server.Addr = addr
|
||||
fmt.Printf("[INFO] RTMP Server is listening on %s...\n", addr)
|
||||
monitor.Infof("RTMP server listening on %s", addr)
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
|
||||
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
||||
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
|
||||
if quality := normalizeQuality(c.Query("quality")); quality != "" {
|
||||
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
q, ok := s.channels[streamPath]
|
||||
@@ -190,9 +234,167 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
||||
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
|
||||
fmt.Printf("[INFO] HTTP-FLV viewer disconnected normally: %s\n", streamPath)
|
||||
monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
|
||||
return
|
||||
}
|
||||
fmt.Printf("[ERROR] HTTP-FLV playback error on %s: %v\n", streamPath, err)
|
||||
monitor.Errorf("HTTP-FLV playback error on %s: %v", streamPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
|
||||
if parts[1] == "live" && len(parts) == 3 {
|
||||
var room model.Room
|
||||
if err := db.DB.Where("stream_key = ?", parts[2]).First(&room).Error; err != nil {
|
||||
return "", "", false, false
|
||||
}
|
||||
roomID = fmt.Sprintf("%d", room.ID)
|
||||
channelPath = fmt.Sprintf("/live/%s", roomID)
|
||||
return roomID, channelPath, true, true
|
||||
}
|
||||
|
||||
if parts[1] == "variant" && len(parts) == 5 {
|
||||
roomID = parts[2]
|
||||
quality := normalizeQuality(parts[3])
|
||||
token := parts[4]
|
||||
if quality == "" || token != s.internalPublishKey {
|
||||
return "", "", false, false
|
||||
}
|
||||
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
|
||||
}
|
||||
|
||||
return "", "", false, false
|
||||
}
|
||||
|
||||
func (s *RTMPServer) startVariantTranscoders(roomID string) {
|
||||
s.stopVariantTranscoders(roomID)
|
||||
|
||||
launch := make([]*variantTranscoder, 0, len(supportedQualities))
|
||||
for quality, profile := range supportedQualities {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
inputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID)
|
||||
outputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/variant/%s/%s/%s", roomID, quality, s.internalPublishKey)
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel", "error",
|
||||
"-i", inputURL,
|
||||
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-tune", "zerolatency",
|
||||
"-g", "48",
|
||||
"-keyint_min", "48",
|
||||
"-sc_threshold", "0",
|
||||
"-b:v", profile.videoBitrate,
|
||||
"-maxrate", profile.videoBitrate,
|
||||
"-bufsize", profile.videoBitrate,
|
||||
"-c:a", "aac",
|
||||
"-b:a", profile.audioBitrate,
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-f", "flv",
|
||||
outputURL,
|
||||
)
|
||||
|
||||
transcoder := &variantTranscoder{
|
||||
quality: quality,
|
||||
cancel: cancel,
|
||||
cmd: cmd,
|
||||
}
|
||||
launch = append(launch, transcoder)
|
||||
|
||||
go func(roomID string, tr *variantTranscoder) {
|
||||
time.Sleep(2 * time.Second)
|
||||
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
|
||||
if err := tr.cmd.Start(); err != nil {
|
||||
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||
return
|
||||
}
|
||||
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||
}
|
||||
}(roomID, transcoder)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.transcoders[roomID] = launch
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
|
||||
s.mutex.Lock()
|
||||
transcoders := s.transcoders[roomID]
|
||||
delete(s.transcoders, roomID)
|
||||
s.mutex.Unlock()
|
||||
|
||||
for _, transcoder := range transcoders {
|
||||
transcoder.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeQuality(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if _, ok := supportedQualities[value]; ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRoomID(value string) uint {
|
||||
var roomID uint
|
||||
_, _ = fmt.Sscanf(value, "%d", &roomID)
|
||||
return roomID
|
||||
}
|
||||
|
||||
func generateInternalPublishKey() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "internal_publish_fallback_key"
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func (s *RTMPServer) ActiveStreamCount() int {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
count := 0
|
||||
for path := range s.channels {
|
||||
if strings.Count(path, "/") == 2 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
basePath := fmt.Sprintf("/live/%s", roomID)
|
||||
available := make([]string, 0, len(qualityOrder))
|
||||
for _, quality := range qualityOrder {
|
||||
streamPath := basePath
|
||||
if quality != "source" {
|
||||
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
|
||||
}
|
||||
if _, ok := s.channels[streamPath]; ok {
|
||||
available = append(available, quality)
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func (s *RTMPServer) ActiveStreamPaths() []string {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
paths := make([]string, 0, len(s.channels))
|
||||
for path := range s.channels {
|
||||
if strings.Count(path, "/") == 2 {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -13,26 +13,36 @@ import (
|
||||
// In production, load this from environment variables
|
||||
var jwtKey = []byte("hightube_super_secret_key_MVP_only")
|
||||
|
||||
type TokenClaims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken generates a JWT token for a given user ID
|
||||
func GenerateToken(userID uint) (string, error) {
|
||||
claims := &jwt.RegisteredClaims{
|
||||
func GenerateToken(userID uint, username, role string) (string, error) {
|
||||
claims := &TokenClaims{
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtKey)
|
||||
}
|
||||
|
||||
// ParseToken parses the JWT string and returns the user ID (Subject)
|
||||
func ParseToken(tokenStr string) (string, error) {
|
||||
claims := &jwt.RegisteredClaims{}
|
||||
func ParseToken(tokenStr string) (*TokenClaims, error) {
|
||||
claims := &TokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return jwtKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return claims.Subject, nil
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// HashPassword creates a bcrypt hash of the password
|
||||
|
||||
@@ -17,8 +17,17 @@ class LoginPage extends StatefulWidget {
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleLogin() async {
|
||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
@@ -108,6 +117,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Fields
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Username",
|
||||
prefixIcon: Icon(Icons.person),
|
||||
@@ -119,7 +130,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) {
|
||||
if (!_isLoading) {
|
||||
_handleLogin();
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password",
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -6,6 +9,7 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../widgets/web_stream_player.dart';
|
||||
|
||||
@@ -22,7 +26,7 @@ class PlayerPage extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
_PlayerPageState createState() => _PlayerPageState();
|
||||
State<PlayerPage> createState() => _PlayerPageState();
|
||||
}
|
||||
|
||||
class _PlayerPageState extends State<PlayerPage> {
|
||||
@@ -30,28 +34,33 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
final ChatService _chatService = ChatService();
|
||||
final TextEditingController _msgController = TextEditingController();
|
||||
final List<ChatMessage> _messages = [];
|
||||
final List<Widget> _danmakus = [];
|
||||
final List<_DanmakuEntry> _danmakus = [];
|
||||
|
||||
bool _isError = false;
|
||||
String? _errorMessage;
|
||||
bool _showDanmaku = true;
|
||||
bool _isRefreshing = false;
|
||||
bool _isFullscreen = false;
|
||||
bool _controlsVisible = true;
|
||||
int _playerVersion = 0;
|
||||
String _selectedResolution = 'Source';
|
||||
List<String> _availableResolutions = const ['Source'];
|
||||
Timer? _controlsHideTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaybackOptions();
|
||||
if (!kIsWeb) {
|
||||
_initializePlayer();
|
||||
}
|
||||
_initializeChat();
|
||||
_showControls();
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
_controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(widget.playbackUrl),
|
||||
);
|
||||
final playbackUrl = _currentPlaybackUrl();
|
||||
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
|
||||
try {
|
||||
await _controller!.initialize();
|
||||
_controller!.play();
|
||||
@@ -75,6 +84,55 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _currentPlaybackUrl() {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final quality = _selectedResolution == 'Source'
|
||||
? null
|
||||
: _selectedResolution.toLowerCase();
|
||||
return settings.playbackUrl(widget.roomId, quality: quality);
|
||||
}
|
||||
|
||||
Future<void> _loadPlaybackOptions() async {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
final api = ApiService(settings, auth.token);
|
||||
|
||||
try {
|
||||
final response = await api.getPlaybackOptions(widget.roomId);
|
||||
if (response.statusCode != 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rawQualities =
|
||||
(data['qualities'] as List<dynamic>? ?? const ['source'])
|
||||
.map((item) => item.toString().trim().toLowerCase())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final normalized = <String>['Source'];
|
||||
for (final quality in rawQualities) {
|
||||
if (quality == 'source') {
|
||||
continue;
|
||||
}
|
||||
normalized.add(quality);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_availableResolutions = normalized.toSet().toList();
|
||||
if (!_availableResolutions.contains(_selectedResolution)) {
|
||||
_selectedResolution = 'Source';
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Keep source-only playback when the capability probe fails.
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeChat() {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
@@ -99,18 +157,23 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
|
||||
void _addDanmaku(String text) {
|
||||
final key = UniqueKey();
|
||||
final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0;
|
||||
final lane = DateTime.now().millisecondsSinceEpoch % 8;
|
||||
final topFactor = 0.06 + lane * 0.045;
|
||||
|
||||
final danmaku = _DanmakuItem(
|
||||
key: key,
|
||||
text: text,
|
||||
top: top,
|
||||
onFinished: () {
|
||||
if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == key));
|
||||
},
|
||||
);
|
||||
|
||||
setState(() => _danmakus.add(danmaku));
|
||||
setState(() {
|
||||
_danmakus.add(
|
||||
_DanmakuEntry(
|
||||
key: key,
|
||||
text: text,
|
||||
topFactor: topFactor,
|
||||
onFinished: () {
|
||||
if (mounted) {
|
||||
setState(() => _danmakus.removeWhere((w) => w.key == key));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _sendMsg() {
|
||||
@@ -130,6 +193,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _loadPlaybackOptions();
|
||||
|
||||
setState(() {
|
||||
_isRefreshing = true;
|
||||
_isError = false;
|
||||
@@ -137,6 +202,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
_danmakus.clear();
|
||||
_playerVersion++;
|
||||
});
|
||||
_showControls();
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
@@ -175,6 +241,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
if (mounted) {
|
||||
setState(() => _isFullscreen = nextValue);
|
||||
}
|
||||
_showControls();
|
||||
}
|
||||
|
||||
void _toggleDanmaku() {
|
||||
@@ -184,6 +251,82 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
_danmakus.clear();
|
||||
}
|
||||
});
|
||||
_showControls();
|
||||
}
|
||||
|
||||
void _showControls() {
|
||||
_controlsHideTimer?.cancel();
|
||||
if (mounted) {
|
||||
setState(() => _controlsVisible = true);
|
||||
}
|
||||
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
setState(() => _controlsVisible = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleControlsVisibility() {
|
||||
if (_controlsVisible) {
|
||||
_controlsHideTimer?.cancel();
|
||||
setState(() => _controlsVisible = false);
|
||||
} else {
|
||||
_showControls();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectResolution() async {
|
||||
_showControls();
|
||||
await _loadPlaybackOptions();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextResolution = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
const options = ['Source', '720p', '480p'];
|
||||
final available = _availableResolutions.toSet();
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('Playback Resolution'),
|
||||
subtitle: Text(
|
||||
available.length > 1
|
||||
? 'Select an available transcoded stream.'
|
||||
: 'Only the source stream is available right now.',
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
final enabled = available.contains(option);
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: Icon(
|
||||
option == _selectedResolution
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
),
|
||||
title: Text(option),
|
||||
subtitle: enabled
|
||||
? const Text('Available now')
|
||||
: const Text('Waiting for backend transcoding output'),
|
||||
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (nextResolution == null || nextResolution == _selectedResolution) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _selectedResolution = nextResolution);
|
||||
await _refreshPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -192,6 +335,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
_controlsHideTimer?.cancel();
|
||||
_controller?.dispose();
|
||||
_chatService.dispose();
|
||||
_msgController.dispose();
|
||||
@@ -203,11 +347,22 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
bool isWide = MediaQuery.of(context).size.width > 900;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.title)),
|
||||
body: isWide ? _buildWideLayout() : _buildMobileLayout(),
|
||||
backgroundColor: _isFullscreen
|
||||
? Colors.black
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
appBar: _isFullscreen ? null : AppBar(title: Text(widget.title)),
|
||||
body: _isFullscreen
|
||||
? _buildFullscreenLayout()
|
||||
: isWide
|
||||
? _buildWideLayout()
|
||||
: _buildMobileLayout(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullscreenLayout() {
|
||||
return SizedBox.expand(child: _buildVideoPanel());
|
||||
}
|
||||
|
||||
// 宽屏布局:左右分栏
|
||||
Widget _buildWideLayout() {
|
||||
return Row(
|
||||
@@ -244,96 +399,179 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
|
||||
Widget _buildVideoPanel() {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildVideoWithDanmaku()),
|
||||
_buildPlaybackControls(),
|
||||
],
|
||||
return MouseRegion(
|
||||
onHover: (_) => _showControls(),
|
||||
onEnter: (_) => _showControls(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggleControlsVisibility,
|
||||
onDoubleTap: _toggleFullscreen,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildVideoWithDanmaku()),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildPlaybackControls(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoWithDanmaku() {
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: _isError
|
||||
? Text(
|
||||
"Error: $_errorMessage",
|
||||
style: TextStyle(color: Colors.white),
|
||||
)
|
||||
: kIsWeb
|
||||
? WebStreamPlayer(
|
||||
key: ValueKey('web-player-$_playerVersion'),
|
||||
streamUrl: widget.playbackUrl,
|
||||
)
|
||||
: _controller != null && _controller!.value.isInitialized
|
||||
? AspectRatio(
|
||||
aspectRatio: _controller!.value.aspectRatio,
|
||||
child: VideoPlayer(_controller!),
|
||||
)
|
||||
: CircularProgressIndicator(),
|
||||
),
|
||||
if (_showDanmaku) ClipRect(child: Stack(children: _danmakus)),
|
||||
if (_isRefreshing)
|
||||
const Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: _isError
|
||||
? Text(
|
||||
"Error: $_errorMessage",
|
||||
style: TextStyle(color: Colors.white),
|
||||
)
|
||||
: kIsWeb
|
||||
? WebStreamPlayer(
|
||||
key: ValueKey('web-player-$_playerVersion'),
|
||||
streamUrl: _currentPlaybackUrl(),
|
||||
)
|
||||
: _controller != null && _controller!.value.isInitialized
|
||||
? AspectRatio(
|
||||
aspectRatio: _controller!.value.aspectRatio,
|
||||
child: VideoPlayer(_controller!),
|
||||
)
|
||||
: CircularProgressIndicator(),
|
||||
),
|
||||
if (_showDanmaku)
|
||||
ClipRect(
|
||||
child: Stack(
|
||||
children: _danmakus
|
||||
.map(
|
||||
(item) => _DanmakuItem(
|
||||
key: item.key,
|
||||
text: item.text,
|
||||
topFactor: item.topFactor,
|
||||
containerWidth: constraints.maxWidth,
|
||||
containerHeight: constraints.maxHeight,
|
||||
onFinished: item.onFinished,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (_isRefreshing)
|
||||
const Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Color _usernameColor(String username, String type) {
|
||||
if (type == "system") {
|
||||
return Colors.blue;
|
||||
}
|
||||
final normalized = username.trim().toLowerCase();
|
||||
var hash = 5381;
|
||||
for (final codeUnit in normalized.codeUnits) {
|
||||
hash = ((hash << 5) + hash) ^ codeUnit;
|
||||
}
|
||||
final hue = (hash.abs() % 360).toDouble();
|
||||
return HSLColor.fromAHSL(1, hue, 0.72, 0.68).toColor();
|
||||
}
|
||||
|
||||
Widget _buildMessageItem(ChatMessage message) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${message.username}: ",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _usernameColor(message.username, message.type),
|
||||
),
|
||||
),
|
||||
TextSpan(text: message.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaybackControls() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.92),
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_buildControlButton(
|
||||
icon: Icons.refresh,
|
||||
label: "Refresh",
|
||||
onPressed: _refreshPlayer,
|
||||
return IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _controlsVisible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.9),
|
||||
Colors.black.withValues(alpha: 0.55),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
||||
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
|
||||
onPressed: _toggleDanmaku,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: Icons.high_quality,
|
||||
label: "Resolution",
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"Resolution switching is planned for a later update.",
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_buildControlButton(
|
||||
icon: Icons.refresh,
|
||||
label: "Refresh",
|
||||
onPressed: _refreshPlayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
_buildControlButton(
|
||||
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
||||
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
|
||||
onPressed: _toggleDanmaku,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
_buildControlButton(
|
||||
icon: Icons.high_quality,
|
||||
label: _selectedResolution,
|
||||
onPressed: _selectResolution,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -341,10 +579,13 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
Widget _buildControlButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
required FutureOr<void> Function() onPressed,
|
||||
}) {
|
||||
return FilledButton.tonalIcon(
|
||||
onPressed: onPressed,
|
||||
onPressed: () async {
|
||||
_showControls();
|
||||
await onPressed();
|
||||
},
|
||||
icon: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -376,28 +617,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final m = _messages[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${m.username}: ",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: m.type == "system"
|
||||
? Colors.blue
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
TextSpan(text: m.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return _buildMessageItem(m);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -437,15 +657,33 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuEntry {
|
||||
final Key key;
|
||||
final String text;
|
||||
final double topFactor;
|
||||
final VoidCallback onFinished;
|
||||
|
||||
const _DanmakuEntry({
|
||||
required this.key,
|
||||
required this.text,
|
||||
required this.topFactor,
|
||||
required this.onFinished,
|
||||
});
|
||||
}
|
||||
|
||||
class _DanmakuItem extends StatefulWidget {
|
||||
final String text;
|
||||
final double top;
|
||||
final double topFactor;
|
||||
final double containerWidth;
|
||||
final double containerHeight;
|
||||
final VoidCallback onFinished;
|
||||
|
||||
const _DanmakuItem({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.top,
|
||||
required this.topFactor,
|
||||
required this.containerWidth,
|
||||
required this.containerHeight,
|
||||
required this.onFinished,
|
||||
});
|
||||
|
||||
@@ -487,9 +725,8 @@ class __DanmakuItemState extends State<_DanmakuItem>
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: widget.top,
|
||||
// left 使用 MediaQuery 获取屏幕宽度进行动态计算
|
||||
left: MediaQuery.of(context).size.width * _animation.value,
|
||||
top: widget.containerHeight * widget.topFactor,
|
||||
left: widget.containerWidth * _animation.value,
|
||||
child: Text(
|
||||
widget.text,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -307,11 +307,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
"Version: 1.0.0-beta3.5",
|
||||
"Version: 1.0.0-beta4.1",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
"Author: Highground-Soft & Minimax",
|
||||
"Author: Highground-Soft",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
|
||||
@@ -62,12 +62,26 @@ class SettingsProvider with ChangeNotifier {
|
||||
return "rtmp://${uri.host}:1935/live";
|
||||
}
|
||||
|
||||
String playbackUrl(String roomId) {
|
||||
String playbackUrl(String roomId, {String? quality}) {
|
||||
final uri = Uri.parse(_baseUrl);
|
||||
final normalizedQuality = quality?.trim().toLowerCase();
|
||||
|
||||
if (kIsWeb) {
|
||||
return uri.replace(path: '/live/$roomId').toString();
|
||||
return uri
|
||||
.replace(
|
||||
path: '/live/$roomId',
|
||||
queryParameters:
|
||||
normalizedQuality == null || normalizedQuality.isEmpty
|
||||
? null
|
||||
: {'quality': normalizedQuality},
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
return "$rtmpUrl/$roomId";
|
||||
|
||||
if (normalizedQuality == null || normalizedQuality.isEmpty) {
|
||||
return "$rtmpUrl/$roomId";
|
||||
}
|
||||
return "$rtmpUrl/$roomId/$normalizedQuality";
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String value) {
|
||||
|
||||
@@ -43,11 +43,24 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> changePassword(String oldPassword, String newPassword) async {
|
||||
Future<http.Response> getPlaybackOptions(String roomId) async {
|
||||
return await http.get(
|
||||
Uri.parse("${settings.baseUrl}/api/rooms/$roomId/playback-options"),
|
||||
headers: _headers,
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> changePassword(
|
||||
String oldPassword,
|
||||
String newPassword,
|
||||
) async {
|
||||
return await http.post(
|
||||
Uri.parse("${settings.baseUrl}/api/user/change-password"),
|
||||
headers: _headers,
|
||||
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}),
|
||||
body: jsonEncode({
|
||||
"old_password": oldPassword,
|
||||
"new_password": newPassword,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0-beta3.7
|
||||
version: 1.0.0-beta4.1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.1
|
||||
|
||||
Reference in New Issue
Block a user