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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
zhuzn 2026-04-20 04:26:13 +08:00 committed by GitHub
parent 1e3b366fba
commit d580086361
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 596 additions and 61 deletions

2
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
github.com/goccy/go-json v0.10.6 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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // 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/google/btree v1.1.3 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect

View file

@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err 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() subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil { if err != nil {
return nil, err return nil, err
} }
subClashEnable, err := s.settingService.GetSubClashEnable()
if err != nil {
return nil, err
}
// Set base_path based on LinksPath for template rendering // Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation // Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath basePath := LinksPath
@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group("/") g := engine.Group("/")
s.sub = NewSUBController( 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, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)

385
sub/subClashService.go Normal file
View file

@ -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
}

View file

@ -21,12 +21,15 @@ type SUBController struct {
subRoutingRules string subRoutingRules string
subPath string subPath string
subJsonPath string subJsonPath string
subClashPath string
jsonEnabled bool jsonEnabled bool
clashEnabled bool
subEncrypt bool subEncrypt bool
updateInterval string updateInterval string
subService *SubService subService *SubService
subJsonService *SubJsonService subJsonService *SubJsonService
subClashService *SubClashService
} }
// NewSUBController creates a new subscription controller with the given configuration. // NewSUBController creates a new subscription controller with the given configuration.
@ -34,7 +37,9 @@ func NewSUBController(
g *gin.RouterGroup, g *gin.RouterGroup,
subPath string, subPath string,
jsonPath string, jsonPath string,
clashPath string,
jsonEnabled bool, jsonEnabled bool,
clashEnabled bool,
encrypt bool, encrypt bool,
showInfo bool, showInfo bool,
rModel string, rModel string,
@ -60,12 +65,15 @@ func NewSUBController(
subRoutingRules: subRoutingRules, subRoutingRules: subRoutingRules,
subPath: subPath, subPath: subPath,
subJsonPath: jsonPath, subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled, jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt, subEncrypt: encrypt,
updateInterval: update, updateInterval: update,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subClashService: NewSubClashService(sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a
@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
gJson := g.Group(a.subJsonPath) gJson := g.Group(a.subJsonPath)
gJson.GET(":subid", a.subJsons) 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. // 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") accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") { if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
// Build page data in service // 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 { if !a.jsonEnabled {
subJsonURL = "" subJsonURL = ""
} }
if !a.clashEnabled {
subClashURL = ""
}
// Get base_path from context (set by middleware) // Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path") basePath, exists := c.Get("base_path")
if !exists { if !exists {
@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
// Remove trailing slash if exists, add subId, then add trailing slash // Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/" 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{ c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title", "title": "subscription.title",
"cur_ver": config.GetVersion(), "cur_ver": config.GetVersion(),
@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
"totalByte": page.TotalByte, "totalByte": page.TotalByte,
"subUrl": page.SubUrl, "subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl, "subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"result": page.Result, "result": page.Result,
}) })
return return
@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
if err != nil || len(jsonSub) == 0 { if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!") c.String(400, "Error!")
} else { } else {
// Add headers
profileUrl := a.subProfileUrl profileUrl := a.subProfileUrl
if profileUrl == "" { if profileUrl == "" {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) 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. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders( func (a *SUBController) ApplyCommonHeaders(
c *gin.Context, c *gin.Context,

View file

@ -1031,6 +1031,7 @@ type PageData struct {
TotalByte int64 TotalByte int64
SubUrl string SubUrl string
SubJsonUrl string SubJsonUrl string
SubClashUrl string
Result []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. // 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. // 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) { func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
// Input validation
if subId == "" { if subId == "" {
return "", "" return "", "", ""
} }
// Get configured URIs first (highest priority)
configuredSubURI, _ := s.settingService.GetSubURI() configuredSubURI, _ := s.settingService.GetSubURI()
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI() configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
// Determine base scheme and host (cached to avoid duplicate calls)
var baseScheme, baseHostWithPort string var baseScheme, baseHostWithPort string
if configuredSubURI == "" || configuredSubJsonURI == "" { if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort) baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
} }
// Build subscription URL
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId) subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
// Build JSON subscription URL
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId) 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 // 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 parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page. // 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) download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up) upload := common.FormatTraffic(traffic.Up)
total := "∞" total := "∞"
@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
TotalByte: traffic.Total, TotalByte: traffic.Total,
SubUrl: subURL, SubUrl: subURL,
SubJsonUrl: subJsonURL, SubJsonUrl: subJsonURL,
SubClashUrl: subClashURL,
Result: subs, Result: subs,
} }
} }

