mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
feat(sub): add template variables to subscription metadata (#6163)
This commit is contained in:
+27
-19
@@ -354,7 +354,9 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
||||
basePath = "/"
|
||||
}
|
||||
basePathStr := basePath.(string)
|
||||
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
|
||||
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
|
||||
page.SubAnnounce = metadata.Announce
|
||||
return page, true
|
||||
}
|
||||
|
||||
@@ -415,11 +417,9 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
|
||||
// Add headers
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
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)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||
result.WriteString(a.subIncyRoutingRules)
|
||||
@@ -605,7 +605,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
||||
"links": page.Result,
|
||||
"emails": page.Emails,
|
||||
"datepicker": datepicker,
|
||||
"announce": a.subAnnounce,
|
||||
"announce": page.SubAnnounce,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,11 +731,15 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
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)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||
}
|
||||
@@ -775,16 +779,20 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
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)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
} else if a.subTitle != "" {
|
||||
} else if metadata.Title != "" {
|
||||
// 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.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
|
||||
}
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
type subPlaceholderData struct {
|
||||
SubID string
|
||||
Context remarkContext
|
||||
HasCtx bool
|
||||
Escape bool
|
||||
}
|
||||
|
||||
type renderedSubMetadata struct {
|
||||
Title string
|
||||
SupportURL string
|
||||
ProfileURL string
|
||||
Announce string
|
||||
}
|
||||
|
||||
func renderSubPlaceholders(value string, data subPlaceholderData) string {
|
||||
if value == "" || !strings.Contains(value, "{") {
|
||||
return value
|
||||
}
|
||||
|
||||
ctx := data.Context
|
||||
if !data.HasCtx {
|
||||
ctx = remarkContext{
|
||||
client: model.Client{
|
||||
SubID: data.SubID,
|
||||
},
|
||||
}
|
||||
}
|
||||
if ctx.client.SubID == "" {
|
||||
ctx.client.SubID = data.SubID
|
||||
}
|
||||
return strings.TrimSpace(expandSubMetadataVars(value, ctx, data.Escape))
|
||||
}
|
||||
|
||||
var subMetadataTokens = map[string]bool{
|
||||
"EMAIL": true,
|
||||
"ID": true,
|
||||
"SHORT_ID": true,
|
||||
"TELEGRAM_ID": true,
|
||||
"SUB_ID": true,
|
||||
}
|
||||
|
||||
func expandSubMetadataVars(template string, ctx remarkContext, escape bool) string {
|
||||
return remarkVarRe.ReplaceAllStringFunc(template, func(match string) string {
|
||||
token := match[2 : len(match)-2]
|
||||
if !subMetadataTokens[token] {
|
||||
return match
|
||||
}
|
||||
value := remarkVarValue(token, ctx)
|
||||
if escape {
|
||||
return url.QueryEscape(value)
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
func subMetadataUsesPlaceholders(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(value, "{") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
|
||||
var context remarkContext
|
||||
var hasContext bool
|
||||
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
|
||||
var err error
|
||||
subReq := getSubReq()
|
||||
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
|
||||
if err != nil {
|
||||
logger.Warning("sub: load template contexts for subscription metadata:", err)
|
||||
}
|
||||
}
|
||||
profileURL := a.subProfileUrl
|
||||
if profileURL == "" {
|
||||
profileURL = fallbackProfileURL
|
||||
} else {
|
||||
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
||||
}
|
||||
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
|
||||
return renderedSubMetadata{
|
||||
Title: renderSubPlaceholders(a.subTitle, data),
|
||||
SupportURL: renderSubPlaceholders(a.subSupportUrl, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true}),
|
||||
ProfileURL: profileURL,
|
||||
Announce: renderSubPlaceholders(a.subAnnounce, data),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubService) subscriptionTemplateContextBySubID(subID string) (remarkContext, bool, error) {
|
||||
if subID == "" {
|
||||
return remarkContext{}, false, nil
|
||||
}
|
||||
var rec model.ClientRecord
|
||||
err := database.GetDB().Where("sub_id = ?", subID).Order("id ASC").First(&rec).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return remarkContext{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return remarkContext{}, false, err
|
||||
}
|
||||
return remarkContext{client: *rec.ToClient()}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestRenderSubPlaceholders(t *testing.T) {
|
||||
data := subPlaceholderData{
|
||||
SubID: "sub-123",
|
||||
Context: remarkContext{client: model.Client{
|
||||
Email: "Ilnur",
|
||||
ID: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
SubID: "sub-123",
|
||||
TgID: 42,
|
||||
Enable: true,
|
||||
}},
|
||||
HasCtx: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
data subPlaceholderData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "identity tokens",
|
||||
tmpl: "{{EMAIL}}/{{ID}}/{{SHORT_ID}}/{{SUB_ID}}/{{TELEGRAM_ID}}",
|
||||
data: data,
|
||||
want: "Ilnur/abcdef12-3456-7890-abcd-ef1234567890/abcdef12/sub-123/42",
|
||||
},
|
||||
{
|
||||
name: "no template",
|
||||
tmpl: "isVPN",
|
||||
data: subPlaceholderData{SubID: "sub-123"},
|
||||
want: "isVPN",
|
||||
},
|
||||
{
|
||||
name: "unsupported tokens stay literal",
|
||||
tmpl: "{{SUB_ID}}/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
|
||||
data: subPlaceholderData{SubID: "sub-123"},
|
||||
want: "sub-123/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
|
||||
},
|
||||
{
|
||||
name: "URL values are escaped",
|
||||
tmpl: "https://support.example/?email={{EMAIL}}&sub={{SUB_ID}}",
|
||||
data: subPlaceholderData{
|
||||
SubID: "sub id",
|
||||
Context: remarkContext{client: model.Client{
|
||||
Email: "john doe@example.com",
|
||||
SubID: "sub id",
|
||||
}},
|
||||
HasCtx: true,
|
||||
Escape: true,
|
||||
},
|
||||
want: "https://support.example/?email=john+doe%40example.com&sub=sub+id",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := renderSubPlaceholders(tt.tmpl, tt.data); got != tt.want {
|
||||
t.Fatalf("renderSubPlaceholders() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN",
|
||||
subSupportUrl: "https://support.example/",
|
||||
}
|
||||
fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
|
||||
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
|
||||
return nil
|
||||
}, "sub-123", fallback)
|
||||
|
||||
if metadata.ProfileURL != fallback {
|
||||
t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
|
||||
initSubDB(t)
|
||||
db := database.GetDB()
|
||||
first := model.ClientRecord{
|
||||
Email: "john doe@example.com",
|
||||
SubID: "sub-123",
|
||||
UUID: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
TgID: 42,
|
||||
Enable: true,
|
||||
}
|
||||
second := model.ClientRecord{
|
||||
Email: "jane@example.com",
|
||||
SubID: "sub-123",
|
||||
UUID: "fedcba98-3456-7890-abcd-ef1234567890",
|
||||
TgID: 99,
|
||||
Enable: true,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("seed first client: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("seed second client: %v", err)
|
||||
}
|
||||
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN — {{EMAIL}}",
|
||||
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
||||
subProfileUrl: "https://profile.example/account/{{ID}}",
|
||||
subAnnounce: "Subscription {{SUB_ID}}",
|
||||
}
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
|
||||
|
||||
if metadata.Title != "isVPN — john doe@example.com" {
|
||||
t.Fatalf("Title = %q", metadata.Title)
|
||||
}
|
||||
if metadata.SupportURL != "https://support.example/?email=john+doe%40example.com&tg=42" {
|
||||
t.Fatalf("SupportURL = %q", metadata.SupportURL)
|
||||
}
|
||||
if metadata.ProfileURL != "https://profile.example/account/abcdef12-3456-7890-abcd-ef1234567890" {
|
||||
t.Fatalf("ProfileURL = %q", metadata.ProfileURL)
|
||||
}
|
||||
if metadata.Announce != "Subscription sub-123" {
|
||||
t.Fatalf("Announce = %q", metadata.Announce)
|
||||
}
|
||||
}
|
||||
@@ -2488,6 +2488,7 @@ type PageData struct {
|
||||
SubClashUrl string
|
||||
SubTitle string
|
||||
SubSupportUrl string
|
||||
SubAnnounce string
|
||||
Result []string
|
||||
Emails []string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user