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{ "
If you received this email, the current SMTP configuration is working.
", }, "") 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 "" } }