View file

@ -38,6 +38,8 @@ class AllSetting {
this.subPort = 2096; this.subPort = 2096;
this.subPath = "/sub/"; this.subPath = "/sub/";
this.subJsonPath = "/json/"; this.subJsonPath = "/json/";
this.subClashEnable = true;
this.subClashPath = "/clash/";
this.subDomain = ""; this.subDomain = "";
this.externalTrafficInformEnable = false; this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = ""; this.externalTrafficInformURI = "";
@ -48,6 +50,7 @@ class AllSetting {
this.subShowInfo = true; this.subShowInfo = true;
this.subURI = ""; this.subURI = "";
this.subJsonURI = ""; this.subJsonURI = "";
this.subClashURI = "";
this.subJsonFragment = ""; this.subJsonFragment = "";
this.subJsonNoises = ""; this.subJsonNoises = "";
this.subJsonMux = ""; this.subJsonMux = "";

View file

@ -9,6 +9,7 @@
sId: el.getAttribute('data-sid') || '', sId: el.getAttribute('data-sid') || '',
subUrl: el.getAttribute('data-sub-url') || '', subUrl: el.getAttribute('data-sub-url') || '',
subJsonUrl: el.getAttribute('data-subjson-url') || '', subJsonUrl: el.getAttribute('data-subjson-url') || '',
subClashUrl: el.getAttribute('data-subclash-url') || '',
download: el.getAttribute('data-download') || '', download: el.getAttribute('data-download') || '',
upload: el.getAttribute('data-upload') || '', upload: el.getAttribute('data-upload') || '',
used: el.getAttribute('data-used') || '', used: el.getAttribute('data-used') || '',
@ -98,13 +99,19 @@
this.lang = LanguageManager.getLanguage(); this.lang = LanguageManager.getLanguage();
const tpl = document.getElementById('subscription-data'); const tpl = document.getElementById('subscription-data');
const sj = tpl ? tpl.getAttribute('data-subjson-url') : ''; const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
if (sj) this.app.subJsonUrl = sj; if (sj) this.app.subJsonUrl = sj;
if (sc) this.app.subClashUrl = sc;
drawQR(this.app.subUrl); drawQR(this.app.subUrl);
try { try {
const elJson = document.getElementById('qrcode-subjson'); const elJson = document.getElementById('qrcode-subjson');
if (elJson && this.app.subJsonUrl) { if (elJson && this.app.subJsonUrl) {
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 }); 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 */ } } catch (e) { /* ignore */ }
this._onResize = () => { this.viewportWidth = window.innerWidth; }; this._onResize = () => { this.viewportWidth = window.innerWidth; };
window.addEventListener('resize', this._onResize); window.addEventListener('resize', this._onResize);

View file

@ -76,6 +76,9 @@ type AllSetting struct {
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI 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 SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
s.SubJsonPath += "/" s.SubJsonPath += "/"
} }
if !strings.HasPrefix(s.SubClashPath, "/") {
s.SubClashPath = "/" + s.SubClashPath
}
if !strings.HasSuffix(s.SubClashPath, "/") {
s.SubClashPath += "/"
}
_, err := time.LoadLocation(s.TimeLocation) _, err := time.LoadLocation(s.TimeLocation)
if err != nil { if err != nil {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)

View file

@ -79,10 +79,10 @@
</template> </template>
{{ template "settings/panel/subscription/general" . }} {{ template "settings/panel/subscription/general" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }"> <a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
<template #tab> <template #tab>
<a-icon type="code"></a-icon> <a-icon type="code"></a-icon>
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span> <span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
</template> </template>
{{ template "settings/panel/subscription/json" . }} {{ template "settings/panel/subscription/json" . }}
</a-tab-pane> </a-tab-pane>

View file

@ -3,43 +3,58 @@
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'> <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnable"}}</template> <template #title>{{ i18n "pages.settings.subEnable"}}</template>
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template> <template #description>{{ i18n
"pages.settings.subEnableDesc"}}</template>
<template #control> <template #control>
<a-switch v-model="allSetting.subEnable"></a-switch> <a-switch v-model="allSetting.subEnable"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>JSON Subscription</template> <template #title>JSON Subscription</template>
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template> <template #description>{{ i18n
"pages.settings.subJsonEnable"}}</template>
<template #control> <template #control>
<a-switch v-model="allSetting.subJsonEnable"></a-switch> <a-switch v-model="allSetting.subJsonEnable"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>Clash / Mihomo Subscription</template>
<template #description>Enable direct Clash and Mihomo YAML
subscriptions.</template>
<template #control>
<a-switch v-model="allSetting.subClashEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subListen"}}</template> <template #title>{{ i18n "pages.settings.subListen"}}</template>
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template> <template #description>{{ i18n
"pages.settings.subListenDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subListen"></a-input> <a-input type="text" v-model="allSetting.subListen"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subDomain"}}</template> <template #title>{{ i18n "pages.settings.subDomain"}}</template>
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template> <template #description>{{ i18n
"pages.settings.subDomainDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subDomain"></a-input> <a-input type="text" v-model="allSetting.subDomain"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPort"}}</template> <template #title>{{ i18n "pages.settings.subPort"}}</template>
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template> <template #description>{{ i18n
"pages.settings.subPortDesc"}}</template>
<template #control> <template #control>
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535" <a-input-number v-model="allSetting.subPort" :min="1"
:min="65535"
:style="{ width: '100%' }"></a-input-number> :style="{ width: '100%' }"></a-input-number>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPath"}}</template> <template #title>{{ i18n "pages.settings.subPath"}}</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template> <template #description>{{ i18n
"pages.settings.subPathDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subPath" <a-input type="text" v-model="allSetting.subPath"
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')" @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@ -49,9 +64,11 @@
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subURI"}}</template> <template #title>{{ i18n "pages.settings.subURI"}}</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template> <template #description>{{ i18n
"pages.settings.subURIDesc"}}</template>
<template #control> <template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/" <a-input type="text"
placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subURI"></a-input> v-model="allSetting.subURI"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
@ -59,14 +76,16 @@
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'> <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template> <template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template> <template #description>{{ i18n
"pages.settings.subEncryptDesc"}}</template>
<template #control> <template #control>
<a-switch v-model="allSetting.subEncrypt"></a-switch> <a-switch v-model="allSetting.subEncrypt"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template> <template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template> <template #description>{{ i18n
"pages.settings.subShowInfoDesc"}}</template>
<template #control> <template #control>
<a-switch v-model="allSetting.subShowInfo"></a-switch> <a-switch v-model="allSetting.subShowInfo"></a-switch>
</template> </template>
@ -74,59 +93,72 @@
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider> <a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subTitle"}}</template> <template #title>{{ i18n "pages.settings.subTitle"}}</template>
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template> <template #description>{{ i18n
"pages.settings.subTitleDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subTitle"></a-input> <a-input type="text" v-model="allSetting.subTitle"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template> <template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template> <template #description>{{ i18n
"pages.settings.subSupportUrlDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input> <a-input type="text" v-model="allSetting.subSupportUrl"
placeholder="https://example.com"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template> <template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template> <template #description>{{ i18n
"pages.settings.subProfileUrlDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input> <a-input type="text" v-model="allSetting.subProfileUrl"
placeholder="https://example.com"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template> <template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template> <template #description>{{ i18n
"pages.settings.subAnnounceDesc"}}</template>
<template #control> <template #control>
<a-textarea v-model="allSetting.subAnnounce"></a-textarea> <a-textarea v-model="allSetting.subAnnounce"></a-textarea>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider> <a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template> <template #title>{{ i18n
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template> "pages.settings.subEnableRouting"}}</template>
<template #description>{{ i18n
"pages.settings.subEnableRoutingDesc"}}</template>
<template #control> <template #control>
<a-switch v-model="allSetting.subEnableRouting"></a-switch> <a-switch v-model="allSetting.subEnableRouting"></a-switch>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template> <template #title>{{ i18n
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template> "pages.settings.subRoutingRules"}}</template>
<template #description>{{ i18n
"pages.settings.subRoutingRulesDesc"}}</template>
<template #control> <template #control>
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea> <a-textarea v-model="allSetting.subRoutingRules"
placeholder="happ://routing/add/..."></a-textarea>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'> <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subCertPath"}}</template> <template #title>{{ i18n "pages.settings.subCertPath"}}</template>
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template> <template #description>{{ i18n
"pages.settings.subCertPathDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subCertFile"></a-input> <a-input type="text" v-model="allSetting.subCertFile"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template> <template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template> <template #description>{{ i18n
"pages.settings.subKeyPathDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subKeyFile"></a-input> <a-input type="text" v-model="allSetting.subKeyFile"></a-input>
</template> </template>
@ -135,9 +167,11 @@
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'> <a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subUpdates"}}</template> <template #title>{{ i18n "pages.settings.subUpdates"}}</template>
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template> <template #description>{{ i18n
"pages.settings.subUpdatesDesc"}}</template>
<template #control> <template #control>
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number> <a-input-number :min="1" v-model="allSetting.subUpdates"
:style="{ width: '100%' }"></a-input-number>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>

