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 }