From 52442cb50c459fd30604403741318d5461bbe2dc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 14 Jul 2026 23:07:15 +0200 Subject: [PATCH] fix(sub): fall back to the raw subscription when an auto-detected format has no content With format auto-detection enabled, a client whose User-Agent matched the Clash or JSON regex was routed straight to that format handler. For a subscription whose entries convert to neither format (an MTProto-only subscription, for example) the handler returns an empty document and the request ended as 404, breaking a URL that served the raw list before the toggle. The auto-detect branches now serve the detected format only when it produces content and otherwise continue to the raw response; the explicit format endpoints keep answering 404 for empty documents. --- internal/sub/controller.go | 68 +++++++++++++++++++++------------ internal/sub/controller_test.go | 48 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/internal/sub/controller.go b/internal/sub/controller.go index c403a1b3e..e66caab84 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -340,14 +340,12 @@ func (a *SUBController) subs(c *gin.Context) { logSubscriptionRoute(userAgent, "html") return } - if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) { + if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) { logSubscriptionRoute(userAgent, "clash") - a.subClashs(c) return } - if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) { + if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") { logSubscriptionRoute(userAgent, "json") - a.serveJson(c, true, "application/json; charset=utf-8") return } logSubscriptionRoute(userAgent, "raw") @@ -605,43 +603,63 @@ func (a *SUBController) subJsons(c *gin.Context) { } func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) { + if !a.serveJsonBody(c, alwaysReturnArray, contentType) { + writeSubError(c, nil) + } +} + +func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool { subId := c.Param("subid") scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray) - if err != nil || len(jsonSub) == 0 { + if err != nil { writeSubError(c, err) - } 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, a.subHideSettings) - - c.Data(200, contentType, []byte(jsonSub)) + return true } + if len(jsonSub) == 0 { + return false + } + 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, a.subHideSettings) + + c.Data(200, contentType, []byte(jsonSub)) + return true } func (a *SUBController) subClashs(c *gin.Context) { if a.maybeServeSubPage(c) { return } + if !a.serveClashBody(c) { + writeSubError(c, nil) + } +} + +func (a *SUBController) serveClashBody(c *gin.Context) bool { 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 { + if err != nil { writeSubError(c, err) - } 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, a.subHideSettings) - if a.subTitle != "" { - // Clash clients commonly use Content-Disposition to choose the imported profile name. - c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle))) - } - c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub)) + return true } + if len(clashSub) == 0 { + return false + } + 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, a.subHideSettings) + if a.subTitle != "" { + // Clash clients commonly use Content-Disposition to choose the imported profile name. + c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle))) + } + c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub)) + return true } // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 14dd294e3..383da0ad7 100644 --- a/internal/sub/controller_test.go +++ b/internal/sub/controller_test.go @@ -3,6 +3,7 @@ package sub import ( "bytes" "encoding/base64" + "fmt" "net/http" "net/http/httptest" "os" @@ -13,6 +14,8 @@ import ( "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) @@ -185,6 +188,51 @@ func TestSanitizeUserAgentForLog(t *testing.T) { } } +func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) { + t.Helper() + db := database.GetDB() + secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d" + email := tag + "@e" + settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret) + ib := &model.Inbound{ + UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port, + Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}", + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed mtproto inbound %s: %v", tag, err) + } + client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true} + if err := db.Create(client).Error; err != nil { + t.Fatalf("seed client %s: %v", email, err) + } + if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed client_inbound %s: %v", email, err) + } +} + +func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) { + seedSubDB(t) + seedSubMtprotoInbound(t, "s1", "tg", 4490) + gin.SetMode(gin.TestMode) + + req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil) + req.Header.Set("User-Agent", "Clash-Verge/v2.4.2") + resp := httptest.NewRecorder() + + newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + decoded, err := base64.StdEncoding.DecodeString(resp.Body.String()) + if err != nil { + t.Fatalf("fallback response is not base64: %v", err) + } + if !strings.Contains(string(decoded), "tg://proxy") { + t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded) + } +} + func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) { seedSubDB(t) seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)