View file

@ -1,8 +1,8 @@
{{define "settings/panel/subscription/json"}} {{define "settings/panel/subscription/json"}}
<a-collapse default-active-key="1"> <a-collapse default-active-key="1">
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'> <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
<template #title>{{ i18n "pages.settings.subPath"}}</template> <template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template> <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #control> <template #control>
<a-input type="text" v-model="allSetting.subJsonPath" <a-input type="text" v-model="allSetting.subJsonPath"
@ -11,14 +11,32 @@
placeholder="/json/"></a-input> placeholder="/json/"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
<template #title>{{ i18n "pages.settings.subURI"}}</template> <template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template> <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
<template #control> <template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/" <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subJsonURI"></a-input> v-model="allSetting.subJsonURI"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subClashPath"
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
placeholder="/clash/"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
<template #control>
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subClashURI"></a-input>
</template>
</a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'> <a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">

View file

@ -83,7 +83,7 @@
<a-form-item> <a-form-item>
<a-space direction="vertical" align="center"> <a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%"> <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;"> <a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag"> <a-tag color="purple" class="qr-tag">
<span>{{ i18n <span>{{ i18n
@ -112,6 +112,19 @@
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
</a-col> </a-col>
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>Clash / Mihomo</span>
</a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
@click="copy(app.subClashUrl)"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</a-col>
</a-row> </a-row>
</a-space> </a-space>
</a-form-item> </a-form-item>
@ -242,7 +255,7 @@
</a-layout> </a-layout>
<!-- Bootstrap data for external JS --> <!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" <template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}" data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}" data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}" data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"

View file

@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
"subURI": "", "subURI": "",
"subJsonPath": "/json/", "subJsonPath": "/json/",
"subJsonURI": "", "subJsonURI": "",
"subClashEnable": "true",
"subClashPath": "/clash/",
"subClashURI": "",
"subJsonFragment": "", "subJsonFragment": "",
"subJsonNoises": "", "subJsonNoises": "",
"subJsonMux": "", "subJsonMux": "",
@ -555,6 +558,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
return s.getString("subJsonURI") return s.getString("subJsonURI")
} }
func (s *SettingService) GetSubClashEnable() (bool, error) {
return s.getBool("subClashEnable")
}
func (s *SettingService) GetSubClashPath() (string, error) {
return s.getString("subClashPath")
}
func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
func (s *SettingService) GetSubJsonFragment() (string, error) { func (s *SettingService) GetSubJsonFragment() (string, error) {
return s.getString("subJsonFragment") return s.getString("subJsonFragment")
} }
@ -751,9 +766,11 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subEnable": func() (any, error) { return s.GetSubEnable() }, "subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() }, "subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() }, "subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, "subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() }, "remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() }, "datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
subJsonEnable = b subJsonEnable = b
} }
} }
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") { subClashEnable := false
if v, ok := result["subClashEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subClashEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
subURI := "" subURI := ""
subTitle, _ := s.GetSubTitle() subTitle, _ := s.GetSubTitle()
subPort, _ := s.GetSubPort() subPort, _ := s.GetSubPort()
subPath, _ := s.GetSubPath() subPath, _ := s.GetSubPath()
subJsonPath, _ := s.GetSubJsonPath() subJsonPath, _ := s.GetSubJsonPath()
subClashPath, _ := s.GetSubClashPath()
subDomain, _ := s.GetSubDomain() subDomain, _ := s.GetSubDomain()
subKeyFile, _ := s.GetSubKeyFile() subKeyFile, _ := s.GetSubKeyFile()
subCertFile, _ := s.GetSubCertFile() subCertFile, _ := s.GetSubCertFile()
@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
if subJsonEnable && result["subJsonURI"].(string) == "" { if subJsonEnable && result["subJsonURI"].(string) == "" {
result["subJsonURI"] = subURI + subJsonPath result["subJsonURI"] = subURI + subJsonPath
} }
if subClashEnable && result["subClashURI"].(string) == "" {
result["subClashURI"] = subURI + subClashPath
}
} }
return result, nil return result, nil