基本功能已初步完善
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled

This commit is contained in:
CN-JS-HuiBai
2026-04-17 20:41:47 +08:00
parent 25fd919477
commit b3435e5ef8
34 changed files with 3495 additions and 429 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"xboard-go/internal/config"
"xboard-go/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
@@ -23,12 +24,21 @@ func InitDB() {
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.Default.LogMode(logger.Info),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
if err := DB.AutoMigrate(
&model.RealNameAuth{},
&model.UserOnlineDevice{},
&model.UserIPv6Subscription{},
); err != nil {
log.Fatalf("Failed to migrate database tables: %v", err)
}
log.Println("Database connection established")
}

View File

@@ -5,7 +5,9 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
@@ -47,21 +49,62 @@ func AdminConfigSave(c *gin.Context) {
Success(c, true)
}
// AdminGetEmailTemplate list available email templates.
func AdminGetEmailTemplate(c *gin.Context) {
path := filepath.Join("resource", "views", "mail")
files, err := listFiles(path, "*")
if err != nil {
Success(c, []string{})
func AdminTestSendMail(c *gin.Context) {
config := service.LoadEmailConfig()
recipient := currentAdminEmail(c)
if recipient == "" {
recipient = config.SenderAddress()
}
subject := fmt.Sprintf("[%s] SMTP test email", service.MustGetString("app_name", "XBoard"))
result := gin.H{
"email": recipient,
"subject": subject,
"template_name": config.TemplateName,
"config": config.DebugConfig(),
}
if recipient == "" {
result["error"] = "no test recipient is available"
Success(c, result)
return
}
Success(c, files)
textBody := strings.Join([]string{
"This is a test email from SingBox-Gopanel.",
"",
"If you received this email, the current SMTP configuration is working.",
}, "\n")
htmlBody := strings.Join([]string{
"<h2>SMTP test email</h2>",
"<p>If you received this email, the current SMTP configuration is working.</p>",
}, "")
if err := service.SendMail(config, service.EmailMessage{
To: []string{recipient},
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
}); err != nil {
result["error"] = err.Error()
}
Success(c, result)
}
func AdminSetTelegramWebhook(c *gin.Context) {
SuccessMessage(c, "telegram webhook setup is not wired in the Go backend yet", true)
}
// AdminGetEmailTemplate list available email templates.
func AdminGetEmailTemplate(c *gin.Context) {
Success(c, collectEmailTemplateNames())
}
// AdminGetThemeTemplate list available themes.
func AdminGetThemeTemplate(c *gin.Context) {
path := filepath.Join("public", "theme")
files, err := listFiles(path, "*")
files, err := listDirectoryEntries(path)
if err != nil {
Success(c, []string{})
return
@@ -89,7 +132,7 @@ func getAllConfigMappings() gin.H {
"site": gin.H{
"logo": service.MustGetString("logo", ""),
"force_https": service.MustGetInt("force_https", 0),
"stop_register": service.MustGetInt("stop_register", 0),
"stop_register": service.MustGetInt("stop_register", 0),
"app_name": service.MustGetString("app_name", "XBoard"),
"app_description": service.MustGetString("app_description", "XBoard is best!"),
"app_url": service.MustGetString("app_url", ""),
@@ -111,14 +154,14 @@ func getAllConfigMappings() gin.H {
"show_info_to_server_enable": service.MustGetBool("show_info_to_server_enable", false),
"show_protocol_to_server_enable": service.MustGetBool("show_protocol_to_server_enable", false),
"default_remind_expire": service.MustGetBool("default_remind_expire", true),
"default_remind_traffic" : service.MustGetBool("default_remind_traffic", true),
"default_remind_traffic": service.MustGetBool("default_remind_traffic", true),
"subscribe_path": service.MustGetString("subscribe_path", "s"),
},
"frontend": gin.H{
"frontend_theme": service.MustGetString("frontend_theme", "Xboard"),
"frontend_theme_sidebar": service.MustGetString("frontend_theme_sidebar", "light"),
"frontend_theme_header": service.MustGetString("frontend_theme_header", "dark"),
"frontend_theme_color": service.MustGetString("frontend_theme_color", "default"),
"frontend_theme": service.MustGetString("frontend_theme", "Xboard"),
"frontend_theme_sidebar": service.MustGetString("frontend_theme_sidebar", "light"),
"frontend_theme_header": service.MustGetString("frontend_theme_header", "dark"),
"frontend_theme_color": service.MustGetString("frontend_theme_color", "default"),
"frontend_background_url": service.MustGetString("frontend_background_url", ""),
},
"server": gin.H{
@@ -130,18 +173,42 @@ func getAllConfigMappings() gin.H {
"server_ws_url": service.MustGetString("server_ws_url", ""),
},
"safe": gin.H{
"email_verify": service.MustGetBool("email_verify", false),
"safe_mode_enable": service.MustGetBool("safe_mode_enable", false),
"email_verify": service.MustGetBool("email_verify", false),
"safe_mode_enable": service.MustGetBool("safe_mode_enable", false),
"secure_path": service.GetAdminSecurePath(),
"email_whitelist_enable": service.MustGetBool("email_whitelist_enable", false),
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
"email_gmail_limit_enable": service.MustGetBool("email_gmail_limit_enable", false),
"captcha_enable": service.MustGetBool("captcha_enable", false),
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
"email_whitelist_enable": service.MustGetBool("email_whitelist_enable", false),
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
"email_gmail_limit_enable": service.MustGetBool("email_gmail_limit_enable", false),
"captcha_enable": service.MustGetBool("captcha_enable", false),
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
"register_limit_by_ip_enable": service.MustGetBool("register_limit_by_ip_enable", false),
"register_limit_count": service.MustGetInt("register_limit_count", 3),
"register_limit_count": service.MustGetInt("register_limit_count", 3),
"password_limit_enable": service.MustGetBool("password_limit_enable", true),
},
"email": gin.H{
"email_template": service.MustGetString("email_template", "classic"),
"email_host": service.MustGetString("email_host", ""),
"email_port": service.MustGetInt("email_port", 465),
"email_username": service.MustGetString("email_username", ""),
"email_password": service.MustGetString("email_password", ""),
"email_encryption": service.MustGetString("email_encryption", ""),
"email_from_address": service.LoadEmailConfig().SenderAddress(),
"email_from_name": service.MustGetString("email_from_name", service.MustGetString("app_name", "XBoard")),
"remind_mail_enable": service.MustGetBool("remind_mail_enable", false),
},
"nebula": gin.H{
"nebula_theme_color": service.MustGetString("nebula_theme_color", "aurora"),
"nebula_hero_slogan": service.MustGetString("nebula_hero_slogan", ""),
"nebula_welcome_target": service.MustGetString("nebula_welcome_target", ""),
"nebula_register_title": service.MustGetString("nebula_register_title", ""),
"nebula_background_url": service.MustGetString("nebula_background_url", ""),
"nebula_metrics_base_url": service.MustGetString("nebula_metrics_base_url", ""),
"nebula_default_theme_mode": service.MustGetString("nebula_default_theme_mode", "system"),
"nebula_light_logo_url": service.MustGetString("nebula_light_logo_url", ""),
"nebula_dark_logo_url": service.MustGetString("nebula_dark_logo_url", ""),
"nebula_custom_html": service.MustGetString("nebula_custom_html", ""),
"nebula_static_cdn_url": service.MustGetString("nebula_static_cdn_url", ""),
},
}
}
@@ -155,7 +222,7 @@ func saveSetting(name string, value any) {
case int64:
val = strconv.FormatInt(v, 10)
case float64:
val = fmt.Sprintf("%f", v)
val = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
val = "1"
@@ -166,19 +233,109 @@ func saveSetting(name string, value any) {
// serialize complex types if needed
}
database.DB.Model(&model.Setting{}).Where("name = ?", name).Update("value", val)
result := database.DB.Model(&model.Setting{}).Where("name = ?", name).Update("value", val)
if result.Error == nil && result.RowsAffected > 0 {
return
}
setting := model.Setting{
Name: name,
Value: val,
}
if group := settingGroupName(name); group != "" {
setting.Group = &group
}
_ = database.DB.Where("name = ?", name).FirstOrCreate(&setting).Error
}
func listFiles(dir string, pattern string) ([]string, error) {
func listDirectoryEntries(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, entry := range entries {
if !entry.IsDir() {
files = append(files, entry.Name())
}
files = append(files, entry.Name())
}
return files, nil
}
func collectEmailTemplateNames() []string {
names := map[string]struct{}{
"classic": {},
}
candidates := []string{
filepath.Join("resource", "views", "mail"),
filepath.Join("reference", "Xboard", "resources", "views", "mail"),
}
for _, dir := range candidates {
entries, err := listDirectoryEntries(dir)
if err != nil {
continue
}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
names[entry] = struct{}{}
}
}
result := make([]string, 0, len(names))
for name := range names {
result = append(result, name)
}
sort.Strings(result)
return result
}
func currentAdminEmail(c *gin.Context) string {
userID, exists := c.Get("user_id")
if !exists {
return ""
}
var user model.User
if err := database.DB.Select("email").Where("id = ?", intFromAny(userID)).First(&user).Error; err != nil {
return ""
}
return strings.TrimSpace(user.Email)
}
func settingGroupName(name string) string {
switch name {
case "invite_force", "invite_commission", "invite_gen_limit", "invite_never_expire",
"commission_first_time_enable", "commission_auto_check_enable", "commission_withdraw_limit",
"commission_withdraw_method", "withdraw_close_enable", "commission_distribution_enable",
"commission_distribution_l1", "commission_distribution_l2", "commission_distribution_l3":
return "invite"
case "logo", "force_https", "stop_register", "app_name", "app_description", "app_url",
"subscribe_url", "try_out_plan_id", "try_out_hour", "tos_url", "currency",
"currency_symbol", "ticket_must_wait_reply":
return "site"
case "plan_change_enable", "reset_traffic_method", "surplus_enable", "new_order_event_id",
"renew_order_event_id", "change_order_event_id", "show_info_to_server_enable",
"show_protocol_to_server_enable", "default_remind_expire", "default_remind_traffic", "subscribe_path":
return "subscribe"
case "frontend_theme", "frontend_theme_sidebar", "frontend_theme_header", "frontend_theme_color", "frontend_background_url":
return "frontend"
case "server_token", "server_pull_interval", "server_push_interval", "device_limit_mode", "server_ws_enable", "server_ws_url":
return "server"
case "email_verify", "safe_mode_enable", "secure_path", "email_whitelist_enable",
"email_whitelist_suffix", "email_gmail_limit_enable", "captcha_enable", "captcha_type",
"register_limit_by_ip_enable", "register_limit_count", "password_limit_enable":
return "safe"
case "email_template", "email_host", "email_port", "email_username", "email_password", "email_encryption",
"email_from_address", "email_from_name", "email_from", "remind_mail_enable":
return "email"
case "nebula_theme_color", "nebula_hero_slogan", "nebula_welcome_target", "nebula_register_title",
"nebula_background_url", "nebula_metrics_base_url", "nebula_default_theme_mode",
"nebula_light_logo_url", "nebula_dark_logo_url", "nebula_custom_html", "nebula_static_cdn_url":
return "nebula"
default:
return ""
}
}

View File

@@ -3,17 +3,21 @@ package handler
import (
"net/http"
"strconv"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// --- Stat Extra ---
func AdminGetStatUser(c *gin.Context) {
userIdStr := c.Query("user_id")
params := getFetchParams(c)
userIdStr := firstString(c.Query("user_id"), params["user_id"], params["id"])
userId, _ := strconv.Atoi(userIdStr)
if userId == 0 {
Fail(c, http.StatusBadRequest, "user_id is required")
@@ -46,16 +50,16 @@ func AdminPaymentSave(c *gin.Context) {
return
}
id := intFromAny(payload["id"])
// Complex configuration usually stored as JSON
configJson, _ := marshalJSON(payload["config"], true)
values := map[string]any{
"name": payload["name"],
"payment": payload["payment"],
"config": configJson,
"notify_domain": payload["notify_domain"],
"handling_fee_fixed": payload["handling_fee_fixed"],
"name": payload["name"],
"payment": payload["payment"],
"config": configJson,
"notify_domain": payload["notify_domain"],
"handling_fee_fixed": payload["handling_fee_fixed"],
"handling_fee_percent": payload["handling_fee_percent"],
}
@@ -68,7 +72,9 @@ func AdminPaymentSave(c *gin.Context) {
}
func AdminPaymentDrop(c *gin.Context) {
var payload struct{ ID int `json:"id"` }
var payload struct {
ID int `json:"id"`
}
c.ShouldBindJSON(&payload)
database.DB.Delete(&model.Payment{}, payload.ID)
Success(c, true)
@@ -85,7 +91,9 @@ func AdminPaymentShow(c *gin.Context) {
}
func AdminPaymentSort(c *gin.Context) {
var payload struct{ IDs []int `json:"ids"` }
var payload struct {
IDs []int `json:"ids"`
}
c.ShouldBindJSON(&payload)
for i, id := range payload.IDs {
database.DB.Model(&model.Payment{}).Where("id = ?", id).Update("sort", i)
@@ -106,15 +114,15 @@ func AdminNoticeSave(c *gin.Context) {
c.ShouldBindJSON(&payload)
id := intFromAny(payload["id"])
now := time.Now().Unix()
values := map[string]any{
"title": payload["title"],
"content": payload["content"],
"img_url": payload["img_url"],
"tags": payload["tags"],
"title": payload["title"],
"content": payload["content"],
"img_url": payload["img_url"],
"tags": payload["tags"],
"updated_at": now,
}
if id > 0 {
database.DB.Model(&model.Notice{}).Where("id = ?", id).Updates(values)
} else {
@@ -125,7 +133,9 @@ func AdminNoticeSave(c *gin.Context) {
}
func AdminNoticeDrop(c *gin.Context) {
var payload struct{ ID int `json:"id"` }
var payload struct {
ID int `json:"id"`
}
c.ShouldBindJSON(&payload)
database.DB.Delete(&model.Notice{}, payload.ID)
Success(c, true)
@@ -148,18 +158,30 @@ func AdminNoticeSort(c *gin.Context) {
// --- Order Extra ---
func AdminOrderDetail(c *gin.Context) {
var payload struct{ TradeNo string `json:"trade_no"` }
var payload struct {
ID int `json:"id"`
TradeNo string `json:"trade_no"`
}
c.ShouldBindJSON(&payload)
var order model.Order
database.DB.Preload("Plan").Preload("Payment").Where("trade_no = ?", payload.TradeNo).First(&order)
query := database.DB.Preload("Plan").Preload("Payment")
if payload.TradeNo != "" {
query = query.Where("trade_no = ?", payload.TradeNo)
} else if payload.ID > 0 {
query = query.Where("id = ?", payload.ID)
}
if err := query.First(&order).Error; err != nil {
Fail(c, http.StatusNotFound, "order not found")
return
}
Success(c, normalizeOrder(order))
}
func AdminOrderAssign(c *gin.Context) {
var payload struct {
Email string `json:"email"`
PlanID int `json:"plan_id"`
Period string `json:"period"`
Email string `json:"email"`
PlanID int `json:"plan_id"`
Period string `json:"period"`
}
c.ShouldBindJSON(&payload)
// Logic to manually create/assign an order and mark as paid
@@ -177,11 +199,22 @@ func AdminOrderUpdate(c *gin.Context) {
// --- User Extra ---
func AdminUserResetSecret(c *gin.Context) {
var payload struct{ ID int `json:"id"` }
c.ShouldBindJSON(&payload)
newUuid := "new-uuid-placeholder" // Generate actual UUID
database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("uuid", newUuid)
Success(c, true)
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
newUUID := uuid.NewString()
newToken := strings.ReplaceAll(uuid.NewString(), "-", "")
if err := database.DB.Model(&model.User{}).
Where("id = ?", payload.ID).
Updates(map[string]any{"uuid": newUUID, "token": newToken}).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to reset secret")
return
}
Success(c, newToken)
}
func AdminUserSendMail(c *gin.Context) {
@@ -190,8 +223,49 @@ func AdminUserSendMail(c *gin.Context) {
Subject string `json:"subject"`
Content string `json:"content"`
}
c.ShouldBindJSON(&payload)
// Logic to send email
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
if payload.UserID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
if strings.TrimSpace(payload.Subject) == "" {
Fail(c, http.StatusBadRequest, "subject is required")
return
}
if strings.TrimSpace(payload.Content) == "" {
Fail(c, http.StatusBadRequest, "content is required")
return
}
var user model.User
if err := database.DB.Select("email").Where("id = ?", payload.UserID).First(&user).Error; err != nil {
Fail(c, http.StatusBadRequest, "user does not exist")
return
}
textBody := payload.Content
htmlBody := ""
if looksLikeHTML(payload.Content) {
htmlBody = payload.Content
}
if err := service.SendMailWithCurrentSettings(service.EmailMessage{
To: []string{user.Email},
Subject: payload.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
}); err != nil {
Fail(c, http.StatusInternalServerError, "failed to send email: "+err.Error())
return
}
Success(c, true)
}
func looksLikeHTML(value string) bool {
value = strings.TrimSpace(value)
return strings.Contains(value, "<") && strings.Contains(value, ">")
}

View File

@@ -16,7 +16,8 @@ import (
// AdminGiftCardFetch handles fetching of gift card templates and batch info.
func AdminGiftCardFetch(c *gin.Context) {
id := c.Query("id")
params := getFetchParams(c)
id := params["id"]
if id != "" {
var template model.GiftCardTemplate
if err := database.DB.Where("id = ?", id).First(&template).Error; err != nil {
@@ -100,6 +101,21 @@ func AdminGiftCardGenerate(c *gin.Context) {
Success(c, gin.H{"batch_id": batchID})
}
func AdminGiftCardDeleteTemplate(c *gin.Context) {
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "template id is required")
return
}
if err := database.DB.Delete(&model.GiftCardTemplate{}, payload.ID).Error; err != nil {
Fail(c, http.StatusInternalServerError, "failed to delete template")
return
}
Success(c, true)
}
func generateGiftCode(prefix string) string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
hash := md5.Sum([]byte(fmt.Sprintf("%d%d", time.Now().UnixNano(), r.Int63())))

View File

@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"path/filepath"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
type adminPluginPageViewData struct {
Title string
Kind string
KindLabel string
SecurePath string
}
func AdminPluginPanelPage(c *gin.Context) {
kind := c.Param("kind")
labels := map[string]string{
"realname": "实名认证",
"online-devices": "在线 IP 统计",
"ipv6-subscription": "IPv6 子账号",
}
label, ok := labels[kind]
if !ok {
c.String(http.StatusNotFound, "plugin panel not found")
return
}
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_plugin_panel.html"), adminPluginPageViewData{
Title: service.MustGetString("app_name", "XBoard") + " - " + label,
Kind: kind,
KindLabel: label,
SecurePath: service.GetAdminSecurePath(),
})
}

View File

@@ -23,6 +23,7 @@ func AdminDashboardSummary(c *gin.Context) {
var totalOrders int64
var pendingOrders int64
var pendingTickets int64
var commissionPendingTotal int64
var onlineUsers int64
var onlineNodes int64
var onlineDevices int64
@@ -30,6 +31,10 @@ func AdminDashboardSummary(c *gin.Context) {
database.DB.Model(&model.User{}).Count(&totalUsers)
database.DB.Model(&model.Order{}).Count(&totalOrders)
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
database.DB.Model(&model.Order{}).
Where("status = ? AND commission_status = ?", 3, 0).
Select("COALESCE(SUM(commission_balance), 0)").
Scan(&commissionPendingTotal)
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
database.DB.Model(&model.Server{}).Where("show = ?", true).Count(&onlineNodes) // Simplified online check
@@ -75,25 +80,28 @@ func AdminDashboardSummary(c *gin.Context) {
userGrowth := calculateGrowth(currentMonthNewUsers, lastMonthNewUsers)
Success(c, gin.H{
"server_time": now,
"todayIncome": todayIncome,
"dayIncomeGrowth": dayIncomeGrowth,
"currentMonthIncome": currentMonthIncome,
"lastMonthIncome": lastMonthIncome,
"monthIncomeGrowth": monthIncomeGrowth,
"currentMonthNewUsers": currentMonthNewUsers,
"totalUsers": totalUsers,
"activeUsers": totalUsers, // Placeholder for valid subscription count
"userGrowth": userGrowth,
"onlineUsers": onlineUsers,
"onlineDevices": onlineDevices,
"ticketPendingTotal": pendingTickets,
"onlineNodes": onlineNodes,
"todayTraffic": todayTraffic,
"monthTraffic": monthTraffic,
"totalTraffic": totalTraffic,
"secure_path": service.GetAdminSecurePath(),
"app_name": service.MustGetString("app_name", "XBoard"),
"server_time": now,
"todayIncome": todayIncome,
"dayIncomeGrowth": dayIncomeGrowth,
"currentMonthIncome": currentMonthIncome,
"lastMonthIncome": lastMonthIncome,
"monthIncomeGrowth": monthIncomeGrowth,
"totalOrders": totalOrders,
"pendingOrders": pendingOrders,
"currentMonthNewUsers": currentMonthNewUsers,
"totalUsers": totalUsers,
"activeUsers": totalUsers, // Placeholder for valid subscription count
"userGrowth": userGrowth,
"commissionPendingTotal": commissionPendingTotal,
"onlineUsers": onlineUsers,
"onlineDevices": onlineDevices,
"ticketPendingTotal": pendingTickets,
"onlineNodes": onlineNodes,
"todayTraffic": todayTraffic,
"monthTraffic": monthTraffic,
"totalTraffic": totalTraffic,
"secure_path": service.GetAdminSecurePath(),
"app_name": service.MustGetString("app_name", "XBoard"),
})
}
@@ -107,7 +115,6 @@ func calculateGrowth(current, previous int64) float64 {
return float64(current-previous) / float64(previous) * 100.0
}
func AdminPlansFetch(c *gin.Context) {
var plans []model.Plan
if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil {
@@ -248,11 +255,10 @@ func AdminPlanSort(c *gin.Context) {
Success(c, true)
}
func AdminOrdersFetch(c *gin.Context) {
params := getFetchParams(c)
page := parsePositiveInt(params["page"], 1)
perPage := parsePositiveInt(params["per_page"], 50)
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
keyword := strings.TrimSpace(params["keyword"])
statusFilter := strings.TrimSpace(params["status"])
@@ -277,15 +283,28 @@ func AdminOrdersFetch(c *gin.Context) {
}
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
inviteEmails := loadUserEmailMap(extractOrderInviteUserIDs(orders))
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
item := normalizeOrder(order)
item["user_email"] = userEmails[order.UserID]
item["user"] = gin.H{
"id": order.UserID,
"email": userEmails[order.UserID],
}
if order.InviteUserID != nil {
item["invite_user"] = gin.H{
"id": *order.InviteUserID,
"email": inviteEmails[*order.InviteUserID],
}
}
items = append(items, item)
}
Success(c, gin.H{
"list": items,
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
"status": statusFilter,
@@ -388,7 +407,6 @@ func AdminCouponDrop(c *gin.Context) {
Success(c, true)
}
func AdminOrderPaid(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
@@ -421,23 +439,17 @@ func AdminOrderPaid(c *gin.Context) {
return
}
// Update user
var user model.User
if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil {
// Calculate expiration and traffic
// Simplified logic: set plan and transfer_enable
updates := map[string]any{
"plan_id": order.PlanID,
"updated_at": now,
}
if order.Plan != nil {
updates["transfer_enable"] = order.Plan.TransferEnable
}
if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
updates := map[string]any{
"plan_id": order.PlanID,
"updated_at": now,
}
if order.Plan != nil {
updates["transfer_enable"] = order.Plan.TransferEnable
}
if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil {
tx.Rollback()
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
tx.Commit()
@@ -527,19 +539,19 @@ func AdminUserUpdate(c *gin.Context) {
now := time.Now().Unix()
values := map[string]any{
"email": payload["email"],
"password": payload["password"],
"balance": payload["balance"],
"commission_type": payload["commission_type"],
"commission_rate": payload["commission_rate"],
"email": payload["email"],
"password": payload["password"],
"balance": payload["balance"],
"commission_type": payload["commission_type"],
"commission_rate": payload["commission_rate"],
"commission_balance": payload["commission_balance"],
"group_id": payload["group_id"],
"plan_id": payload["plan_id"],
"speed_limit": payload["speed_limit"],
"device_limit": payload["device_limit"],
"expired_at": payload["expired_at"],
"remarks": payload["remarks"],
"updated_at": now,
"group_id": payload["group_id"],
"plan_id": payload["plan_id"],
"speed_limit": payload["speed_limit"],
"device_limit": payload["device_limit"],
"expired_at": payload["expired_at"],
"remarks": payload["remarks"],
"updated_at": now,
}
// Remove nil values to avoid overwriting with defaults if not provided
@@ -564,8 +576,8 @@ func AdminUserUpdate(c *gin.Context) {
func AdminUsersFetch(c *gin.Context) {
params := getFetchParams(c)
page := parsePositiveInt(params["page"], 1)
perPage := parsePositiveInt(params["per_page"], 50)
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
keyword := strings.TrimSpace(params["keyword"])
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
@@ -591,6 +603,28 @@ func AdminUsersFetch(c *gin.Context) {
}
deviceMap := service.GetUsersDevices(userIDs)
realnameStatusByUserID := make(map[int]string, len(userIDs))
if len(userIDs) > 0 {
var records []model.RealNameAuth
if err := database.DB.Select("user_id", "status").Where("user_id IN ?", userIDs).Find(&records).Error; err == nil {
for _, record := range records {
realnameStatusByUserID[int(record.UserID)] = record.Status
}
}
}
shadowByParentID := make(map[int]model.User, len(userIDs))
if len(userIDs) > 0 {
var shadowUsers []model.User
if err := database.DB.Select("id", "parent_id", "email").Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
for _, shadow := range shadowUsers {
if shadow.ParentID != nil {
shadowByParentID[*shadow.ParentID] = shadow
}
}
}
}
groupNames := loadServerGroupNameMap()
items := make([]gin.H, 0, len(users))
for _, user := range users {
@@ -599,37 +633,70 @@ func AdminUsersFetch(c *gin.Context) {
onlineIP = strings.Join(ips, ", ")
}
realnameStatus := realnameStatusByUserID[user.ID]
if realnameStatus == "" {
realnameStatus = "unverified"
}
ipv6Shadow, hasIPv6Shadow := shadowByParentID[user.ID]
ipv6ShadowID := 0
if hasIPv6Shadow {
ipv6ShadowID = ipv6Shadow.ID
}
items = append(items, gin.H{
"id": user.ID,
"email": user.Email,
"balance": user.Balance,
"group_id": intValue(user.GroupID),
"group_name": groupNames[intFromPointer(user.GroupID)],
"plan_id": intValue(user.PlanID),
"plan_name": planName(user.Plan),
"transfer_enable": user.TransferEnable,
"u": user.U,
"d": user.D,
"banned": user.Banned,
"is_admin": user.IsAdmin,
"is_staff": user.IsStaff,
"device_limit": intValue(user.DeviceLimit),
"online_count": intValue(user.OnlineCount),
"expired_at": int64Value(user.ExpiredAt),
"last_login_at": int64Value(user.LastLoginAt),
"last_login_ip": user.LastLoginIP,
"online_ip": onlineIP,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"remarks": stringValue(user.Remarks),
"commission_type": user.CommissionType,
"commission_rate": intValue(user.CommissionRate),
"id": user.ID,
"email": user.Email,
"parent_id": intValue(user.ParentID),
"is_shadow_user": user.ParentID != nil,
"balance": user.Balance,
"uuid": user.UUID,
"token": user.Token,
"group_id": intValue(user.GroupID),
"group_name": groupNames[intFromPointer(user.GroupID)],
"group": gin.H{
"id": intValue(user.GroupID),
"name": groupNames[intFromPointer(user.GroupID)],
},
"plan_id": intValue(user.PlanID),
"plan_name": planName(user.Plan),
"plan": gin.H{
"id": intValue(user.PlanID),
"name": planName(user.Plan),
},
"transfer_enable": user.TransferEnable,
"u": user.U,
"d": user.D,
"total_used": user.U + user.D,
"banned": user.Banned,
"is_admin": user.IsAdmin,
"is_staff": user.IsStaff,
"device_limit": intValue(user.DeviceLimit),
"online_count": intValue(user.OnlineCount),
"expired_at": int64Value(user.ExpiredAt),
"next_reset_at": int64Value(user.NextResetAt),
"last_login_at": int64Value(user.LastLoginAt),
"last_login_ip": user.LastLoginIP,
"online_ip": onlineIP,
"online_ip_count": len(deviceMap[user.ID]),
"realname_status": realnameStatus,
"realname_label": realNameStatusLabel(realnameStatus),
"ipv6_shadow_id": ipv6ShadowID,
"ipv6_shadow_email": firstString(ipv6Shadow.Email, service.IPv6ShadowEmail(user.Email)),
"ipv6_enabled": hasIPv6Shadow,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"remarks": stringValue(user.Remarks),
"commission_type": user.CommissionType,
"commission_rate": intValue(user.CommissionRate),
"commission_balance": user.CommissionBalance,
})
}
Success(c, gin.H{
"list": items,
c.JSON(http.StatusOK, gin.H{
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
},
@@ -689,7 +756,9 @@ func AdminTicketsFetch(c *gin.Context) {
}
Success(c, gin.H{
"list": items,
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
},
@@ -746,6 +815,16 @@ func extractOrderUserIDs(orders []model.Order) []int {
return ids
}
func extractOrderInviteUserIDs(orders []model.Order) []int {
ids := make([]int, 0, len(orders))
for _, order := range orders {
if order.InviteUserID != nil {
ids = append(ids, *order.InviteUserID)
}
}
return ids
}
func extractTicketUserIDs(tickets []model.Ticket) []int {
ids := make([]int, 0, len(tickets))
for _, ticket := range tickets {

View File

@@ -557,6 +557,7 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
if parentServer, ok := servers[*server.ParentID]; ok {
parent = gin.H{
"id": parentServer.ID,
"code": stringValue(parentServer.Code),
"type": parentServer.Type,
"name": parentServer.Name,
"host": parentServer.Host,
@@ -581,11 +582,11 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
isOnline = 2
}
availableStatus := "offline"
availableStatus := 0
if isOnline == 1 {
availableStatus = "online_no_push"
availableStatus = 1
} else if isOnline == 2 {
availableStatus = "online"
availableStatus = 2
}
hasChildren := false
@@ -862,4 +863,3 @@ func isAllowedRouteAction(action string) bool {
return false
}
}

View File

@@ -25,13 +25,18 @@ func AdminGetTrafficRank(c *gin.Context) {
if endTime == 0 {
endTime = time.Now().Unix()
}
periodDuration := endTime - startTime
if periodDuration <= 0 {
periodDuration = 86400
}
previousStart := startTime - periodDuration
previousEnd := startTime
var result []gin.H
if rankType == "user" {
type userRank struct {
ID int `json:"id"`
Value int64 `json:"value"`
Email string `json:"name"`
ID int `json:"id"`
Value int64 `json:"value"`
}
var ranks []userRank
database.DB.Model(&model.StatUser{}).
@@ -47,11 +52,15 @@ func AdminGetTrafficRank(c *gin.Context) {
userIDs = append(userIDs, r.ID)
}
userEmails := loadUserEmailMap(userIDs)
previousValues := loadUserPreviousTrafficMap(userIDs, previousStart, previousEnd)
for _, r := range ranks {
previousValue := previousValues[r.ID]
result = append(result, gin.H{
"id": fmt.Sprintf("%d", r.ID),
"name": userEmails[r.ID],
"value": r.Value,
"id": fmt.Sprintf("%d", r.ID),
"name": userEmails[r.ID],
"value": r.Value,
"previousValue": previousValue,
"change": calculateGrowth(r.Value, previousValue),
})
}
} else {
@@ -75,11 +84,15 @@ func AdminGetTrafficRank(c *gin.Context) {
nodeIDs = append(nodeIDs, r.ID)
}
nodeNames := loadNodeNameMap(nodeIDs)
previousValues := loadNodePreviousTrafficMap(nodeIDs, previousStart, previousEnd)
for _, r := range ranks {
previousValue := previousValues[r.ID]
result = append(result, gin.H{
"id": fmt.Sprintf("%d", r.ID),
"name": nodeNames[r.ID],
"value": r.Value,
"id": fmt.Sprintf("%d", r.ID),
"name": nodeNames[r.ID],
"value": r.Value,
"previousValue": previousValue,
"change": calculateGrowth(r.Value, previousValue),
})
}
}
@@ -116,6 +129,10 @@ func AdminGetOrderStats(c *gin.Context) {
Find(&stats)
var list []gin.H
var paidTotal int64
var paidCount int64
var commissionTotal int64
var commissionCount int64
for _, s := range stats {
dateStr := time.Unix(s.RecordAt, 0).Format("2006-01-02")
item := gin.H{
@@ -127,16 +144,80 @@ func AdminGetOrderStats(c *gin.Context) {
item["paid_total"] = s.PaidTotal
item["paid_count"] = s.PaidCount
item["commission_total"] = s.CommissionTotal
item["commission_count"] = s.CommissionCount
}
paidTotal += s.PaidTotal
paidCount += int64(s.PaidCount)
commissionTotal += s.CommissionTotal
commissionCount += int64(s.CommissionCount)
list = append(list, item)
}
avgPaidAmount := int64(0)
if paidCount > 0 {
avgPaidAmount = paidTotal / paidCount
}
commissionRate := 0.0
if paidTotal > 0 {
commissionRate = float64(commissionTotal) / float64(paidTotal) * 100.0
}
Success(c, gin.H{
"list": list,
"summary": gin.H{
"start_date": time.Unix(startDate, 0).Format("2006-01-02"),
"end_date": time.Unix(endDate, 0).Format("2006-01-02"),
"paid_total": paidTotal,
"paid_count": paidCount,
"avg_paid_amount": avgPaidAmount,
"commission_total": commissionTotal,
"commission_count": commissionCount,
"commission_rate": commissionRate,
},
})
}
func loadUserPreviousTrafficMap(ids []int, startTime int64, endTime int64) map[int]int64 {
result := make(map[int]int64)
if len(ids) == 0 {
return result
}
type userRank struct {
ID int `json:"id"`
Value int64 `json:"value"`
}
var ranks []userRank
database.DB.Model(&model.StatUser{}).
Select("user_id as id, SUM(u + d) as value").
Where("user_id IN ? AND record_at >= ? AND record_at <= ?", ids, startTime, endTime).
Group("user_id").
Scan(&ranks)
for _, rank := range ranks {
result[rank.ID] = rank.Value
}
return result
}
func loadNodePreviousTrafficMap(ids []int, startTime int64, endTime int64) map[int]int64 {
result := make(map[int]int64)
if len(ids) == 0 {
return result
}
type nodeRank struct {
ID int `json:"id"`
Value int64 `json:"value"`
}
var ranks []nodeRank
database.DB.Model(&model.StatServer{}).
Select("server_id as id, SUM(u + d) as value").
Where("server_id IN ? AND record_at >= ? AND record_at <= ?", ids, startTime, endTime).
Group("server_id").
Scan(&ranks)
for _, rank := range ranks {
result[rank.ID] = rank.Value
}
return result
}
func loadNodeNameMap(ids []int) map[int]string {
result := make(map[int]string)

View File

@@ -99,10 +99,6 @@ func Register(c *gin.Context) {
return
}
if service.IsPluginEnabled(service.PluginUserAddIPv6) && user.PlanID != nil {
service.SyncIPv6ShadowAccount(&user)
}
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
if err != nil {
Fail(c, http.StatusInternalServerError, "failed to create auth token")
@@ -166,7 +162,29 @@ func SendEmailVerify(c *gin.Context) {
return
}
service.StoreEmailVerifyCode(strings.ToLower(strings.TrimSpace(req.Email)), code, 10*time.Minute)
email := strings.ToLower(strings.TrimSpace(req.Email))
subject := fmt.Sprintf("[%s] Email verification code", service.MustGetString("app_name", "XBoard"))
textBody := strings.Join([]string{
"Your email verification code is:",
code,
"",
"This code will expire in 10 minutes.",
}, "\n")
htmlBody := fmt.Sprintf(
"<h2>Email verification</h2><p>Your verification code is <strong style=\"font-size:24px;letter-spacing:4px;\">%s</strong>.</p><p>This code will expire in 10 minutes.</p>",
code,
)
if err := service.SendMailWithCurrentSettings(service.EmailMessage{
To: []string{email},
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
}); err != nil {
Fail(c, http.StatusInternalServerError, "failed to send verification email: "+err.Error())
return
}
service.StoreEmailVerifyCode(email, code, 10*time.Minute)
SuccessMessage(c, "email verify code generated", gin.H{
"email": req.Email,
"debug_code": code,

View File

@@ -6,6 +6,7 @@ import (
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"xboard-go/pkg/utils"
"github.com/gin-gonic/gin"
)
@@ -16,7 +17,7 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Model(&model.User{}).Order("id DESC")
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
if keyword != "" {
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
}
@@ -35,33 +36,66 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
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 {
subscriptionName := "No subscription"
if user.PlanID != nil {
var plan model.Plan
if database.DB.First(&plan, *user.PlanID).Error == nil {
subscriptionName = plan.Name
}
ips := devices[user.ID]
meta := deviceMetaByUserID[user.ID]
onlineCount := len(ips)
if meta.Count > onlineCount {
onlineCount = meta.Count
}
if onlineCount > 0 {
usersWithOnlineIP++
totalOnlineIPs += onlineCount
}
ips := devices[user.ID]
if len(ips) > 0 {
usersWithOnlineIP++
totalOnlineIPs += len(ips)
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": subscriptionName,
"online_count": len(ips),
"subscription_name": planName(user.Plan),
"online_count": onlineCount,
"online_devices": ips,
"last_online_text": formatTimeValue(user.LastOnlineAt),
"last_online_text": lastOnlineText,
"created_text": formatUnixValue(user.CreatedAt),
"status": status,
"status_label": statusLabel,
})
}
@@ -86,6 +120,170 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
})
}
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)
@@ -136,24 +334,41 @@ func PluginUserAddIPv6Check(c *gin.Context) {
return
}
if user.PlanID == nil {
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
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 plan model.Plan
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
return
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")
}
}
ipv6Email := service.IPv6ShadowEmail(user.Email)
var count int64
database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Count(&count)
Success(c, gin.H{
"allowed": service.PluginPlanAllowed(&plan),
"is_active": count > 0,
"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)),
})
}
@@ -170,7 +385,23 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
return
}
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
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) {
@@ -181,13 +412,7 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
return
}
ipv6Email := service.IPv6ShadowEmail(user.Email)
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
if result.Error != nil {
Fail(c, 404, "IPv6 user not found")
return
}
if result.RowsAffected == 0 {
if !service.SyncIPv6PasswordState(user) {
Fail(c, 404, "IPv6 user not found")
return
}
@@ -195,8 +420,6 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
SuccessMessage(c, "Password synced to IPv6 account", true)
}
func AdminSystemStatus(c *gin.Context) {
Success(c, gin.H{
"server_time": time.Now().Unix(),
@@ -210,7 +433,39 @@ func AdminSystemStatus(c *gin.Context) {
// 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,

View File

@@ -250,9 +250,6 @@ func currentUser(c *gin.Context) (*model.User, bool) {
if err := database.DB.First(&user, userID).Error; err != nil {
return nil, false
}
if service.IsPluginEnabled(service.PluginUserAddIPv6) && !strings.Contains(user.Email, "-ipv6@") && user.PlanID != nil {
service.SyncIPv6ShadowAccount(&user)
}
c.Set("user", &user)
return &user, true

View File

@@ -7,7 +7,9 @@ import (
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
@@ -27,6 +29,7 @@ type userThemeViewData struct {
type adminAppViewData struct {
Title string
SettingsJS template.JS
AssetNonce string
}
func UserThemePage(c *gin.Context) {
@@ -42,6 +45,7 @@ func UserThemePage(c *gin.Context) {
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
"icpNo": service.MustGetString("icp_no", ""),
"psbNo": service.MustGetString("psb_no", ""),
"staticCdnUrl": service.MustGetString("nebula_static_cdn_url", ""),
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
}
@@ -80,6 +84,7 @@ func AdminAppPage(c *gin.Context) {
payload := adminAppViewData{
Title: title,
SettingsJS: template.JS(settingsJSON),
AssetNonce: strconv.FormatInt(time.Now().UnixNano(), 10),
}
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)

View File

@@ -0,0 +1,21 @@
package model
type UserIPv6Subscription struct {
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
UserID int `gorm:"column:user_id;uniqueIndex:idx_user_ipv6_subscription_user" json:"user_id"`
ShadowUserID *int `gorm:"column:shadow_user_id;index:idx_user_ipv6_subscription_shadow" json:"shadow_user_id"`
IPv6Email string `gorm:"column:ipv6_email;size:191" json:"ipv6_email"`
Allowed bool `gorm:"column:allowed;default:false" json:"allowed"`
Status string `gorm:"column:status;size:32;index:idx_user_ipv6_subscription_status" json:"status"`
LastSyncAt int64 `gorm:"column:last_sync_at" json:"last_sync_at"`
PasswordSyncedAt *int64 `gorm:"column:password_synced_at" json:"password_synced_at"`
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
ShadowUser *User `gorm:"foreignKey:ShadowUserID;references:ID" json:"shadow_user"`
}
func (UserIPv6Subscription) TableName() string {
return "v2_user_ipv6_subscriptions"
}

View File

@@ -0,0 +1,19 @@
package model
type UserOnlineDevice struct {
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
UserID int `gorm:"column:user_id;index:idx_user_online_devices_user;uniqueIndex:idx_user_online_devices_unique" json:"user_id"`
NodeID int `gorm:"column:node_id;index:idx_user_online_devices_node;uniqueIndex:idx_user_online_devices_unique" json:"node_id"`
IP string `gorm:"column:ip;size:191;uniqueIndex:idx_user_online_devices_unique" json:"ip"`
FirstSeenAt int64 `gorm:"column:first_seen_at" json:"first_seen_at"`
LastSeenAt int64 `gorm:"column:last_seen_at;index:idx_user_online_devices_last_seen" json:"last_seen_at"`
ExpiresAt int64 `gorm:"column:expires_at;index:idx_user_online_devices_expires" json:"expires_at"`
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
}
func (UserOnlineDevice) TableName() string {
return "v2_user_online_devices"
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
)
const deviceStateTTL = 10 * time.Minute
@@ -28,7 +29,10 @@ func SaveUserNodeDevices(userID, nodeID int, ips []string) error {
}
sort.Strings(unique)
return database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL)
if err := database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL); err != nil {
return err
}
return syncUserOnlineDevices(userID, nodeID, unique)
}
func GetUsersDevices(userIDs []int) map[int][]string {
@@ -53,6 +57,9 @@ func GetUsersDevices(userIDs []int) map[int][]string {
}
}
sort.Strings(merged)
if len(merged) == 0 {
merged = loadUserOnlineDevicesFromDB(userID)
}
result[userID] = merged
}
@@ -66,6 +73,9 @@ func SetDevices(userID, nodeID int, ips []string) error {
indexKey := deviceStateUserIndexKey(userID)
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
if indexSnapshot == nil {
indexSnapshot = make(userDevicesSnapshot)
}
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
}
@@ -95,3 +105,109 @@ func deviceStateKey(userID, nodeID int) string {
func deviceStateUserIndexKey(userID int) string {
return fmt.Sprintf("device_state:user:%d:index", userID)
}
func syncUserOnlineDevices(userID, nodeID int, ips []string) error {
now := time.Now().Unix()
expiresAt := now + int64(deviceStateTTL.Seconds())
if err := database.DB.Where("user_id = ? AND node_id = ? AND expires_at <= ?", userID, nodeID, now).
Delete(&model.UserOnlineDevice{}).Error; err != nil {
return err
}
existing := make([]model.UserOnlineDevice, 0)
if err := database.DB.Where("user_id = ? AND node_id = ?", userID, nodeID).Find(&existing).Error; err != nil {
return err
}
existingByIP := make(map[string]model.UserOnlineDevice, len(existing))
for _, item := range existing {
existingByIP[item.IP] = item
}
seen := make(map[string]struct{}, len(ips))
for _, ip := range ips {
seen[ip] = struct{}{}
if current, ok := existingByIP[ip]; ok {
if err := database.DB.Model(&model.UserOnlineDevice{}).
Where("id = ?", current.ID).
Updates(map[string]any{
"last_seen_at": now,
"expires_at": expiresAt,
"updated_at": now,
}).Error; err != nil {
return err
}
continue
}
record := model.UserOnlineDevice{
UserID: userID,
NodeID: nodeID,
IP: ip,
FirstSeenAt: now,
LastSeenAt: now,
ExpiresAt: expiresAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := database.DB.Create(&record).Error; err != nil {
return err
}
}
if len(existing) > 0 {
staleIDs := make([]uint64, 0)
for _, item := range existing {
if _, ok := seen[item.IP]; !ok {
staleIDs = append(staleIDs, item.ID)
}
}
if len(staleIDs) > 0 {
if err := database.DB.Where("id IN ?", staleIDs).Delete(&model.UserOnlineDevice{}).Error; err != nil {
return err
}
}
}
var onlineCount int64
if err := database.DB.Model(&model.UserOnlineDevice{}).
Where("user_id = ? AND expires_at > ?", userID, now).
Distinct("ip").
Count(&onlineCount).Error; err == nil {
count := int(onlineCount)
_ = database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"online_count": count,
"last_online_at": time.Unix(now, 0),
"updated_at": now,
}).Error
}
return nil
}
func loadUserOnlineDevicesFromDB(userID int) []string {
now := time.Now().Unix()
var records []model.UserOnlineDevice
if err := database.DB.
Select("ip").
Where("user_id = ? AND expires_at > ?", userID, now).
Order("ip ASC").
Find(&records).Error; err != nil {
return []string{}
}
seen := make(map[string]struct{}, len(records))
ips := make([]string, 0, len(records))
for _, item := range records {
if item.IP == "" {
continue
}
if _, ok := seen[item.IP]; ok {
continue
}
seen[item.IP] = struct{}{}
ips = append(ips, item.IP)
}
return ips
}

283
internal/service/email.go Normal file
View File

@@ -0,0 +1,283 @@
package service
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"net/textproto"
"strings"
)
type EmailConfig struct {
Host string
Port int
Username string
Password string
Encryption string
FromAddress string
FromName string
TemplateName string
}
type EmailMessage struct {
To []string
Subject string
TextBody string
HTMLBody string
}
func LoadEmailConfig() EmailConfig {
fromAddress := strings.TrimSpace(MustGetString("email_from_address", ""))
if fromAddress == "" {
fromAddress = strings.TrimSpace(MustGetString("email_from", ""))
}
fromName := strings.TrimSpace(MustGetString("email_from_name", ""))
if fromName == "" {
fromName = strings.TrimSpace(MustGetString("app_name", "XBoard"))
}
return EmailConfig{
Host: strings.TrimSpace(MustGetString("email_host", "")),
Port: MustGetInt("email_port", 465),
Username: strings.TrimSpace(MustGetString("email_username", "")),
Password: MustGetString("email_password", ""),
Encryption: normalizeEmailEncryption(MustGetString("email_encryption", "")),
FromAddress: fromAddress,
FromName: fromName,
TemplateName: firstNonEmpty(strings.TrimSpace(MustGetString("email_template", "")), "classic"),
}
}
func (cfg EmailConfig) DebugConfig() map[string]any {
return map[string]any{
"driver": "smtp",
"host": cfg.Host,
"port": cfg.Port,
"encryption": cfg.Encryption,
"username": cfg.Username,
"from_address": cfg.SenderAddress(),
"from_name": cfg.FromName,
}
}
func (cfg EmailConfig) SenderAddress() string {
return firstNonEmpty(strings.TrimSpace(cfg.FromAddress), strings.TrimSpace(cfg.Username))
}
func (cfg EmailConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Host) == "":
return errors.New("email_host is required")
case cfg.Port <= 0:
return errors.New("email_port is required")
case cfg.SenderAddress() == "":
return errors.New("email_from_address or email_username is required")
}
return nil
}
func SendMailWithCurrentSettings(message EmailMessage) error {
return SendMail(LoadEmailConfig(), message)
}
func SendMail(cfg EmailConfig, message EmailMessage) error {
if err := cfg.Validate(); err != nil {
return err
}
if len(message.To) == 0 {
return errors.New("at least one recipient is required")
}
if strings.TrimSpace(message.Subject) == "" {
return errors.New("subject is required")
}
if strings.TrimSpace(message.TextBody) == "" && strings.TrimSpace(message.HTMLBody) == "" {
return errors.New("message body is required")
}
payload, err := buildEmailMessage(cfg, message)
if err != nil {
return err
}
client, err := dialSMTP(cfg)
if err != nil {
return err
}
defer client.Close()
if cfg.Username != "" && cfg.Password != "" {
if ok, _ := client.Extension("AUTH"); !ok {
return errors.New("smtp server does not support authentication")
}
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return err
}
}
if err := client.Mail(cfg.SenderAddress()); err != nil {
return err
}
for _, recipient := range message.To {
recipient = strings.TrimSpace(recipient)
if recipient == "" {
continue
}
if err := client.Rcpt(recipient); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(payload); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
func dialSMTP(cfg EmailConfig) (*smtp.Client, error) {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
switch cfg.Encryption {
case "ssl":
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return nil, err
}
return smtp.NewClient(conn, cfg.Host)
case "tls":
client, err := smtp.Dial(addr)
if err != nil {
return nil, err
}
if ok, _ := client.Extension("STARTTLS"); !ok {
_ = client.Close()
return nil, errors.New("smtp server does not support STARTTLS")
}
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
default:
return smtp.Dial(addr)
}
}
func buildEmailMessage(cfg EmailConfig, message EmailMessage) ([]byte, error) {
fromAddress := cfg.SenderAddress()
if _, err := mail.ParseAddress(fromAddress); err != nil {
return nil, fmt.Errorf("invalid sender address: %w", err)
}
toAddresses := make([]string, 0, len(message.To))
for _, recipient := range message.To {
recipient = strings.TrimSpace(recipient)
if recipient == "" {
continue
}
if _, err := mail.ParseAddress(recipient); err != nil {
return nil, fmt.Errorf("invalid recipient address: %w", err)
}
toAddresses = append(toAddresses, recipient)
}
if len(toAddresses) == 0 {
return nil, errors.New("no valid recipients")
}
fromHeader := (&mail.Address{Name: cfg.FromName, Address: fromAddress}).String()
subjectHeader := mime.QEncoding.Encode("UTF-8", strings.TrimSpace(message.Subject))
var buf bytes.Buffer
writeHeader := func(key, value string) {
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteString("\r\n")
}
writeHeader("From", fromHeader)
writeHeader("To", strings.Join(toAddresses, ", "))
writeHeader("Subject", subjectHeader)
writeHeader("MIME-Version", "1.0")
textBody := message.TextBody
htmlBody := message.HTMLBody
if strings.TrimSpace(htmlBody) != "" {
mw := multipart.NewWriter(&buf)
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, mw.Boundary()))
buf.WriteString("\r\n")
if strings.TrimSpace(textBody) == "" {
textBody = htmlBody
}
if err := writeMIMEPart(mw, "text/plain", textBody); err != nil {
return nil, err
}
if err := writeMIMEPart(mw, "text/html", htmlBody); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
writeHeader("Content-Type", `text/plain; charset="UTF-8"`)
writeHeader("Content-Transfer-Encoding", "quoted-printable")
buf.WriteString("\r\n")
qp := quotedprintable.NewWriter(&buf)
if _, err := qp.Write([]byte(textBody)); err != nil {
return nil, err
}
if err := qp.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeMIMEPart(mw *multipart.Writer, contentType string, body string) error {
header := textproto.MIMEHeader{}
header.Set("Content-Type", fmt.Sprintf(`%s; charset="UTF-8"`, contentType))
header.Set("Content-Transfer-Encoding", "quoted-printable")
part, err := mw.CreatePart(header)
if err != nil {
return err
}
qp := quotedprintable.NewWriter(part)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}
func normalizeEmailEncryption(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "ssl":
return "ssl"
case "tls", "starttls":
return "tls"
default:
return ""
}
}

View File

@@ -76,21 +76,26 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
}
func SyncIPv6ShadowAccount(user *model.User) bool {
if user == nil || user.PlanID == nil {
if user == nil {
return false
}
var plan model.Plan
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
return false
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
}
}
if !PluginPlanAllowed(&plan) {
if !PluginUserAllowed(user, plan) {
syncIPv6SubscriptionRecord(user, nil, false, "not_allowed")
return false
}
ipv6Email := IPv6ShadowEmail(user.Email)
var ipv6User model.User
now := time.Now().Unix()
created := false
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
ipv6User = *user
ipv6User.ID = 0
@@ -102,6 +107,7 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.T = 0
ipv6User.ParentID = &user.ID
ipv6User.CreatedAt = now
created = true
}
ipv6User.Email = ipv6Email
@@ -118,20 +124,41 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.GroupID = &groupID
}
return database.DB.Save(&ipv6User).Error == nil
}
func PluginPlanAllowed(plan *model.Plan) bool {
if plan == nil {
if err := database.DB.Save(&ipv6User).Error; err != nil {
syncIPv6SubscriptionRecord(user, nil, true, "eligible")
return false
}
for _, raw := range strings.Split(GetPluginConfigString(PluginUserAddIPv6, "allowed_plans", ""), ",") {
if parsePluginPositiveInt(strings.TrimSpace(raw), 0) == plan.ID {
if created {
_ = database.DB.Model(&model.User{}).Where("id = ?", ipv6User.ID).Update("parent_id", user.ID).Error
}
syncIPv6SubscriptionRecord(user, &ipv6User, true, "active")
return true
}
func PluginPlanAllowed(plan *model.Plan) bool {
return PluginUserAllowed(nil, plan)
}
func PluginUserAllowed(user *model.User, plan *model.Plan) bool {
allowedPlans := GetPluginConfigIntList(PluginUserAddIPv6, "allowed_plans")
for _, planID := range allowedPlans {
if plan != nil && plan.ID == planID {
return true
}
}
allowedGroups := GetPluginConfigIntList(PluginUserAddIPv6, "allowed_groups")
for _, groupID := range allowedGroups {
if user != nil && user.GroupID != nil && *user.GroupID == groupID {
return true
}
}
if plan == nil {
return false
}
referenceFlag := strings.ToLower(GetPluginConfigString(PluginUserAddIPv6, "reference_flag", "ipv6"))
reference := ""
if plan.Reference != nil {
@@ -140,6 +167,68 @@ func PluginPlanAllowed(plan *model.Plan) bool {
return referenceFlag != "" && strings.Contains(reference, referenceFlag)
}
func GetPluginConfigIntList(code, key string) []int {
cfg := GetPluginConfig(code)
value, ok := cfg[key]
if !ok || value == nil {
return []int{}
}
parseItem := func(raw any) int {
switch typed := raw.(type) {
case int:
if typed > 0 {
return typed
}
case int64:
if typed > 0 {
return int(typed)
}
case float64:
if typed > 0 {
return int(typed)
}
case string:
return parsePluginPositiveInt(typed, 0)
}
return 0
}
result := make([]int, 0)
seen := make(map[int]struct{})
appendValue := func(candidate int) {
if candidate <= 0 {
return
}
if _, ok := seen[candidate]; ok {
return
}
seen[candidate] = struct{}{}
result = append(result, candidate)
}
switch typed := value.(type) {
case []any:
for _, item := range typed {
appendValue(parseItem(item))
}
case []int:
for _, item := range typed {
appendValue(item)
}
case []float64:
for _, item := range typed {
appendValue(int(item))
}
case string:
for _, raw := range strings.Split(typed, ",") {
appendValue(parsePluginPositiveInt(strings.TrimSpace(raw), 0))
}
}
return result
}
func IPv6ShadowEmail(email string) string {
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
parts := strings.SplitN(email, "@", 2)
@@ -156,3 +245,67 @@ func parsePluginPositiveInt(raw string, defaultValue int) int {
}
return value
}
func SyncIPv6PasswordState(user *model.User) bool {
if user == nil {
return false
}
ipv6Email := IPv6ShadowEmail(user.Email)
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
if result.Error != nil || result.RowsAffected == 0 {
return false
}
now := time.Now().Unix()
updates := map[string]any{
"password_synced_at": now,
"updated_at": now,
}
var subscription model.UserIPv6Subscription
if err := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error; err == nil {
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("id = ?", subscription.ID).Updates(updates).Error
}
return true
}
func syncIPv6SubscriptionRecord(user *model.User, shadowUser *model.User, allowed bool, status string) {
if user == nil {
return
}
now := time.Now().Unix()
record := model.UserIPv6Subscription{
UserID: user.ID,
IPv6Email: IPv6ShadowEmail(user.Email),
Allowed: allowed,
Status: status,
LastSyncAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if shadowUser != nil {
record.ShadowUserID = &shadowUser.ID
}
var existing model.UserIPv6Subscription
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
updates := map[string]any{
"ipv6_email": record.IPv6Email,
"allowed": allowed,
"status": status,
"last_sync_at": now,
"updated_at": now,
}
if shadowUser != nil {
updates["shadow_user_id"] = shadowUser.ID
} else {
updates["shadow_user_id"] = nil
}
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("id = ?", existing.ID).Updates(updates).Error
return
}
_ = database.DB.Create(&record).Error
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"github.com/google/uuid"
)
@@ -36,6 +37,7 @@ func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent)
list[i].LastUsedAt = now
saveSessions(userID, list)
syncUserSessionOnlineState(userID, list[i].IP, now)
return list[i]
}
}
@@ -53,6 +55,7 @@ func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
}
list = append(list, record)
saveSessions(userID, list)
syncUserSessionOnlineState(userID, record.IP, now)
return record
}
@@ -177,6 +180,45 @@ func saveSessions(userID int, sessions []SessionRecord) {
_ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL)
}
func syncUserSessionOnlineState(userID int, ip string, now int64) {
updates := map[string]any{
"last_online_at": time.Unix(now, 0),
"updated_at": now,
}
if sessions := activeSessions(userID, now); len(sessions) > 0 {
updates["online_count"] = len(sessions)
}
_ = database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
ip = firstNonEmpty(ip)
if ip == "" {
return
}
record := model.UserOnlineDevice{
UserID: userID,
NodeID: 0,
IP: ip,
FirstSeenAt: now,
LastSeenAt: now,
ExpiresAt: now + int64(sessionTTL.Seconds()),
CreatedAt: now,
UpdatedAt: now,
}
var existing model.UserOnlineDevice
if err := database.DB.Where("user_id = ? AND node_id = ? AND ip = ?", userID, 0, ip).First(&existing).Error; err == nil {
_ = database.DB.Model(&model.UserOnlineDevice{}).Where("id = ?", existing.ID).Updates(map[string]any{
"last_seen_at": now,
"expires_at": record.ExpiresAt,
"updated_at": now,
}).Error
return
}
_ = database.DB.Create(&record).Error
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])