package handler import ( "encoding/json" "errors" "net/http" "strconv" "strings" "time" "xboard-go/internal/database" "xboard-go/internal/model" "xboard-go/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func AdminServerGroupsFetch(c *gin.Context) { var groups []model.ServerGroup if err := database.DB.Order("id DESC").Find(&groups).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch server groups") return } var servers []model.Server if err := database.DB.Find(&servers).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch servers") return } type usageCount struct { GroupID *int Total int64 } var userCounts []usageCount if err := database.DB.Model(&model.User{}). Select("group_id, COUNT(*) AS total"). Group("group_id"). Scan(&userCounts).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch group usage") return } userCountMap := make(map[int]int64, len(userCounts)) for _, item := range userCounts { if item.GroupID == nil { continue } userCountMap[*item.GroupID] = item.Total } serverCountMap := make(map[int]int) for _, server := range servers { for _, groupID := range decodeIntSlice(server.GroupIDs) { serverCountMap[groupID] += 1 } } result := make([]gin.H, 0, len(groups)) for _, group := range groups { result = append(result, gin.H{ "id": group.ID, "name": group.Name, "created_at": group.CreatedAt, "updated_at": group.UpdatedAt, "user_count": userCountMap[group.ID], "server_count": serverCountMap[group.ID], }) } Success(c, result) } func AdminServerGroupSave(c *gin.Context) { var payload struct { ID *int `json:"id"` Name string `json:"name"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } name := strings.TrimSpace(payload.Name) if name == "" { Fail(c, http.StatusUnprocessableEntity, "group name is required") return } now := time.Now().Unix() if payload.ID != nil && *payload.ID > 0 { if err := database.DB.Model(&model.ServerGroup{}). Where("id = ?", *payload.ID). Updates(map[string]any{ "name": name, "updated_at": now, }).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to save server group") return } Success(c, true) return } group := model.ServerGroup{ Name: name, CreatedAt: now, UpdatedAt: now, } if err := database.DB.Create(&group).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to create server group") return } Success(c, true) } func AdminServerGroupDrop(c *gin.Context) { var payload struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "group id is required") return } var group model.ServerGroup if err := database.DB.Where("id = ?", payload.ID).First(&group).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { Fail(c, http.StatusBadRequest, "server group does not exist") return } Fail(c, http.StatusInternalServerError, "failed to load server group") return } var servers []model.Server if err := database.DB.Find(&servers).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to inspect server usage") return } for _, server := range servers { if intSliceContains(decodeIntSlice(server.GroupIDs), payload.ID) { Fail(c, http.StatusBadRequest, "server group is still used by nodes") return } } var planUsage int64 if err := database.DB.Model(&model.Plan{}).Where("group_id = ?", payload.ID).Count(&planUsage).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to inspect plan usage") return } if planUsage > 0 { Fail(c, http.StatusBadRequest, "server group is still used by plans") return } var userUsage int64 if err := database.DB.Model(&model.User{}).Where("group_id = ?", payload.ID).Count(&userUsage).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to inspect user usage") return } if userUsage > 0 { Fail(c, http.StatusBadRequest, "server group is still used by users") return } if err := database.DB.Delete(&group).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to delete server group") return } Success(c, true) } func AdminServerRoutesFetch(c *gin.Context) { var routes []model.ServerRoute if err := database.DB.Order("id DESC").Find(&routes).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch server routes") return } result := make([]gin.H, 0, len(routes)) for _, route := range routes { result = append(result, gin.H{ "id": route.ID, "remarks": stringValue(route.Remarks), "match": decodeStringSlice(route.Match), "action": stringValue(route.Action), "action_value": stringValue(route.ActionValue), "created_at": route.CreatedAt, "updated_at": route.UpdatedAt, }) } Success(c, result) } func AdminServerRouteSave(c *gin.Context) { var payload struct { ID *int `json:"id"` Remarks string `json:"remarks"` Match []any `json:"match"` Action string `json:"action"` ActionValue any `json:"action_value"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } remarks := strings.TrimSpace(payload.Remarks) if remarks == "" { Fail(c, http.StatusUnprocessableEntity, "remarks is required") return } match := filterNonEmptyStrings(payload.Match) if len(match) == 0 { Fail(c, http.StatusUnprocessableEntity, "match is required") return } action := strings.TrimSpace(payload.Action) if !isAllowedRouteAction(action) { Fail(c, http.StatusUnprocessableEntity, "invalid route action") return } matchJSON, err := marshalJSON(match, false) if err != nil { Fail(c, http.StatusBadRequest, "invalid route match") return } now := time.Now().Unix() values := map[string]any{ "remarks": remarks, "match": matchJSON, "action": action, "action_value": nullableString(payload.ActionValue), "updated_at": now, } if payload.ID != nil && *payload.ID > 0 { if err := database.DB.Model(&model.ServerRoute{}).Where("id = ?", *payload.ID).Updates(values).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to save server route") return } Success(c, true) return } values["created_at"] = now if err := database.DB.Model(&model.ServerRoute{}).Create(values).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to create server route") return } Success(c, true) } func AdminServerRouteDrop(c *gin.Context) { var payload struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "route id is required") return } result := database.DB.Where("id = ?", payload.ID).Delete(&model.ServerRoute{}) if result.Error != nil { Fail(c, http.StatusInternalServerError, "failed to delete server route") return } if result.RowsAffected == 0 { Fail(c, http.StatusBadRequest, "server route does not exist") return } Success(c, true) } func AdminServerManageGetNodes(c *gin.Context) { var servers []model.Server if err := database.DB.Order("sort ASC, id ASC").Find(&servers).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch nodes") return } var groups []model.ServerGroup if err := database.DB.Find(&groups).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to fetch node groups") return } groupMap := make(map[int]model.ServerGroup, len(groups)) for _, group := range groups { groupMap[group.ID] = group } serverMap := make(map[int]model.Server, len(servers)) for _, server := range servers { serverMap[server.ID] = server } result := make([]gin.H, 0, len(servers)) for _, server := range servers { result = append(result, serializeAdminServer(server, groupMap, serverMap)) } Success(c, result) } func AdminServerManageSort(c *gin.Context) { var payload []struct { ID int `json:"id"` Order int `json:"order"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } if err := database.DB.Transaction(func(tx *gorm.DB) error { for _, item := range payload { if item.ID <= 0 { continue } if err := tx.Model(&model.Server{}).Where("id = ?", item.ID).Update("sort", item.Order).Error; err != nil { return err } } return nil }); err != nil { Fail(c, http.StatusInternalServerError, "failed to sort nodes") return } Success(c, true) } func AdminServerManageSave(c *gin.Context) { payload := map[string]any{} if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } values, id, err := normalizeServerPayload(payload) if err != nil { Fail(c, http.StatusBadRequest, err.Error()) return } if id > 0 { result := database.DB.Model(&model.Server{}).Where("id = ?", id).Updates(values) if result.Error != nil { Fail(c, http.StatusInternalServerError, "failed to save node") return } if result.RowsAffected == 0 { Fail(c, http.StatusBadRequest, "node does not exist") return } Success(c, true) return } if err := database.DB.Model(&model.Server{}).Create(values).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to create node") return } Success(c, true) } func AdminServerManageUpdate(c *gin.Context) { var payload struct { ID int `json:"id"` Show *int `json:"show"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "invalid request body") return } updates := map[string]any{} if payload.Show != nil { updates["show"] = *payload.Show != 0 } if len(updates) == 0 { Fail(c, http.StatusBadRequest, "no updates were provided") return } result := database.DB.Model(&model.Server{}).Where("id = ?", payload.ID).Updates(updates) if result.Error != nil { Fail(c, http.StatusInternalServerError, "failed to update node") return } if result.RowsAffected == 0 { Fail(c, http.StatusBadRequest, "node does not exist") return } Success(c, true) } func AdminServerManageDrop(c *gin.Context) { var payload struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "node id is required") return } result := database.DB.Where("id = ?", payload.ID).Delete(&model.Server{}) if result.Error != nil { Fail(c, http.StatusInternalServerError, "failed to delete node") return } if result.RowsAffected == 0 { Fail(c, http.StatusBadRequest, "node does not exist") return } Success(c, true) } func AdminServerManageCopy(c *gin.Context) { var payload struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "node id is required") return } var server model.Server if err := database.DB.Where("id = ?", payload.ID).First(&server).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { Fail(c, http.StatusBadRequest, "node does not exist") return } Fail(c, http.StatusInternalServerError, "failed to load node") return } now := time.Now() server.ID = 0 server.Code = nil server.Show = false server.U = 0 server.D = 0 server.CreatedAt = &now server.UpdatedAt = &now if err := database.DB.Create(&server).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to copy node") return } Success(c, true) } func AdminServerManageBatchDelete(c *gin.Context) { var payload struct { IDs []int `json:"ids"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } ids := sanitizePositiveIDs(payload.IDs) if len(ids) == 0 { Fail(c, http.StatusBadRequest, "ids is required") return } if err := database.DB.Where("id IN ?", ids).Delete(&model.Server{}).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to batch delete nodes") return } Success(c, true) } func AdminServerManageResetTraffic(c *gin.Context) { var payload struct { ID int `json:"id"` } if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { Fail(c, http.StatusBadRequest, "node id is required") return } result := database.DB.Model(&model.Server{}).Where("id = ?", payload.ID).Updates(map[string]any{ "u": 0, "d": 0, }) if result.Error != nil { Fail(c, http.StatusInternalServerError, "failed to reset node traffic") return } if result.RowsAffected == 0 { Fail(c, http.StatusBadRequest, "node does not exist") return } Success(c, true) } func AdminServerManageBatchResetTraffic(c *gin.Context) { var payload struct { IDs []int `json:"ids"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } ids := sanitizePositiveIDs(payload.IDs) if len(ids) == 0 { Fail(c, http.StatusBadRequest, "ids is required") return } if err := database.DB.Model(&model.Server{}).Where("id IN ?", ids).Updates(map[string]any{ "u": 0, "d": 0, }).Error; err != nil { Fail(c, http.StatusInternalServerError, "failed to batch reset node traffic") return } Success(c, true) } func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup, servers map[int]model.Server) gin.H { groupIDs := decodeIntSlice(server.GroupIDs) groupList := make([]gin.H, 0, len(groupIDs)) for _, groupID := range groupIDs { group, ok := groups[groupID] if !ok { continue } groupList = append(groupList, gin.H{ "id": group.ID, "name": group.Name, }) } var parent any if server.ParentID != nil { if parentServer, ok := servers[*server.ParentID]; ok { parent = gin.H{ "id": parentServer.ID, "code": stringValue(parentServer.Code), "type": parentServer.Type, "name": parentServer.Name, "host": parentServer.Host, "port": parentServer.Port, "server_port": parentServer.ServerPort, } } } lastCheckAt, _ := database.CacheGetJSON[int64](nodeLastCheckKey(&server)) lastPushAt, _ := database.CacheGetJSON[int64](nodeLastPushKey(&server)) online, _ := database.CacheGetJSON[int](nodeOnlineKey(&server)) loadStatus, _ := database.CacheGetJSON[map[string]any](nodeLoadStatusKey(&server)) metrics, _ := database.CacheGetJSON[map[string]any](nodeMetricsKey(&server)) isOnline := 0 now := time.Now().Unix() if lastCheckAt > 0 && now-lastCheckAt <= 300 { isOnline = 1 } if lastPushAt > 0 && now-lastPushAt <= 300 { isOnline = 2 } availableStatus := 0 if isOnline == 1 { availableStatus = 1 } else if isOnline == 2 { availableStatus = 2 } hasChildren := false for _, s := range servers { if s.ParentID != nil && *s.ParentID == server.ID { hasChildren = true break } } return gin.H{ "id": server.ID, "type": server.Type, "code": stringValue(server.Code), "parent_id": intValue(server.ParentID), "has_children": hasChildren, "group_ids": groupIDs, "route_ids": decodeIntSlice(server.RouteIDs), "name": server.Name, "rate": server.Rate, "transfer_enable": int64Value(server.TransferEnable), "u": server.U, "d": server.D, "tags": decodeStringSlice(server.Tags), "host": server.Host, "port": server.Port, "server_port": server.ServerPort, "protocol_settings": decodeJSON(server.ProtocolSettings), "custom_outbounds": decodeJSON(server.CustomOutbounds), "custom_routes": decodeJSON(server.CustomRoutes), "cert_config": decodeJSON(server.CertConfig), "show": server.Show, "sort": intValue(server.Sort), "rate_time_enable": server.RateTimeEnable, "rate_time_ranges": decodeJSON(server.RateTimeRanges), "created_at": unixTimeValue(server.CreatedAt), "updated_at": unixTimeValue(server.UpdatedAt), "groups": groupList, "parent": parent, "last_check_at": lastCheckAt, "last_push_at": lastPushAt, "online": online, "is_online": isOnline, "available_status": availableStatus, "load_status": loadStatus, "metrics": metrics, } } func normalizeServerPayload(payload map[string]any) (map[string]any, int, error) { id := intFromAny(payload["id"]) serverType := service.NormalizeServerType(stringFromAny(payload["type"])) if serverType == "" { return nil, 0, errors.New("node type is required") } if !service.IsValidServerType(serverType) { return nil, 0, errors.New("invalid node type") } name := strings.TrimSpace(stringFromAny(payload["name"])) if name == "" { return nil, 0, errors.New("node name is required") } host := strings.TrimSpace(stringFromAny(payload["host"])) if host == "" { return nil, 0, errors.New("node host is required") } port := strings.TrimSpace(stringFromAny(payload["port"])) if port == "" { return nil, 0, errors.New("node port is required") } serverPort := intFromAny(payload["server_port"]) if serverPort <= 0 { return nil, 0, errors.New("server_port is required") } rate, ok := float32FromAny(payload["rate"]) if !ok { return nil, 0, errors.New("rate is required") } groupIDs, err := marshalJSON(payload["group_ids"], true) if err != nil { return nil, 0, errors.New("invalid group_ids") } routeIDs, err := marshalJSON(payload["route_ids"], true) if err != nil { return nil, 0, errors.New("invalid route_ids") } tags, err := marshalJSON(payload["tags"], true) if err != nil { return nil, 0, errors.New("invalid tags") } protocolSettings, err := marshalJSON(payload["protocol_settings"], false) if err != nil { return nil, 0, errors.New("invalid protocol_settings") } rateTimeRanges, err := marshalJSON(payload["rate_time_ranges"], false) if err != nil { return nil, 0, errors.New("invalid rate_time_ranges") } customOutbounds, err := marshalJSON(payload["custom_outbounds"], false) if err != nil { return nil, 0, errors.New("invalid custom_outbounds") } customRoutes, err := marshalJSON(payload["custom_routes"], false) if err != nil { return nil, 0, errors.New("invalid custom_routes") } certConfig, err := marshalJSON(payload["cert_config"], false) if err != nil { return nil, 0, errors.New("invalid cert_config") } values := map[string]any{ "type": serverType, "code": nullableString(payload["code"]), "parent_id": nullableInt(payload["parent_id"]), "group_ids": groupIDs, "route_ids": routeIDs, "name": name, "rate": rate, "transfer_enable": nullableInt64(payload["transfer_enable"]), "tags": tags, "host": host, "port": port, "server_port": serverPort, "protocol_settings": protocolSettings, "custom_outbounds": customOutbounds, "custom_routes": customRoutes, "cert_config": certConfig, "show": boolFromAny(payload["show"]), "sort": nullableInt(payload["sort"]), "rate_time_enable": boolFromAny(payload["rate_time_enable"]), "rate_time_ranges": rateTimeRanges, } return values, id, nil } func decodeJSON(raw *string) any { if raw == nil || strings.TrimSpace(*raw) == "" { return nil } var value any if err := json.Unmarshal([]byte(*raw), &value); err != nil { return *raw } return value } func decodeIntSlice(raw *string) []int { if raw == nil || strings.TrimSpace(*raw) == "" { return []int{} } var value []any if err := json.Unmarshal([]byte(*raw), &value); err == nil { result := make([]int, 0, len(value)) for _, item := range value { if parsed := intFromAny(item); parsed > 0 { result = append(result, parsed) } } return result } parts := strings.Split(*raw, ",") result := make([]int, 0, len(parts)) for _, part := range parts { if parsed, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && parsed > 0 { result = append(result, parsed) } } return result } func decodeStringSlice(raw *string) []string { if raw == nil || strings.TrimSpace(*raw) == "" { return []string{} } var value []any if err := json.Unmarshal([]byte(*raw), &value); err == nil { return filterNonEmptyStrings(value) } return filterNonEmptyStrings(strings.Split(*raw, ",")) } func float32FromAny(value any) (float32, bool) { switch typed := value.(type) { case float32: return typed, true case float64: return float32(typed), true case int: return float32(typed), true case int64: return float32(typed), true case json.Number: parsed, err := typed.Float64() return float32(parsed), err == nil case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 32) return float32(parsed), err == nil default: return 0, false } } func nullableInt(value any) any { parsed := intFromAny(value) if parsed <= 0 { return nil } return parsed } func nullableInt64(value any) any { parsed := intFromAny(value) if parsed <= 0 { return nil } return int64(parsed) } func nullableString(value any) any { text := strings.TrimSpace(stringFromAny(value)) if text == "" { return nil } return text } func filterNonEmptyStrings(values any) []string { result := make([]string, 0) switch typed := values.(type) { case []string: for _, item := range typed { item = strings.TrimSpace(item) if item != "" { result = append(result, item) } } case []any: for _, item := range typed { text := strings.TrimSpace(stringFromAny(item)) if text != "" { result = append(result, text) } } } return result } func intSliceContains(values []int, target int) bool { for _, value := range values { if value == target { return true } } return false } func isAllowedRouteAction(action string) bool { switch action { case "block", "direct", "dns", "proxy": return true default: return false } }