package handler import ( "strings" "time" "xboard-go/internal/database" "xboard-go/internal/model" "xboard-go/internal/service" "xboard-go/pkg/utils" "github.com/gin-gonic/gin" ) 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)) 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 } } } shadowByParentID := make(map[int]model.User, len(userIDs)) if len(userIDs) > 0 { var shadowUsers []model.User if err := database.DB.Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil { for _, shadow := range shadowUsers { if shadow.ParentID != nil { shadowByParentID[*shadow.ParentID] = shadow } } } } list := make([]gin.H, 0, len(users)) for _, user := range users { subscription, hasSubscription := subscriptionByUserID[user.ID] shadowUser, hasShadowUser := shadowByParentID[user.ID] 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 } allowed := service.PluginUserAllowed(&user, user.Plan) status := "not_allowed" statusLabel := "Not eligible" if allowed { status = "eligible" statusLabel = "Ready to enable" } shadowUserID := 0 shadowUpdatedAt := int64(0) if hasSubscription { status = firstString(subscription.Status, status) statusLabel = "IPv6 enabled" if subscription.Status != "active" && subscription.Status != "" { statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6") } if subscription.ShadowUserID != nil { shadowUserID = *subscription.ShadowUserID } shadowUpdatedAt = subscription.UpdatedAt } else if allowed { statusLabel = "Ready to enable" } list = append(list, gin.H{ "id": user.ID, "email": user.Email, "plan_name": planName(user.Plan), "allowed": allowed || hasSubscription && subscription.Allowed, "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 AdminIPv6SubscriptionEnable(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.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.SyncIPv6ShadowAccount(&user) { Fail(c, 403, "user plan does not support ipv6 subscription") return } SuccessMessage(c, "IPv6 subscription enabled/synced", 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 plan *model.Plan if user.PlanID != nil { var loadedPlan model.Plan if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil { plan = &loadedPlan } } var subscription model.UserIPv6Subscription hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil allowed := service.PluginUserAllowed(user, plan) status := "not_allowed" statusLabel := "Not eligible" reason := "Current plan or group is not allowed to enable IPv6" if allowed { status = "eligible" statusLabel = "Ready to enable" reason = "" } if hasSubscription { status = firstString(subscription.Status, "active") statusLabel = "IPv6 enabled" reason = "" if subscription.Status != "active" && subscription.Status != "" { statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6") } } Success(c, gin.H{ "allowed": allowed || hasSubscription && subscription.Allowed, "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, }) }