478 lines
13 KiB
Go
478 lines
13 KiB
Go
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,
|
|
})
|
|
}
|