Browse Source

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.
MHSanaei 1 ngày trước cách đây
mục cha
commit
52442cb50c
2 tập tin đã thay đổi với 91 bổ sung25 xóa
  1. 43 25
      internal/sub/controller.go
  2. 48 0
      internal/sub/controller_test.go

+ 43 - 25
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.

+ 48 - 0
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"}`)