package handler import ( "encoding/json" "net/http" "strings" "time" "xboard-go/internal/database" "xboard-go/internal/model" "xboard-go/internal/service" "xboard-go/pkg/utils" "github.com/gin-gonic/gin" ) func ipv6StatusPresentation(status string, allowed bool) (string, string, string) { status = strings.TrimSpace(strings.ToLower(status)) switch status { case "active": return "active", "IPv6 enabled", "" case "disabled": return "disabled", "IPv6 disabled", "" case "eligible": return "eligible", "Ready to enable", "" case "", "not_allowed", "not_eligible", "disallowed": if allowed { return "eligible", "Ready to enable", "" } return "not_allowed", "Not eligible", "Current plan or group is not allowed to enable IPv6" default: label := strings.ReplaceAll(strings.Title(strings.ReplaceAll(status, "_", " ")), "Ipv6", "IPv6") if strings.EqualFold(label, "Not Allowed") { label = "Not eligible" } reason := "" if !allowed && (status == "not_allowed" || status == "not_eligible") { reason = "Current plan or group is not allowed to enable IPv6" } return status, label, reason } } func PluginUserOnlineDevicesUsers(c *gin.Context) { page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20) keyword := strings.TrimSpace(c.Query("keyword")) query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC") if keyword != "" { query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword) } var total int64 query.Count(&total) var users []model.User if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil { Fail(c, 500, "failed to fetch users") return } userIDs := make([]int, 0, len(users)) for _, user := range users { userIDs = append(userIDs, user.ID) } devices := service.GetUsersDevices(userIDs) deviceMetaByUserID := make(map[int]struct { LastSeenAt int64 Count int }, len(userIDs)) if len(userIDs) > 0 { var rows []struct { UserID int LastSeenAt int64 IPCount int } _ = database.DB.Table("v2_user_online_devices"). Select("user_id, MAX(last_seen_at) AS last_seen_at, COUNT(DISTINCT ip) AS ip_count"). Where("user_id IN ? AND expires_at > ?", userIDs, time.Now().Unix()). Group("user_id"). Scan(&rows).Error for _, row := range rows { deviceMetaByUserID[row.UserID] = struct { LastSeenAt int64 Count int }{LastSeenAt: row.LastSeenAt, Count: row.IPCount} } } list := make([]gin.H, 0, len(users)) usersWithOnlineIP := 0 totalOnlineIPs := 0 for _, user := range users { ips := devices[user.ID] meta := deviceMetaByUserID[user.ID] onlineCount := len(ips) if meta.Count > onlineCount { onlineCount = meta.Count } if onlineCount > 0 { usersWithOnlineIP++ totalOnlineIPs += onlineCount } lastOnlineText := formatTimeValue(user.LastOnlineAt) if meta.LastSeenAt > 0 { lastOnlineText = formatUnixValue(meta.LastSeenAt) } status := "offline" statusLabel := "Offline" if onlineCount > 0 { status = "online" statusLabel = "Online" } list = append(list, gin.H{ "id": user.ID, "email": user.Email, "subscription_name": planName(user.Plan), "online_count": onlineCount, "online_devices": ips, "last_online_text": lastOnlineText, "created_text": formatUnixValue(user.CreatedAt), "status": status, "status_label": statusLabel, }) } Success(c, gin.H{ "list": list, "filters": gin.H{ "keyword": keyword, "per_page": perPage, }, "summary": gin.H{ "page_users": len(users), "users_with_online_ip": usersWithOnlineIP, "total_online_ips": totalOnlineIPs, "current_page": page, }, "pagination": gin.H{ "current": page, "last_page": calculateLastPage(total, perPage), "per_page": perPage, "total": total, }, }) } func AdminIPv6SubscriptionUsers(c *gin.Context) { page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20) keyword := strings.TrimSpace(c.Query("keyword")) suffix := service.GetPluginConfigString(service.PluginUserAddIPv6, "email_suffix", "-ipv6") shadowPattern := "%" + suffix + "@%" query := database.DB.Model(&model.User{}). Preload("Plan"). Where("email NOT LIKE ?", shadowPattern). Order("id DESC") if keyword != "" { query = query.Where("email LIKE ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword) } var total int64 if err := query.Count(&total).Error; err != nil { Fail(c, 500, "failed to count users") return } var users []model.User if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil { Fail(c, 500, "failed to fetch users") return } userIDs := make([]int, 0, len(users)) for _, user := range users { userIDs = append(userIDs, user.ID) } subscriptionByUserID := make(map[int]model.UserIPv6Subscription, len(userIDs)) shadowUserIDs := make([]int, 0, len(userIDs)) if len(userIDs) > 0 { var rows []model.UserIPv6Subscription if err := database.DB.Where("user_id IN ?", userIDs).Find(&rows).Error; err == nil { for _, row := range rows { subscriptionByUserID[row.UserID] = row if row.ShadowUserID != nil && *row.ShadowUserID > 0 { shadowUserIDs = append(shadowUserIDs, *row.ShadowUserID) } } } } shadowByParentID := make(map[int]model.User, len(userIDs)) shadowByID := make(map[int]model.User, len(shadowUserIDs)) shadowByEmail := make(map[string]model.User, len(userIDs)) if len(userIDs) > 0 { var shadowUsers []model.User query := database.DB.Preload("Plan") if len(shadowUserIDs) > 0 { query = query.Where("parent_id IN ? OR id IN ?", userIDs, shadowUserIDs) } else { query = query.Where("parent_id IN ?", userIDs) } if err := query.Find(&shadowUsers).Error; err == nil { for _, shadow := range shadowUsers { shadowByID[shadow.ID] = shadow shadowByEmail[strings.ToLower(shadow.Email)] = shadow if shadow.ParentID != nil { shadowByParentID[*shadow.ParentID] = shadow } } } } planNames := make(map[int]string) var plans []model.Plan if err := database.DB.Select("id", "name").Find(&plans).Error; err == nil { for _, plan := range plans { planNames[plan.ID] = plan.Name } } list := make([]gin.H, 0, len(users)) for _, user := range users { subscription, hasSubscription := subscriptionByUserID[user.ID] shadowUser, hasShadowUser := shadowByParentID[user.ID] if !hasShadowUser && hasSubscription && subscription.ShadowUserID != nil { shadowUser, hasShadowUser = shadowByID[*subscription.ShadowUserID] } if !hasShadowUser { shadowUser, hasShadowUser = shadowByEmail[strings.ToLower(service.IPv6ShadowEmail(user.Email))] } if !hasSubscription && hasShadowUser { subscription = model.UserIPv6Subscription{ UserID: user.ID, ShadowUserID: &shadowUser.ID, IPv6Email: shadowUser.Email, Allowed: !user.Banned, Status: "active", UpdatedAt: shadowUser.UpdatedAt, } hasSubscription = true } status := "eligible" planID := 0 planNameValue := "" if hasShadowUser { planID = intFromPointer(shadowUser.PlanID) planNameValue = firstString(planName(shadowUser.Plan), planNames[planID]) } shadowUserID := 0 shadowUpdatedAt := int64(0) if hasSubscription { status = firstString(subscription.Status, status) if subscription.ShadowUserID != nil { shadowUserID = *subscription.ShadowUserID } shadowUpdatedAt = subscription.UpdatedAt } effectiveAllowed := true status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed) list = append(list, gin.H{ "id": user.ID, "email": user.Email, "plan_id": planID, "plan_name": planNameValue, "allowed": effectiveAllowed, "is_active": hasSubscription && subscription.Status == "active", "status": status, "status_label": statusLabel, "ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)), "shadow_user_id": shadowUserID, "updated_at": shadowUpdatedAt, "group_id": user.GroupID, }) } Success(c, gin.H{ "list": list, "pagination": gin.H{ "current": page, "last_page": calculateLastPage(total, perPage), "per_page": perPage, "total": total, }, }) } func AdminIPv6SubscriptionConfigFetch(c *gin.Context) { cfg := service.GetPluginConfig(service.PluginUserAddIPv6) Success(c, gin.H{ "ipv6_plan_id": parsePositiveInt(service.GetPluginConfigString(service.PluginUserAddIPv6, "ipv6_plan_id", "0"), 0), "allowed_plans": service.GetPluginConfigIntList(service.PluginUserAddIPv6, "allowed_plans"), "allowed_groups": service.GetPluginConfigIntList(service.PluginUserAddIPv6, "allowed_groups"), "email_suffix": firstString(stringFromAny(cfg["email_suffix"]), "-ipv6"), "reference_flag": firstString(stringFromAny(cfg["reference_flag"]), "ipv6"), }) } func AdminIPv6SubscriptionConfigSave(c *gin.Context) { var payload struct { IPv6PlanID int `json:"ipv6_plan_id"` } if err := c.ShouldBindJSON(&payload); err != nil { Fail(c, http.StatusBadRequest, "invalid request body") return } var plugin model.Plugin err := database.DB.Where("code = ?", service.PluginUserAddIPv6).First(&plugin).Error if err != nil { plugin = model.Plugin{ Code: service.PluginUserAddIPv6, Name: "IPv6 Subscription", IsEnabled: true, } } cfg := service.GetPluginConfig(service.PluginUserAddIPv6) if payload.IPv6PlanID > 0 { cfg["ipv6_plan_id"] = payload.IPv6PlanID } else { delete(cfg, "ipv6_plan_id") } raw, marshalErr := json.Marshal(cfg) if marshalErr != nil { Fail(c, http.StatusInternalServerError, "failed to encode plugin config") return } configText := string(raw) plugin.Config = &configText if plugin.ID > 0 { if updateErr := database.DB.Model(&model.Plugin{}).Where("id = ?", plugin.ID).Updates(map[string]any{ "config": plugin.Config, "is_enabled": plugin.IsEnabled, }).Error; updateErr != nil { Fail(c, http.StatusInternalServerError, "failed to save plugin config") return } } else { if createErr := database.DB.Create(&plugin).Error; createErr != nil { Fail(c, http.StatusInternalServerError, "failed to create plugin config") return } } Success(c, gin.H{ "ipv6_plan_id": payload.IPv6PlanID, }) } func AdminIPv6SubscriptionEnable(c *gin.Context) { userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { Fail(c, 400, "invalid user id") return } var payload struct { PlanID int `json:"plan_id"` } _ = c.ShouldBindJSON(&payload) var user model.User if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil { Fail(c, 404, "user not found") return } if user.ParentID != nil { Fail(c, 400, "shadow users cannot be enabled again") return } if !service.SyncIPv6ShadowAccountWithPlan(&user, payload.PlanID) { Fail(c, 500, "failed to create/sync IPv6 subscription") return } SuccessMessage(c, "IPv6 subscription enabled/synced", true) } func AdminIPv6SubscriptionDisable(c *gin.Context) { userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { Fail(c, 400, "invalid user id") return } var user model.User if err := database.DB.First(&user, userID).Error; err != nil { Fail(c, 404, "user not found") return } if !service.DisableIPv6ShadowAccount(&user) { Fail(c, 500, "failed to disable IPv6 subscription") return } SuccessMessage(c, "IPv6 subscription disabled", true) } func AdminIPv6SubscriptionSyncPassword(c *gin.Context) { userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { Fail(c, 400, "invalid user id") return } var user model.User if err := database.DB.First(&user, userID).Error; err != nil { Fail(c, 404, "user not found") return } if !service.SyncIPv6PasswordState(&user) { Fail(c, 404, "IPv6 user not found") return } SuccessMessage(c, "Password synced to IPv6 account", true) } func PluginUserOnlineDevicesGetIP(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") return } devices := service.GetUsersDevices([]int{user.ID}) ips := devices[user.ID] authToken, _ := c.Get("auth_token") currentToken, _ := authToken.(string) sessions := service.GetUserSessions(user.ID, currentToken) currentID := currentSessionID(c) sessionItems := make([]gin.H, 0, len(sessions)) for _, session := range sessions { sessionItems = append(sessionItems, gin.H{ "id": session.ID, "name": session.Name, "user_agent": session.UserAgent, "ip": firstString(session.IP, "-"), "created_at": session.CreatedAt, "last_used_at": session.LastUsedAt, "expires_at": session.ExpiresAt, "is_current": session.ID == currentID, }) } Success(c, gin.H{ "ips": ips, "session_overview": gin.H{ "online_ip_count": len(ips), "online_ips": ips, "online_device_count": len(ips), "device_limit": user.DeviceLimit, "last_online_at": user.LastOnlineAt, "active_session_count": len(sessionItems), "sessions": sessionItems, }, }) } func PluginUserAddIPv6Check(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") return } var subscription model.UserIPv6Subscription hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil status := "eligible" if hasSubscription { status = firstString(subscription.Status, "active") } effectiveAllowed := true status, statusLabel, reason := ipv6StatusPresentation(status, effectiveAllowed) Success(c, gin.H{ "allowed": effectiveAllowed, "is_active": hasSubscription && subscription.Status == "active", "status": status, "status_label": statusLabel, "reason": reason, "ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)), }) } func PluginUserAddIPv6Enable(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") return } if !service.SyncIPv6ShadowAccount(user) { Fail(c, 403, "your plan does not support IPv6 subscription") return } payload := gin.H{ "ipv6_email": service.IPv6ShadowEmail(user.Email), } var shadowUser model.User if err := database.DB.Where("parent_id = ? AND email = ?", user.ID, service.IPv6ShadowEmail(user.Email)).First(&shadowUser).Error; err == nil { payload["shadow_user_id"] = shadowUser.ID token, err := utils.GenerateToken(shadowUser.ID, shadowUser.IsAdmin) if err == nil { payload["token"] = token payload["auth_data"] = token service.TrackSession(shadowUser.ID, token, c.ClientIP(), c.GetHeader("User-Agent")) _ = database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("last_login_at", time.Now().Unix()).Error } } SuccessMessage(c, "IPv6 subscription enabled/synced", payload) } func PluginUserAddIPv6SyncPassword(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") return } if !service.SyncIPv6PasswordState(user) { Fail(c, 404, "IPv6 user not found") return } SuccessMessage(c, "Password synced to IPv6 account", true) } func AdminSystemStatus(c *gin.Context) { Success(c, gin.H{ "server_time": time.Now().Unix(), "app_name": service.MustGetString("app_name", "XBoard"), "app_url": service.GetAppURL(), "version": "1.0.0", "go_version": "1.21+", }) } // AdminSystemQueueStats returns empty queue stats (Go app has no Horizon/queue workers). // The React frontend polls this endpoint; returning empty data prevents 404 polling errors. func AdminSystemQueueStats(c *gin.Context) { fullPath := c.FullPath() if strings.HasSuffix(fullPath, "/getHorizonFailedJobs") { c.JSON(200, gin.H{ "data": []any{}, "total": 0, }) return } if strings.HasSuffix(fullPath, "/getQueueWorkload") || strings.HasSuffix(fullPath, "/getQueueMasters") { Success(c, []any{}) return } Success(c, gin.H{ "status": true, "wait": gin.H{"default": 0}, "recentJobs": 0, "jobsPerMinute": 0, "failedJobs": 0, "processes": 0, "pausedMasters": 0, "periods": gin.H{ "recentJobs": 60, "failedJobs": 168, }, "queueWithMaxThroughput": gin.H{ "name": "default", "throughput": 0, }, "queueWithMaxRuntime": gin.H{ "name": "default", "runtime": 0, }, "workload": []any{}, "masters": []any{}, "failed_jobs": 0, "total_jobs": 0, "pending_jobs": 0, "processed": 0, "failed": 0, }) }