package handler import ( "encoding/base64" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "xboard-go/internal/model" "xboard-go/internal/protocol" "xboard-go/internal/service" "github.com/gin-gonic/gin" ) func ClientSubscribe(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, http.StatusForbidden, "invalid token") return } FulfillSubscription(c, user) } func FulfillSubscription(c *gin.Context, user *model.User) { servers, err := service.AvailableServersForUser(user) if err != nil { Fail(c, 500, "failed to fetch servers") return } // 1. Filter servers types := c.Query("types") filter := c.Query("filter") servers = filterServers(servers, types, filter) ua := c.GetHeader("User-Agent") flag := c.Query("flag") uaLower := strings.ToLower(ua) // 2. Handle specialized configs if strings.Contains(uaLower, "stash") || flag == "stash" { config, _ := protocol.GenerateClashWithTemplate("stash", servers, *user) c.Header("Content-Type", "application/octet-stream") c.String(http.StatusOK, config) return } if strings.Contains(uaLower, "clashmeta") || strings.Contains(uaLower, "clash meta") || strings.Contains(uaLower, "metacubex") || strings.Contains(uaLower, "verge") || strings.Contains(uaLower, "flclash") || strings.Contains(uaLower, "nekobox") || flag == "clashmeta" || flag == "clash-meta" { config, _ := protocol.GenerateClashWithTemplate("clashmeta", servers, *user) c.Header("Content-Type", "application/octet-stream") c.String(http.StatusOK, config) return } if strings.Contains(ua, "Clash") || flag == "clash" { config, _ := protocol.GenerateClashWithTemplate("clash", servers, *user) c.Header("Content-Type", "application/octet-stream") c.String(http.StatusOK, config) return } if strings.Contains(uaLower, "sing-box") || flag == "sing-box" { config, _ := protocol.GenerateSingBox(servers, *user) c.Header("Content-Type", "application/json; charset=utf-8") c.String(http.StatusOK, config) return } // 3. General Subscription (Links) var finalLinks []string // 3a. Add Info Nodes (match reference behavior) if service.MustGetBool("show_info_to_server_enable", true) { finalLinks = append(finalLinks, generateInfoNodes(user)...) } // 3b. Add Actual Node Links nodeLinks := protocol.GenerateGeneralLinks(servers, *user) if nodeLinks != "" { finalLinks = append(finalLinks, strings.Split(nodeLinks, "\n")...) } c.Header("Content-Type", "text/plain; charset=utf-8") c.Header("Subscription-Userinfo", subscriptionUserInfo(*user)) c.String(http.StatusOK, base64.StdEncoding.EncodeToString([]byte(strings.Join(finalLinks, "\n")))) } func filterServers(servers []model.Server, types, filter string) []model.Server { var filtered []model.Server allowed := make(map[string]bool) if types != "" && types != "all" { for _, t := range strings.Split(types, ",") { allowed[strings.TrimSpace(t)] = true } } for _, s := range servers { if len(allowed) > 0 && !allowed[s.Type] { continue } if filter != "" { if !strings.Contains(strings.ToLower(s.Name), strings.ToLower(filter)) { continue } } filtered = append(filtered, s) } return filtered } func generateInfoNodes(user *model.User) []string { var nodes []string // Expire Info expireDate := "长期有效" if user.ExpiredAt != nil { expireDate = time.Unix(*user.ExpiredAt, 0).Format("2006-01-02") } nodes = append(nodes, buildInfoLink("套餐到期:"+expireDate)) // Traffic Info remaining := int64(user.TransferEnable) - (int64(user.U) + int64(user.D)) if remaining < 0 { remaining = 0 } nodes = append(nodes, buildInfoLink("剩余流量:"+formatTraffic(remaining))) return nodes } func buildInfoLink(name string) string { m := map[string]string{ "v": "2", "ps": name, "add": "1.2.3.4", "port": "443", "id": "00000000-0000-0000-0000-000000000000", "aid": "0", "scy": "auto", "net": "tcp", "type": "none", "host": "", "path": "", "tls": "", } b, _ := json.Marshal(m) return "vmess://" + base64.StdEncoding.EncodeToString(b) } func formatTraffic(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } func ClientAppConfigV1(c *gin.Context) { user, ok := currentUser(c) if !ok { Fail(c, http.StatusForbidden, "invalid token") return } servers, err := service.AvailableServersForUser(user) if err != nil { Fail(c, 500, "failed to fetch servers") return } config, _ := protocol.GenerateClash(servers, *user) c.Header("Content-Type", "text/yaml; charset=utf-8") c.String(http.StatusOK, config) } func ClientAppConfigV2(c *gin.Context) { config := gin.H{ "app_info": gin.H{ "app_name": service.MustGetString("app_name", "XBoard"), "app_description": service.MustGetString("app_description", ""), "app_url": service.GetAppURL(), "logo": service.MustGetString("logo", ""), "version": service.MustGetString("app_version", "1.0.0"), }, "features": gin.H{ "enable_register": service.MustGetBool("app_enable_register", true), "enable_invite_system": service.MustGetBool("app_enable_invite_system", true), "enable_telegram_bot": service.MustGetBool("telegram_bot_enable", false), "enable_ticket_system": service.MustGetBool("app_enable_ticket_system", true), "enable_commission_system": service.MustGetBool("app_enable_commission_system", true), "enable_traffic_log": service.MustGetBool("app_enable_traffic_log", true), "enable_knowledge_base": service.MustGetBool("app_enable_knowledge_base", true), "enable_announcements": service.MustGetBool("app_enable_announcements", true), }, "security_config": gin.H{ "tos_url": service.MustGetString("tos_url", ""), "privacy_policy_url": service.MustGetString("app_privacy_policy_url", ""), "is_email_verify": service.MustGetInt("email_verify", 0), "is_invite_force": service.MustGetInt("invite_force", 0), "email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""), "is_captcha": service.MustGetInt("captcha_enable", 0), "captcha_type": service.MustGetString("captcha_type", "recaptcha"), "recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""), "turnstile_site_key": service.MustGetString("turnstile_site_key", ""), }, } Success(c, config) } func ClientAppVersion(c *gin.Context) { ua := strings.ToLower(c.GetHeader("User-Agent")) if strings.Contains(ua, "tidalab/4.0.0") || strings.Contains(ua, "tunnelab/4.0.0") { if strings.Contains(ua, "win64") { Success(c, gin.H{ "version": service.MustGetString("windows_version", ""), "download_url": service.MustGetString("windows_download_url", ""), }) return } Success(c, gin.H{ "version": service.MustGetString("macos_version", ""), "download_url": service.MustGetString("macos_download_url", ""), }) return } Success(c, 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", ""), }) } func subscriptionUserInfo(user model.User) string { expire := int64(0) if user.ExpiredAt != nil { expire = *user.ExpiredAt } return "upload=" + strconv.FormatInt(int64(user.U), 10) + "; download=" + strconv.FormatInt(int64(user.D), 10) + "; total=" + strconv.FormatInt(int64(user.TransferEnable), 10) + "; expire=" + strconv.FormatInt(expire, 10) }