Files
SingBox-Gopanel/internal/handler/admin_config_api.go
CN-JS-HuiBai b874d2e258
Some checks failed
build / build (api, amd64, linux) (push) Failing after -50s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -52s
移除不必要的前端拦截
2026-04-18 16:33:36 +08:00

446 lines
17 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
// AdminConfigFetch returns modular configuration settings based on Categories.
// This matches the official Xboard ConfigController.fetch logic.
func AdminConfigFetch(c *gin.Context) {
key := c.Query("key")
mappings := getAllConfigMappings()
if key != "" {
if val, ok := mappings[key]; ok {
Success(c, gin.H{key: val})
return
}
}
Success(c, mappings)
}
// AdminConfigSave saves configuration settings.
func AdminConfigSave(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
// In a real implementation, we'd loop through payload and update model.Setting
// We also handle special keys like subscribe_template_* or frontend_theme
for k, v := range payload {
// Special handling for templates could go here
saveSetting(k, v)
}
Success(c, true)
}
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
}
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 := listDirectoryEntries(path)
if err != nil {
Success(c, []string{})
return
}
Success(c, files)
}
func getAllConfigMappings() gin.H {
return gin.H{
"invite": gin.H{
"invite_force": service.MustGetBool("invite_force", false),
"invite_commission": service.MustGetInt("invite_commission", 10),
"invite_gen_limit": service.MustGetInt("invite_gen_limit", 5),
"invite_never_expire": service.MustGetBool("invite_never_expire", false),
"commission_first_time_enable": service.MustGetBool("commission_first_time_enable", true),
"commission_auto_check_enable": service.MustGetBool("commission_auto_check_enable", true),
"commission_withdraw_limit": service.MustGetInt("commission_withdraw_limit", 100),
"commission_withdraw_method": getStringListSetting("commission_withdraw_method", []string{"alipay"}),
"withdraw_close_enable": service.MustGetBool("withdraw_close_enable", false),
"commission_distribution_enable": service.MustGetBool("commission_distribution_enable", false),
"commission_distribution_l1": service.MustGetInt("commission_distribution_l1", 0),
"commission_distribution_l2": service.MustGetInt("commission_distribution_l2", 0),
"commission_distribution_l3": service.MustGetInt("commission_distribution_l3", 0),
},
"site": gin.H{
"logo": service.MustGetString("logo", ""),
"force_https": service.MustGetInt("force_https", 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", ""),
"subscribe_url": service.MustGetString("subscribe_url", ""),
"try_out_plan_id": service.MustGetInt("try_out_plan_id", 0),
"try_out_hour": service.MustGetInt("try_out_hour", 1),
"tos_url": service.MustGetString("tos_url", ""),
"currency": service.MustGetString("currency", "CNY"),
"currency_symbol": service.MustGetString("currency_symbol", "¥"),
"ticket_must_wait_reply": service.MustGetBool("ticket_must_wait_reply", false),
},
"subscribe": gin.H{
"plan_change_enable": service.MustGetBool("plan_change_enable", true),
"reset_traffic_method": service.MustGetInt("reset_traffic_method", 0),
"surplus_enable": service.MustGetBool("surplus_enable", true),
"new_order_event_id": service.MustGetInt("new_order_event_id", 0),
"renew_order_event_id": service.MustGetInt("renew_order_event_id", 0),
"change_order_event_id": service.MustGetInt("change_order_event_id", 0),
"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),
"subscribe_path": service.GetSubscribePath(),
},
"subscribe_template": service.GetAllSubscribeTemplates(),
"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_background_url": service.MustGetString("frontend_background_url", ""),
},
"server": gin.H{
"server_token": service.MustGetString("server_token", ""),
"server_pull_interval": service.MustGetInt("server_pull_interval", 60),
"server_push_interval": service.MustGetInt("server_push_interval", 60),
"device_limit_mode": service.MustGetInt("device_limit_mode", 0),
"server_ws_enable": service.MustGetBool("server_ws_enable", true),
"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),
"secure_path": service.GetAdminSecurePath(),
"email_whitelist_enable": service.MustGetBool("email_whitelist_enable", false),
"email_whitelist_suffix": getStringListSetting("email_whitelist_suffix", []string{}),
"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"),
"recaptcha_key": service.MustGetString("recaptcha_key", ""),
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
"recaptcha_v3_secret_key": service.MustGetString("recaptcha_v3_secret_key", ""),
"recaptcha_v3_site_key": service.MustGetString("recaptcha_v3_site_key", ""),
"recaptcha_v3_score_threshold": service.MustGetString("recaptcha_v3_score_threshold", "0.5"),
"turnstile_secret_key": service.MustGetString("turnstile_secret_key", ""),
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
"register_limit_by_ip_enable": service.MustGetBool("register_limit_by_ip_enable", false),
"register_limit_count": service.MustGetInt("register_limit_count", 3),
"register_limit_expire": service.MustGetString("register_limit_expire", ""),
"password_limit_enable": service.MustGetBool("password_limit_enable", true),
"password_limit_count": service.MustGetString("password_limit_count", ""),
"password_limit_expire": service.MustGetString("password_limit_expire", ""),
},
"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),
},
"telegram": gin.H{
"telegram_bot_enable": service.MustGetBool("telegram_bot_enable", false),
"telegram_bot_token": service.MustGetString("telegram_bot_token", ""),
"telegram_webhook_url": service.MustGetString("telegram_webhook_url", ""),
"telegram_discuss_link": service.MustGetString("telegram_discuss_link", ""),
},
"app": gin.H{
"windows_version": service.MustGetString("windows_version", ""),
"windows_download_url": service.MustGetString("windows_download_url", ""),
"macos_version": service.MustGetString("macos_version", ""),
"macos_download_url": service.MustGetString("macos_download_url", ""),
"android_version": service.MustGetString("android_version", ""),
"android_download_url": service.MustGetString("android_download_url", ""),
},
"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", ""),
},
}
}
func saveSetting(name string, value any) {
var val string
switch v := value.(type) {
case string:
val = v
case int:
val = strconv.Itoa(v)
case int64:
val = strconv.FormatInt(v, 10)
case float64:
val = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
val = "1"
} else {
val = "0"
}
case []string:
val = serializeSettingList(name, v)
case []any:
items := make([]string, 0, len(v))
for _, item := range v {
text := strings.TrimSpace(stringFromAny(item))
if text != "" {
items = append(items, text)
}
}
val = serializeSettingList(name, items)
default:
if marshaled, err := json.Marshal(v); err == nil {
val = string(marshaled)
}
}
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 listDirectoryEntries(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, entry := range entries {
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 getStringListSetting(name string, defaultValue []string) []string {
raw, ok := service.GetSetting(name)
if !ok || strings.TrimSpace(raw) == "" {
return append([]string(nil), defaultValue...)
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err == nil {
return compactStringList(values, defaultValue)
}
normalized := strings.ReplaceAll(raw, "\r\n", "\n")
var parts []string
if strings.Contains(normalized, "\n") {
parts = strings.Split(normalized, "\n")
} else if strings.Contains(normalized, ",") {
parts = strings.Split(normalized, ",")
} else {
parts = []string{normalized}
}
return compactStringList(parts, defaultValue)
}
func compactStringList(values []string, defaultValue []string) []string {
result := make([]string, 0, len(values))
for _, item := range values {
item = strings.TrimSpace(item)
if item != "" {
result = append(result, item)
}
}
if len(result) == 0 {
return append([]string(nil), defaultValue...)
}
return result
}
func serializeSettingList(name string, values []string) string {
values = compactStringList(values, []string{})
if len(values) == 0 {
return ""
}
switch name {
case "commission_withdraw_method":
if payload, err := json.Marshal(values); err == nil {
return string(payload)
}
}
return strings.Join(values, "\n")
}
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 "subscribe_template_singbox", "subscribe_template_clash", "subscribe_template_clashmeta",
"subscribe_template_stash", "subscribe_template_surge", "subscribe_template_surfboard":
return "subscribe_template"
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",
"recaptcha_key", "recaptcha_site_key", "recaptcha_v3_secret_key", "recaptcha_v3_site_key",
"recaptcha_v3_score_threshold", "turnstile_secret_key", "turnstile_site_key",
"register_limit_by_ip_enable", "register_limit_count", "register_limit_expire",
"password_limit_enable", "password_limit_count", "password_limit_expire":
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 "telegram_bot_enable", "telegram_bot_token", "telegram_webhook_url", "telegram_discuss_link":
return "telegram"
case "windows_version", "windows_download_url", "macos_version", "macos_download_url",
"android_version", "android_download_url":
return "app"
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 ""
}
}