611 lines
17 KiB
Go
611 lines
17 KiB
Go
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 "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))
|
|
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.Preload("Plan").Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
|
|
for _, shadow := range shadowUsers {
|
|
if shadow.ParentID != nil {
|
|
shadowByParentID[*shadow.ParentID] = shadow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
shadowPlanID := parsePositiveInt(
|
|
service.GetPluginConfigString(service.PluginUserAddIPv6, "ipv6_plan_id", "0"),
|
|
0,
|
|
)
|
|
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 !hasSubscription && hasShadowUser {
|
|
subscription = model.UserIPv6Subscription{
|
|
UserID: user.ID,
|
|
ShadowUserID: &shadowUser.ID,
|
|
IPv6Email: shadowUser.Email,
|
|
Allowed: !user.Banned && shadowUser.Banned == 0,
|
|
Status: "active",
|
|
UpdatedAt: shadowUser.UpdatedAt,
|
|
}
|
|
hasSubscription = true
|
|
}
|
|
allowed := service.PluginUserAllowed(&user, user.Plan)
|
|
planID := shadowPlanID
|
|
planNameValue := planNames[shadowPlanID]
|
|
if hasShadowUser {
|
|
planID = intFromPointer(shadowUser.PlanID)
|
|
planNameValue = planNames[planID]
|
|
if planNameValue == "" && shadowUser.Plan != nil {
|
|
planNameValue = shadowUser.Plan.Name
|
|
}
|
|
}
|
|
|
|
shadowUserID := 0
|
|
shadowUpdatedAt := int64(0)
|
|
status := "not_allowed"
|
|
if hasSubscription {
|
|
status = firstString(subscription.Status, "eligible")
|
|
if subscription.ShadowUserID != nil {
|
|
shadowUserID = *subscription.ShadowUserID
|
|
}
|
|
shadowUpdatedAt = subscription.UpdatedAt
|
|
if hasShadowUser && shadowUser.Banned != 0 {
|
|
status = "banned"
|
|
}
|
|
}
|
|
|
|
effectiveAllowed := allowed || (hasSubscription && subscription.Allowed)
|
|
status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed)
|
|
|
|
list = append(list, gin.H{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"plan_id": planID,
|
|
"plan_name": firstString(planNameValue, "-"),
|
|
"allowed": effectiveAllowed,
|
|
"is_active": hasSubscription && 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.SyncIPv6ShadowAccount(&user, payload.PlanID) {
|
|
Fail(c, 403, "failed to enable/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
|
|
}
|
|
|
|
// Find shadow user
|
|
var shadowUser model.User
|
|
if err := database.DB.Where("parent_id = ?", userID).First(&shadowUser).Error; err != nil {
|
|
Fail(c, 404, "IPv6 shadow user not found")
|
|
return
|
|
}
|
|
|
|
// Just ban it as per user requirement
|
|
if err := database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("banned", 1).Error; err != nil {
|
|
Fail(c, 500, "failed to ban shadow user")
|
|
return
|
|
}
|
|
|
|
// Update record
|
|
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("user_id = ?", userID).Update("status", "banned")
|
|
|
|
SuccessMessage(c, "IPv6 subscription disabled (banned)", 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"
|
|
if hasSubscription {
|
|
status = firstString(subscription.Status, "active")
|
|
}
|
|
effectiveAllowed := allowed || hasSubscription && subscription.Allowed
|
|
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,
|
|
})
|
|
}
|
|
|
|
func PluginUserAddIPv6Enable(c *gin.Context) {
|
|
|
|
user, ok := currentUser(c)
|
|
if !ok {
|
|
Fail(c, 401, "unauthorized")
|
|
return
|
|
}
|
|
|
|
Fail(c, 403, "IPv6 self-service is disabled. Please contact administrator via ticket to enable it.")
|
|
return
|
|
/*
|
|
if !service.SyncIPv6ShadowAccount(user, 0) {
|
|
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,
|
|
})
|
|
}
|