基本功能已初步完善
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ">")
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
|
||||
37
internal/handler/admin_plugin_page.go
Normal file
37
internal/handler/admin_plugin_page.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
internal/model/user_ipv6_subscription.go
Normal file
21
internal/model/user_ipv6_subscription.go
Normal 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"
|
||||
}
|
||||
19
internal/model/user_online_device.go
Normal file
19
internal/model/user_online_device.go
Normal 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"
|
||||
}
|
||||
@@ -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
283
internal/service/email.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
Reference in New Issue
Block a user