Files
SingBox-Gopanel/internal/handler/plugin_api.go
CN-JS-HuiBai 98379b21f4
Some checks failed
build / build (api, amd64, linux) (push) Failing after -50s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -51s
修复节点无法编辑的错误
2026-04-18 10:31:31 +08:00

597 lines
16 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 "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))
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,
Status: "active",
UpdatedAt: shadowUser.UpdatedAt,
}
hasSubscription = true
}
allowed := service.PluginUserAllowed(&user, user.Plan)
status := "not_allowed"
planID := shadowPlanID
planNameValue := planNames[shadowPlanID]
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 := 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 && 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 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,
"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,
})
}