From d580086361036f87af843d0f7386bdc54736720a Mon Sep 17 00:00:00 2001 From: zhuzn Date: Mon, 20 Apr 2026 04:26:13 +0800 Subject: [PATCH] feat add clash yaml convert (#3916) * docs(agents): add AI agent guidance documentation * feat(sub): add Clash/Mihomo YAML subscription service Add SubClashService to convert subscription links to Clash/Mihomo YAML format for direct client compatibility. Co-Authored-By: Claude Sonnet 4.6 * feat(sub): integrate Clash YAML endpoint into subscription system - Add Clash route handler in SUBController - Update BuildURLs to include Clash URL - Pass Clash settings through subscription pipeline Co-Authored-By: Claude Sonnet 4.6 * feat(web): add Clash settings to entity and service - Add SubClashEnable, SubClashPath, SubClashURI fields - Add getter methods for Clash configuration - Set default Clash path to /clash/ and enable by default Co-Authored-By: Claude Sonnet 4.6 * feat(ui): add Clash settings to subscription panels - Add Clash enable switch in general subscription settings - Add Clash path/URI configuration in formats panel - Display Clash QR code on subscription page - Rename JSON tab to "Formats" for clarity Co-Authored-By: Claude Sonnet 4.6 * feat(js): add Clash support to frontend models - Add subClashEnable, subClashPath, subClashURI to AllSetting - Generate and display Clash QR code on subscription page - Handle Clash URL in subscription data binding Co-Authored-By: Claude Sonnet 4.6 * fix --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Sanaei --- go.mod | 2 +- sub/sub.go | 13 +- sub/subClashService.go | 385 ++++++++++++++++++ sub/subController.go | 45 +- sub/subService.go | 20 +- web/assets/js/model/setting.js | 3 + web/assets/js/subscription.js | 7 + web/entity/entity.go | 10 + web/html/settings.html | 4 +- .../settings/panel/subscription/general.html | 86 ++-- .../settings/panel/subscription/json.html | 26 +- .../settings/panel/subscription/subpage.html | 17 +- web/service/setting.go | 39 +- 13 files changed, 596 insertions(+), 61 deletions(-) create mode 100644 sub/subClashService.go diff --git a/go.mod b/go.mod index a30157c3..6858d5b6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-ldap/ldap/v3 v3.4.13 github.com/goccy/go-json v0.10.6 + github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 @@ -48,7 +49,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/sub/sub.go b/sub/sub.go index 1dcd9601..b940cc95 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } - // Determine if JSON subscription endpoint is enabled + ClashPath, err := s.settingService.GetSubClashPath() + if err != nil { + return nil, err + } + subJsonEnable, err := s.settingService.GetSubJsonEnable() if err != nil { return nil, err } + subClashEnable, err := s.settingService.GetSubClashEnable() + if err != nil { + return nil, err + } + // Set base_path based on LinksPath for template rendering // Ensure LinksPath ends with "/" for proper asset URL generation basePath := LinksPath @@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { g := engine.Group("/") s.sub = NewSUBController( - g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, + g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) diff --git a/sub/subClashService.go b/sub/subClashService.go new file mode 100644 index 00000000..ea095919 --- /dev/null +++ b/sub/subClashService.go @@ -0,0 +1,385 @@ +package sub + +import ( + "fmt" + "strings" + + "github.com/goccy/go-json" + yaml "github.com/goccy/go-yaml" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +type SubClashService struct { + inboundService service.InboundService + SubService *SubService +} + +type ClashConfig struct { + Proxies []map[string]any `yaml:"proxies"` + ProxyGroups []map[string]any `yaml:"proxy-groups"` + Rules []string `yaml:"rules"` +} + +func NewSubClashService(subService *SubService) *SubClashService { + return &SubClashService{SubService: subService} +} + +func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { + inbounds, err := s.SubService.getInboundsBySubId(subId) + if err != nil || len(inbounds) == 0 { + return "", "", err + } + + var traffic xray.ClientTraffic + var clientTraffics []xray.ClientTraffic + var proxies []map[string]any + + for _, inbound := range inbounds { + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubClashService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { + listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings) + if err == nil { + inbound.Listen = listen + inbound.Port = port + inbound.StreamSettings = streamSettings + } + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email)) + proxies = append(proxies, s.getProxies(inbound, client, host)...) + } + } + } + + if len(proxies) == 0 { + return "", "", nil + } + + for index, clientTraffic := range clientTraffics { + if index == 0 { + traffic.Up = clientTraffic.Up + traffic.Down = clientTraffic.Down + traffic.Total = clientTraffic.Total + if clientTraffic.ExpiryTime > 0 { + traffic.ExpiryTime = clientTraffic.ExpiryTime + } + } else { + traffic.Up += clientTraffic.Up + traffic.Down += clientTraffic.Down + if traffic.Total == 0 || clientTraffic.Total == 0 { + traffic.Total = 0 + } else { + traffic.Total += clientTraffic.Total + } + if clientTraffic.ExpiryTime != traffic.ExpiryTime { + traffic.ExpiryTime = 0 + } + } + } + + proxyNames := make([]string, 0, len(proxies)+1) + for _, proxy := range proxies { + if name, ok := proxy["name"].(string); ok && name != "" { + proxyNames = append(proxyNames, name) + } + } + proxyNames = append(proxyNames, "DIRECT") + + config := ClashConfig{ + Proxies: proxies, + ProxyGroups: []map[string]any{{ + "name": "PROXY", + "type": "select", + "proxies": proxyNames, + }}, + Rules: []string{"MATCH,PROXY"}, + } + + finalYAML, err := yaml.Marshal(config) + if err != nil { + return "", "", err + } + + header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) + return string(finalYAML), header, nil +} + +func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { + stream := s.streamData(inbound.StreamSettings) + externalProxies, ok := stream["externalProxy"].([]any) + if !ok || len(externalProxies) == 0 { + externalProxies = []any{map[string]any{ + "forceTls": "same", + "dest": host, + "port": float64(inbound.Port), + "remark": "", + }} + } + delete(stream, "externalProxy") + + proxies := make([]map[string]any, 0, len(externalProxies)) + for _, ep := range externalProxies { + extPrxy := ep.(map[string]any) + workingInbound := *inbound + workingInbound.Listen = extPrxy["dest"].(string) + workingInbound.Port = int(extPrxy["port"].(float64)) + workingStream := cloneMap(stream) + + switch extPrxy["forceTls"].(string) { + case "tls": + if workingStream["security"] != "tls" { + workingStream["security"] = "tls" + workingStream["tlsSettings"] = map[string]any{} + } + case "none": + if workingStream["security"] != "none" { + workingStream["security"] = "none" + delete(workingStream, "tlsSettings") + delete(workingStream, "realitySettings") + } + } + + proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string)) + if len(proxy) > 0 { + proxies = append(proxies, proxy) + } + } + return proxies +} + +func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any { + proxy := map[string]any{ + "name": s.SubService.genRemark(inbound, client.Email, extraRemark), + "server": inbound.Listen, + "port": inbound.Port, + "udp": true, + } + + network, _ := stream["network"].(string) + if !s.applyTransport(proxy, network, stream) { + return nil + } + + switch inbound.Protocol { + case model.VMESS: + proxy["type"] = "vmess" + proxy["uuid"] = client.ID + proxy["alterId"] = 0 + cipher := client.Security + if cipher == "" { + cipher = "auto" + } + proxy["cipher"] = cipher + case model.VLESS: + proxy["type"] = "vless" + proxy["uuid"] = client.ID + if client.Flow != "" && network == "tcp" { + proxy["flow"] = client.Flow + } + var inboundSettings map[string]any + json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" { + proxy["packet-encoding"] = encryption + } + case model.Trojan: + proxy["type"] = "trojan" + proxy["password"] = client.Password + case model.Shadowsocks: + proxy["type"] = "ss" + proxy["password"] = client.Password + var inboundSettings map[string]any + json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + method, _ := inboundSettings["method"].(string) + if method == "" { + return nil + } + proxy["cipher"] = method + if strings.HasPrefix(method, "2022") { + if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" { + proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password) + } + } + default: + return nil + } + + security, _ := stream["security"].(string) + if !s.applySecurity(proxy, security, stream) { + return nil + } + + return proxy +} + +func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool { + switch network { + case "", "tcp": + proxy["network"] = "tcp" + tcp, _ := stream["tcpSettings"].(map[string]any) + if tcp != nil { + header, _ := tcp["header"].(map[string]any) + if header != nil { + typeStr, _ := header["type"].(string) + if typeStr != "" && typeStr != "none" { + return false + } + } + } + return true + case "ws": + proxy["network"] = "ws" + ws, _ := stream["wsSettings"].(map[string]any) + wsOpts := map[string]any{} + if ws != nil { + if path, ok := ws["path"].(string); ok && path != "" { + wsOpts["path"] = path + } + host := "" + if v, ok := ws["host"].(string); ok && v != "" { + host = v + } else if headers, ok := ws["headers"].(map[string]any); ok { + host = searchHost(headers) + } + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + } + if len(wsOpts) > 0 { + proxy["ws-opts"] = wsOpts + } + return true + case "grpc": + proxy["network"] = "grpc" + grpc, _ := stream["grpcSettings"].(map[string]any) + grpcOpts := map[string]any{} + if grpc != nil { + if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" { + grpcOpts["grpc-service-name"] = serviceName + } + } + if len(grpcOpts) > 0 { + proxy["grpc-opts"] = grpcOpts + } + return true + default: + return false + } +} + +func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool { + switch security { + case "", "none": + proxy["tls"] = false + return true + case "tls": + proxy["tls"] = true + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + if tlsSettings != nil { + if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" { + proxy["servername"] = serverName + switch proxy["type"] { + case "trojan": + proxy["sni"] = serverName + } + } + if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" { + proxy["client-fingerprint"] = fingerprint + } + } + return true + case "reality": + proxy["tls"] = true + realitySettings, _ := stream["realitySettings"].(map[string]any) + if realitySettings == nil { + return false + } + if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" { + proxy["servername"] = serverName + } + realityOpts := map[string]any{} + if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" { + realityOpts["public-key"] = publicKey + } + if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" { + realityOpts["short-id"] = shortID + } + if len(realityOpts) > 0 { + proxy["reality-opts"] = realityOpts + } + if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" { + proxy["client-fingerprint"] = fingerprint + } + return true + default: + return false + } +} + +func (s *SubClashService) streamData(stream string) map[string]any { + var streamSettings map[string]any + json.Unmarshal([]byte(stream), &streamSettings) + security, _ := streamSettings["security"].(string) + switch security { + case "tls": + if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok { + streamSettings["tlsSettings"] = s.tlsData(tlsSettings) + } + case "reality": + if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok { + streamSettings["realitySettings"] = s.realityData(realitySettings) + } + } + delete(streamSettings, "sockopt") + return streamSettings +} + +func (s *SubClashService) tlsData(tData map[string]any) map[string]any { + tlsData := make(map[string]any, 1) + tlsClientSettings, _ := tData["settings"].(map[string]any) + tlsData["serverName"] = tData["serverName"] + tlsData["alpn"] = tData["alpn"] + if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok { + tlsData["fingerprint"] = fingerprint + } + return tlsData +} + +func (s *SubClashService) realityData(rData map[string]any) map[string]any { + rDataOut := make(map[string]any, 1) + realityClientSettings, _ := rData["settings"].(map[string]any) + if publicKey, ok := realityClientSettings["publicKey"].(string); ok { + rDataOut["publicKey"] = publicKey + } + if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok { + rDataOut["fingerprint"] = fingerprint + } + if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 { + rDataOut["serverName"] = fmt.Sprint(serverNames[0]) + } + if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 { + rDataOut["shortId"] = fmt.Sprint(shortIDs[0]) + } + return rDataOut +} + +func cloneMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/sub/subController.go b/sub/subController.go index 79ea755d..0e9e2c97 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -21,12 +21,15 @@ type SUBController struct { subRoutingRules string subPath string subJsonPath string + subClashPath string jsonEnabled bool + clashEnabled bool subEncrypt bool updateInterval string - subService *SubService - subJsonService *SubJsonService + subService *SubService + subJsonService *SubJsonService + subClashService *SubClashService } // NewSUBController creates a new subscription controller with the given configuration. @@ -34,7 +37,9 @@ func NewSUBController( g *gin.RouterGroup, subPath string, jsonPath string, + clashPath string, jsonEnabled bool, + clashEnabled bool, encrypt bool, showInfo bool, rModel string, @@ -60,12 +65,15 @@ func NewSUBController( subRoutingRules: subRoutingRules, subPath: subPath, subJsonPath: jsonPath, + subClashPath: clashPath, jsonEnabled: jsonEnabled, + clashEnabled: clashEnabled, subEncrypt: encrypt, updateInterval: update, - subService: sub, - subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), + subService: sub, + subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), + subClashService: NewSubClashService(sub), } a.initRouter(g) return a @@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { gJson := g.Group(a.subJsonPath) gJson.GET(":subid", a.subJsons) } + if a.clashEnabled { + gClash := g.Group(a.subClashPath) + gClash.GET(":subid", a.subClashs) + } } // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data. @@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) { accept := c.GetHeader("Accept") if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") { // Build page data in service - subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId) + subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId) if !a.jsonEnabled { subJsonURL = "" } + if !a.clashEnabled { + subClashURL = "" + } // Get base_path from context (set by middleware) basePath, exists := c.Get("base_path") if !exists { @@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) { // Remove trailing slash if exists, add subId, then add trailing slash basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/" } - page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr) + page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr) c.HTML(200, "subpage.html", gin.H{ "title": "subscription.title", "cur_ver": config.GetVersion(), @@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) { "totalByte": page.TotalByte, "subUrl": page.SubUrl, "subJsonUrl": page.SubJsonUrl, + "subClashUrl": page.SubClashUrl, "result": page.Result, }) return @@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) { if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { - // Add headers profileUrl := a.subProfileUrl if profileUrl == "" { profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) @@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) { } } +func (a *SUBController) subClashs(c *gin.Context) { + subId := c.Param("subid") + scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) + clashSub, header, err := a.subClashService.GetClash(subId, host) + if err != nil || len(clashSub) == 0 { + c.String(400, "Error!") + } else { + profileUrl := a.subProfileUrl + if profileUrl == "" { + profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) + } + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules) + c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub)) + } +} + // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. func (a *SUBController) ApplyCommonHeaders( c *gin.Context, diff --git a/sub/subService.go b/sub/subService.go index a0508ddc..b6422dd4 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1031,6 +1031,7 @@ type PageData struct { TotalByte int64 SubUrl string SubJsonUrl string + SubClashUrl string Result []string } @@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, // BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID. // It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components. -func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) { - // Input validation +func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) { if subId == "" { - return "", "" + return "", "", "" } - // Get configured URIs first (highest priority) configuredSubURI, _ := s.settingService.GetSubURI() configuredSubJsonURI, _ := s.settingService.GetSubJsonURI() + configuredSubClashURI, _ := s.settingService.GetSubClashURI() - // Determine base scheme and host (cached to avoid duplicate calls) var baseScheme, baseHostWithPort string - if configuredSubURI == "" || configuredSubJsonURI == "" { + if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" { baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort) } - // Build subscription URL subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId) - - // Build JSON subscription URL subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId) + subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId) - return subURL, subJsonURL + return subURL, subJsonURL, subClashURL } // getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values @@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string { // BuildPageData parses header and prepares the template view model. // BuildPageData constructs page data for rendering the subscription information page. -func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData { +func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData { download := common.FormatTraffic(traffic.Down) upload := common.FormatTraffic(traffic.Up) total := "∞" @@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray TotalByte: traffic.Total, SubUrl: subURL, SubJsonUrl: subJsonURL, + SubClashUrl: subClashURL, Result: subs, } } diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index af80a63e..d61d4b8e 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -38,6 +38,8 @@ class AllSetting { this.subPort = 2096; this.subPath = "/sub/"; this.subJsonPath = "/json/"; + this.subClashEnable = true; + this.subClashPath = "/clash/"; this.subDomain = ""; this.externalTrafficInformEnable = false; this.externalTrafficInformURI = ""; @@ -48,6 +50,7 @@ class AllSetting { this.subShowInfo = true; this.subURI = ""; this.subJsonURI = ""; + this.subClashURI = ""; this.subJsonFragment = ""; this.subJsonNoises = ""; this.subJsonMux = ""; diff --git a/web/assets/js/subscription.js b/web/assets/js/subscription.js index 228dcfa0..d08bfd28 100644 --- a/web/assets/js/subscription.js +++ b/web/assets/js/subscription.js @@ -9,6 +9,7 @@ sId: el.getAttribute('data-sid') || '', subUrl: el.getAttribute('data-sub-url') || '', subJsonUrl: el.getAttribute('data-subjson-url') || '', + subClashUrl: el.getAttribute('data-subclash-url') || '', download: el.getAttribute('data-download') || '', upload: el.getAttribute('data-upload') || '', used: el.getAttribute('data-used') || '', @@ -98,13 +99,19 @@ this.lang = LanguageManager.getLanguage(); const tpl = document.getElementById('subscription-data'); const sj = tpl ? tpl.getAttribute('data-subjson-url') : ''; + const sc = tpl ? tpl.getAttribute('data-subclash-url') : ''; if (sj) this.app.subJsonUrl = sj; + if (sc) this.app.subClashUrl = sc; drawQR(this.app.subUrl); try { const elJson = document.getElementById('qrcode-subjson'); if (elJson && this.app.subJsonUrl) { new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 }); } + const elClash = document.getElementById('qrcode-subclash'); + if (elClash && this.app.subClashUrl) { + new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 }); + } } catch (e) { /* ignore */ } this._onResize = () => { this.viewportWidth = window.innerWidth; }; window.addEventListener('resize', this._onResize); diff --git a/web/entity/entity.go b/web/entity/entity.go index 40294925..14353cf0 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -76,6 +76,9 @@ type AllSetting struct { SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI + SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint + SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint + SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration @@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error { s.SubJsonPath += "/" } + if !strings.HasPrefix(s.SubClashPath, "/") { + s.SubClashPath = "/" + s.SubClashPath + } + if !strings.HasSuffix(s.SubClashPath, "/") { + s.SubClashPath += "/" + } + _, err := time.LoadLocation(s.TimeLocation) if err != nil { return common.NewError("time location not exist:", s.TimeLocation) diff --git a/web/html/settings.html b/web/html/settings.html index 48aad524..441e62de 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -79,10 +79,10 @@ {{ template "settings/panel/subscription/general" . }} - + {{ template "settings/panel/subscription/json" . }} diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html index 5d83aa37..725a9359 100644 --- a/web/html/settings/panel/subscription/general.html +++ b/web/html/settings/panel/subscription/general.html @@ -3,43 +3,58 @@ - + - + + + + + + - + - + - + - +