mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it.
This commit is contained in:
@@ -52,6 +52,7 @@ type SUBController struct {
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
happConfig HappConfig
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
@@ -110,6 +111,7 @@ type subControllerConfig struct {
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
happConfig HappConfig
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
@@ -229,6 +231,10 @@ func WithSUBIncyRoutingRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
|
||||
}
|
||||
|
||||
func WithSUBHappConfig(value HappConfig) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.happConfig = value }
|
||||
}
|
||||
|
||||
func defaultSUBControllerConfig() subControllerConfig {
|
||||
return subControllerConfig{
|
||||
subPath: "/sub/",
|
||||
@@ -258,6 +264,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
subEnableRouting: config.subEnableRouting,
|
||||
subRoutingRules: config.subRoutingRules,
|
||||
subHideSettings: config.subHideSettings,
|
||||
happConfig: config.happConfig,
|
||||
|
||||
subIncyEnableRouting: config.subIncyEnableRouting,
|
||||
subIncyRoutingRules: config.subIncyRoutingRules,
|
||||
@@ -845,16 +852,23 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||
}
|
||||
|
||||
// Advanced (Happ). Routing stays independent of the enable flag; remote
|
||||
// values come only from the validated cache and never delay this response.
|
||||
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
|
||||
// The off values undo a previously pushed setting, so they ride the same
|
||||
// opt-in as every other Happ header rather than reaching every Happ client.
|
||||
happManaged := a.happConfig.AutoDetect && c.Request != nil && IsHappClient(c.GetHeader("User-Agent"))
|
||||
if profileEnableRouting {
|
||||
c.Writer.Header().Set("Routing-Enable", "true")
|
||||
} else if happManaged {
|
||||
c.Writer.Header().Set("Routing-Enable", "0")
|
||||
}
|
||||
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
|
||||
c.Writer.Header().Set("Routing", rules)
|
||||
}
|
||||
if profileHideSettings {
|
||||
c.Writer.Header().Set("Hide-Settings", "1")
|
||||
} else if happManaged {
|
||||
c.Writer.Header().Set("Hide-Settings", "0")
|
||||
}
|
||||
|
||||
ApplyHappHeaders(c, a.happConfig, happManaged)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,32 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
|
||||
// link parameter, never a key of the VMess object, so nothing may leak into it.
|
||||
func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
|
||||
s := &SubService{}
|
||||
in := &model.Inbound{Remark: "ib"}
|
||||
baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "none"}
|
||||
host := &model.Host{Address: "a.example.com", Port: 8443, ServerDescription: "Berlin premium"}
|
||||
eps := []ShareEndpoint{externalProxyToEndpoint(hostToExternalProxyMap(host, "a.example.com", 8443))}
|
||||
|
||||
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode vmess link: %v", err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal vmess object: %v", err)
|
||||
}
|
||||
if obj["add"] != "a.example.com" {
|
||||
t.Fatalf("host endpoint not applied: add = %v", obj["add"])
|
||||
}
|
||||
if value, ok := obj["serverDescription"]; ok {
|
||||
t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
|
||||
}
|
||||
}
|
||||
|
||||
// N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
|
||||
func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
|
||||
s := &SubService{}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var happUserAgentRegex = regexp.MustCompile(`(?i)\bhapp\b`)
|
||||
|
||||
// HappConfig holds all Happ client customization parameters.
|
||||
type HappConfig struct {
|
||||
AutoDetect bool
|
||||
ProviderId string
|
||||
NewUrl string
|
||||
FallbackUrl string
|
||||
SubInfoColor string
|
||||
SubInfoText string
|
||||
SubInfoButtonText string
|
||||
SubInfoButtonLink string
|
||||
SubExpire bool
|
||||
SubExpireButtonLink string
|
||||
NotificationExpire bool
|
||||
NoLimit bool
|
||||
AlwaysHwid bool
|
||||
TunMode string
|
||||
TunType string
|
||||
ExcludeRoutes string
|
||||
ExcludeApns bool
|
||||
ColorProfile string
|
||||
PingType string
|
||||
AutoConnect bool
|
||||
AutoConnectType string
|
||||
PerAppMode string
|
||||
PerAppList string
|
||||
}
|
||||
|
||||
// IsHappClient checks if the client user-agent identifies as Happ.
|
||||
func IsHappClient(userAgent string) bool {
|
||||
return happUserAgentRegex.MatchString(userAgent)
|
||||
}
|
||||
|
||||
// ApplyHappHeaders sets standard and advanced Happ subscription headers.
|
||||
func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
|
||||
if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
|
||||
return
|
||||
}
|
||||
if cfg.ProviderId != "" {
|
||||
c.Writer.Header().Set("ProviderID", cfg.ProviderId)
|
||||
}
|
||||
if cfg.NewUrl != "" {
|
||||
c.Writer.Header().Set("New-Url", cfg.NewUrl)
|
||||
}
|
||||
if cfg.FallbackUrl != "" {
|
||||
c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
|
||||
}
|
||||
if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
|
||||
color := strings.TrimSpace(cfg.SubInfoColor)
|
||||
switch strings.ToLower(color) {
|
||||
case "primary", "info":
|
||||
color = "blue"
|
||||
case "success":
|
||||
color = "green"
|
||||
case "warning", "danger":
|
||||
color = "red"
|
||||
case "":
|
||||
color = "blue"
|
||||
}
|
||||
c.Writer.Header().Set("Sub-Info-Color", color)
|
||||
c.Writer.Header().Set("Sub-Info-Text", text)
|
||||
if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
|
||||
c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
|
||||
}
|
||||
if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
|
||||
c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
|
||||
}
|
||||
}
|
||||
if cfg.SubExpire {
|
||||
c.Writer.Header().Set("Sub-Expire", "1")
|
||||
if link := strings.TrimSpace(cfg.SubExpireButtonLink); link != "" {
|
||||
c.Writer.Header().Set("Sub-Expire-Button-Link", link)
|
||||
}
|
||||
}
|
||||
if cfg.NotificationExpire {
|
||||
c.Writer.Header().Set("Notification-Subs-Expire", "1")
|
||||
}
|
||||
if cfg.NoLimit {
|
||||
c.Writer.Header().Set("No-Limit-Enabled", "1")
|
||||
}
|
||||
if cfg.AlwaysHwid {
|
||||
c.Writer.Header().Set("Subscription-Always-Hwid-Enable", "1")
|
||||
}
|
||||
if cfg.TunMode != "" {
|
||||
c.Writer.Header().Set("Tun-Mode", cfg.TunMode)
|
||||
}
|
||||
if cfg.TunType != "" {
|
||||
c.Writer.Header().Set("Tun-Type", cfg.TunType)
|
||||
}
|
||||
if routes := strings.TrimSpace(cfg.ExcludeRoutes); routes != "" {
|
||||
c.Writer.Header().Set("Exclude-Routes", routes)
|
||||
}
|
||||
if cfg.ExcludeApns {
|
||||
c.Writer.Header().Set("Exclude-Apns-Enable", "true")
|
||||
}
|
||||
if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" {
|
||||
profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "")
|
||||
c.Writer.Header().Set("Color-Profile", profile)
|
||||
}
|
||||
if ping := strings.TrimSpace(cfg.PingType); ping != "" {
|
||||
if strings.EqualFold(ping, "http") {
|
||||
ping = "proxy"
|
||||
}
|
||||
c.Writer.Header().Set("Ping-Type", ping)
|
||||
}
|
||||
if cfg.AutoConnect {
|
||||
c.Writer.Header().Set("Subscription-Autoconnect", "1")
|
||||
autoType := strings.TrimSpace(cfg.AutoConnectType)
|
||||
switch strings.ToLower(autoType) {
|
||||
case "fastest":
|
||||
autoType = "lowestdelay"
|
||||
case "last":
|
||||
autoType = "lastused"
|
||||
}
|
||||
if autoType != "" {
|
||||
c.Writer.Header().Set("Subscription-Autoconnect-Type", autoType)
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(cfg.PerAppMode); mode != "" && mode != "off" {
|
||||
switch strings.ToLower(mode) {
|
||||
case "include":
|
||||
mode = "on"
|
||||
case "exclude":
|
||||
mode = "bypass"
|
||||
}
|
||||
c.Writer.Header().Set("Per-App-Proxy-Mode", mode)
|
||||
if list := strings.TrimSpace(cfg.PerAppList); list != "" {
|
||||
c.Writer.Header().Set("Per-App-Proxy-List", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeHappRouting_Off(t *testing.T) {
|
||||
got, err := normalizeHappRouting([]byte("happ://routing/off"))
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeHappRouting(off) error: %v", err)
|
||||
}
|
||||
if got != "happ://routing/off" {
|
||||
t.Fatalf("normalizeHappRouting(off) = %q, want happ://routing/off", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := HappConfig{
|
||||
AutoDetect: true,
|
||||
ProviderId: "pid-test-123",
|
||||
NewUrl: "https://new.example.com/sub",
|
||||
FallbackUrl: "https://backup.example.com/sub",
|
||||
SubInfoColor: "primary",
|
||||
SubInfoText: "Welcome to VIP Network",
|
||||
SubInfoButtonText: "Telegram",
|
||||
SubInfoButtonLink: "https://t.me/example",
|
||||
SubExpire: true,
|
||||
SubExpireButtonLink: "https://renew.example.com",
|
||||
NotificationExpire: true,
|
||||
NoLimit: true,
|
||||
AlwaysHwid: true,
|
||||
TunMode: "gvisor",
|
||||
TunType: "singbox",
|
||||
ExcludeRoutes: "192.168.1.0/24, 10.0.0.0/8",
|
||||
ExcludeApns: true,
|
||||
ColorProfile: "{\"serverRowBackgroundColor\":\n\"#21003D67\"}",
|
||||
PingType: "http",
|
||||
AutoConnect: true,
|
||||
AutoConnectType: "fastest",
|
||||
PerAppMode: "include",
|
||||
PerAppList: "com.google.chrome,com.meta.instagram",
|
||||
}
|
||||
|
||||
controller := &SUBController{happConfig: cfg}
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
|
||||
|
||||
controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
|
||||
|
||||
h := recorder.Header()
|
||||
if h.Get("Routing-Enable") != "0" {
|
||||
t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
|
||||
}
|
||||
if h.Get("Hide-Settings") != "0" {
|
||||
t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
|
||||
}
|
||||
if h.Get("ProviderID") != "pid-test-123" {
|
||||
t.Fatalf("ProviderID = %q, want pid-test-123", h.Get("ProviderID"))
|
||||
}
|
||||
if h.Get("New-Url") != "https://new.example.com/sub" {
|
||||
t.Fatalf("New-Url = %q", h.Get("New-Url"))
|
||||
}
|
||||
if h.Get("Fallback-Url") != "https://backup.example.com/sub" {
|
||||
t.Fatalf("Fallback-Url = %q", h.Get("Fallback-Url"))
|
||||
}
|
||||
if h.Get("Sub-Info-Color") != "blue" || h.Get("Sub-Info-Text") != "Welcome to VIP Network" {
|
||||
t.Fatalf("Sub-Info = %s / %s, want blue / Welcome to VIP Network", h.Get("Sub-Info-Color"), h.Get("Sub-Info-Text"))
|
||||
}
|
||||
if h.Get("Sub-Info-Button-Text") != "Telegram" || h.Get("Sub-Info-Button-Link") != "https://t.me/example" {
|
||||
t.Fatalf("Sub-Info button = %s / %s", h.Get("Sub-Info-Button-Text"), h.Get("Sub-Info-Button-Link"))
|
||||
}
|
||||
if h.Get("Sub-Expire") != "1" || h.Get("Sub-Expire-Button-Link") != "https://renew.example.com" {
|
||||
t.Fatalf("Sub-Expire = %s / %s", h.Get("Sub-Expire"), h.Get("Sub-Expire-Button-Link"))
|
||||
}
|
||||
if h.Get("Notification-Subs-Expire") != "1" {
|
||||
t.Fatalf("Notification-Subs-Expire = %q", h.Get("Notification-Subs-Expire"))
|
||||
}
|
||||
if h.Get("No-Limit-Enabled") != "1" {
|
||||
t.Fatalf("No-Limit-Enabled = %q", h.Get("No-Limit-Enabled"))
|
||||
}
|
||||
if h.Get("Subscription-Always-Hwid-Enable") != "1" {
|
||||
t.Fatalf("Subscription-Always-Hwid-Enable = %q", h.Get("Subscription-Always-Hwid-Enable"))
|
||||
}
|
||||
if h.Get("Tun-Mode") != "gvisor" || h.Get("Tun-Type") != "singbox" {
|
||||
t.Fatalf("Tun mode/type = %s / %s", h.Get("Tun-Mode"), h.Get("Tun-Type"))
|
||||
}
|
||||
if h.Get("Exclude-Routes") != "192.168.1.0/24, 10.0.0.0/8" || h.Get("Exclude-Apns-Enable") != "true" {
|
||||
t.Fatalf("Exclude routes/apns = %s / %s", h.Get("Exclude-Routes"), h.Get("Exclude-Apns-Enable"))
|
||||
}
|
||||
if wantProfile := "{\"serverRowBackgroundColor\":\"#21003D67\"}"; h.Get("Color-Profile") != wantProfile {
|
||||
t.Fatalf("Color-Profile = %q, want %q", h.Get("Color-Profile"), wantProfile)
|
||||
}
|
||||
if h.Get("Ping-Type") != "proxy" {
|
||||
t.Fatalf("Ping-Type = %q, want proxy for http alias", h.Get("Ping-Type"))
|
||||
}
|
||||
if h.Get("Subscription-Autoconnect") != "1" || h.Get("Subscription-Autoconnect-Type") != "lowestdelay" {
|
||||
t.Fatalf("Autoconnect = %s / %s, want 1 / lowestdelay for fastest alias", h.Get("Subscription-Autoconnect"), h.Get("Subscription-Autoconnect-Type"))
|
||||
}
|
||||
if h.Get("Per-App-Proxy-Mode") != "on" || h.Get("Per-App-Proxy-List") != "com.google.chrome,com.meta.instagram" {
|
||||
t.Fatalf("Per-App = %s / %s, want on / com.google.chrome,com.meta.instagram", h.Get("Per-App-Proxy-Mode"), h.Get("Per-App-Proxy-List"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHappHeaders_Gating(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := HappConfig{
|
||||
AutoDetect: true,
|
||||
ProviderId: "pid-secret",
|
||||
SubInfoText: "Banner",
|
||||
TunMode: "system",
|
||||
}
|
||||
|
||||
t.Run("non-Happ User-Agent receives no headers", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||
ctx.Request.Header.Set("User-Agent", "v2rayNG/1.8.5")
|
||||
|
||||
controller := &SUBController{happConfig: cfg}
|
||||
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
|
||||
|
||||
if got := recorder.Header().Get("ProviderID"); got != "" {
|
||||
t.Fatalf("ProviderID emitted to non-Happ client: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
|
||||
t.Fatalf("Sub-Info-Text emitted to non-Happ client: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Tun-Mode"); got != "" {
|
||||
t.Fatalf("Tun-Mode emitted to non-Happ client: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AutoDetect disabled suppresses Happ headers", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (Android)")
|
||||
|
||||
disabledCfg := cfg
|
||||
disabledCfg.AutoDetect = false
|
||||
|
||||
controller := &SUBController{happConfig: disabledCfg}
|
||||
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
|
||||
|
||||
if got := recorder.Header().Get("ProviderID"); got != "" {
|
||||
t.Fatalf("ProviderID emitted when AutoDetect is false: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
|
||||
t.Fatalf("Sub-Info-Text emitted when AutoDetect is false: %q", got)
|
||||
}
|
||||
// happ.su documents routing-enable 0/false as "disables routing
|
||||
// globally", so it must stay behind the same opt-in as the rest.
|
||||
if got := recorder.Header().Get("Routing-Enable"); got != "" {
|
||||
t.Fatalf("Routing-Enable emitted when AutoDetect is false: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Hide-Settings"); got != "" {
|
||||
t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyHappHeaders_Aliases(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg HappConfig
|
||||
wantHeader string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "color warning maps to red",
|
||||
cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "warning"},
|
||||
wantHeader: "Sub-Info-Color",
|
||||
wantValue: "red",
|
||||
},
|
||||
{
|
||||
name: "color danger maps to red",
|
||||
cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "danger"},
|
||||
wantHeader: "Sub-Info-Color",
|
||||
wantValue: "red",
|
||||
},
|
||||
{
|
||||
name: "color success maps to green",
|
||||
cfg: HappConfig{AutoDetect: true, SubInfoText: "Ok", SubInfoColor: "success"},
|
||||
wantHeader: "Sub-Info-Color",
|
||||
wantValue: "green",
|
||||
},
|
||||
{
|
||||
name: "autoconnect last maps to lastused",
|
||||
cfg: HappConfig{AutoDetect: true, AutoConnect: true, AutoConnectType: "last"},
|
||||
wantHeader: "Subscription-Autoconnect-Type",
|
||||
wantValue: "lastused",
|
||||
},
|
||||
{
|
||||
name: "per-app exclude maps to bypass",
|
||||
cfg: HappConfig{AutoDetect: true, PerAppMode: "exclude", PerAppList: "app.id"},
|
||||
wantHeader: "Per-App-Proxy-Mode",
|
||||
wantValue: "bypass",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
|
||||
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iOS)")
|
||||
|
||||
controller := &SUBController{happConfig: tc.cfg}
|
||||
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
|
||||
|
||||
if got := recorder.Header().Get(tc.wantHeader); got != tc.wantValue {
|
||||
t.Fatalf("%s = %q, want %q", tc.wantHeader, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,9 @@ func normalizeHappRouting(body []byte) (string, error) {
|
||||
}
|
||||
return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
|
||||
}
|
||||
if text == "happ://routing/off" {
|
||||
return text, nil
|
||||
}
|
||||
if strings.ContainsAny(text, "\r\n") {
|
||||
return "", errors.New("Happ deeplink must be a single line")
|
||||
}
|
||||
|
||||
@@ -2166,8 +2166,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
|
||||
|
||||
if fragment != "" {
|
||||
sb.WriteByte('#')
|
||||
// Match the frontend's encodeURIComponent(remark): spaces become
|
||||
// %20 (not + as in query strings).
|
||||
// Match the frontend's encodeURIComponent(remark): spaces become %20.
|
||||
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
|
||||
}
|
||||
return sb.String()
|
||||
|
||||
@@ -215,6 +215,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubIncyRoutingRules = ""
|
||||
}
|
||||
|
||||
happCfg := HappConfig{}
|
||||
happCfg.AutoDetect, _ = s.settingService.GetSubHappAutoDetect()
|
||||
happCfg.ProviderId, _ = s.settingService.GetSubHappProviderId()
|
||||
happCfg.NewUrl, _ = s.settingService.GetSubHappNewUrl()
|
||||
happCfg.FallbackUrl, _ = s.settingService.GetSubHappFallbackUrl()
|
||||
happCfg.SubInfoColor, _ = s.settingService.GetSubHappSubInfoColor()
|
||||
happCfg.SubInfoText, _ = s.settingService.GetSubHappSubInfoText()
|
||||
happCfg.SubInfoButtonText, _ = s.settingService.GetSubHappSubInfoButtonText()
|
||||
happCfg.SubInfoButtonLink, _ = s.settingService.GetSubHappSubInfoButtonLink()
|
||||
happCfg.SubExpire, _ = s.settingService.GetSubHappSubExpire()
|
||||
happCfg.SubExpireButtonLink, _ = s.settingService.GetSubHappSubExpireButtonLink()
|
||||
happCfg.NotificationExpire, _ = s.settingService.GetSubHappNotificationExpire()
|
||||
happCfg.NoLimit, _ = s.settingService.GetSubHappNoLimit()
|
||||
happCfg.AlwaysHwid, _ = s.settingService.GetSubHappAlwaysHwid()
|
||||
happCfg.TunMode, _ = s.settingService.GetSubHappTunMode()
|
||||
happCfg.TunType, _ = s.settingService.GetSubHappTunType()
|
||||
happCfg.ExcludeRoutes, _ = s.settingService.GetSubHappExcludeRoutes()
|
||||
happCfg.ExcludeApns, _ = s.settingService.GetSubHappExcludeApns()
|
||||
happCfg.ColorProfile, _ = s.settingService.GetSubHappColorProfile()
|
||||
happCfg.PingType, _ = s.settingService.GetSubHappPingType()
|
||||
happCfg.AutoConnect, _ = s.settingService.GetSubHappAutoConnect()
|
||||
happCfg.AutoConnectType, _ = s.settingService.GetSubHappAutoConnectType()
|
||||
happCfg.PerAppMode, _ = s.settingService.GetSubHappPerAppMode()
|
||||
happCfg.PerAppList, _ = s.settingService.GetSubHappPerAppList()
|
||||
|
||||
// set per-request localizer from headers/cookies
|
||||
engine.Use(locale.LocalizerMiddleware())
|
||||
|
||||
@@ -296,6 +321,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBEnableRouting(SubEnableRouting),
|
||||
WithSUBRoutingRules(SubRoutingRules),
|
||||
WithSUBHideSettings(SubHideSettings),
|
||||
WithSUBHappConfig(happCfg),
|
||||
WithSUBIncyEnableRouting(SubIncyEnableRouting),
|
||||
WithSUBIncyRoutingRules(SubIncyRoutingRules),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user