mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 20:37:18 +00:00
da01b7637d
* feat(sub): add SubBalancer model and migration Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up. * feat(sub): add SubBalancer service List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart. * feat(sub): add SubBalancer API controller and routes GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it. * feat(sub): emit client-side balancers in JSON subscription For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present. * test(sub): cover SubBalancer service and JSON output Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup. * feat(sub): add sub-balancers i18n keys pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales. * feat(sub): add SubBalancer schema and API queries Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key. * feat(sub): add subscription balancers settings tab SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled. * test(sub): add SubBalancer form modal test Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled). * feat(sub): register sub-balancers in API docs and OpenAPI Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it. * docs: sync openapi.json with frontend docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API. * docs: add subscription balancers API reference Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations. * feat(sub): accept roundRobin balancer strategy Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist. * feat(sub): add roundRobin strategy label pages.settings.subBalancers.strategyRoundRobin added to all 13 locales. * feat(sub): expose roundRobin in balancer form Zod strategy enum, form modal label key, and table strategy colour for roundRobin. * docs(sub): list roundRobin in strategy description The create/update strategy param description now mentions roundRobin alongside the other three. * feat(sub): add subJsonObservatory setting Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask. * feat(sub): wire observatory config through sub controller WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction. * feat(sub): emit observatory conditionally with configurable probes burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override. * feat(sub): add subJsonObservatory to AllSetting model Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string. * feat(sub): add balancer observatory config card New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults. * fix(sub): hide save/restart header on sub-balancers tab Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly. * feat(sub): add observatory config i18n keys pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales. * feat(sub): regenerate openapi for subJsonObservatory openapigen picks up the new AllSetting field; openapi.json synced into docs. * feat(sub): add observatory tab to sub-balancers Mirrors the Xray Balancers page: two tabs (Balancers + Observatory). Wires allSetting/updateSetting into the tab and adds tabBalancers / tabObservatory labels to all locales. The page Save header is shown again on this tab so the observatory config can be saved. * refactor(sub): drop observatory tab from sub-formats Now that the observatory config lives under sub-balancers, remove the duplicate tab plus its state and defaults from sub-formats. * fix(sub): add missing inboundsCount i18n key The sub-balancers table rendered the raw key path in the Inbounds column because pages.settings.subBalancers.inboundsCount was not defined. Added it to all 13 locales. * test(sub): pin disabled-inbound exclusion from balancer The balancer builds its members from the subscriber's already-filtered entry set, so an inbound disabled for that user can never surface as a member. Adds tests for both shapes (one of several disabled, and the only selected one disabled). * fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback Three coupled defects on the balancer observatory surface, flagged in PR review: - The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this. - Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value. - routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking. Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it. * fix(sub-balancer): localize controller toasts and reject malformed ids Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success. * fix(sub-balancer): enforce remark length cap server-side The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap. * fix(sub-balancer): exclude mtproto from balancer member picker SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment. * docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope - Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only). - Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX. - Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror. * style(model): trim SubBalancer comment to 2-line cap CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3. * fix(sub-balancer): parse enabled explicitly and preserve it on partial update parseSubBalancerForm treated any non-"false" value as true (so "bogus" silently enabled) and always overwrote Enabled on update, so a PATCH that omitted the toggle reset a disabled balancer back to enabled. Parse the field with strconv.ParseBool and return *bool: absent means "no change" on update and "true" on create; a malformed value is rejected as 400. Update keeps the stored Enabled when the pointer is nil. * fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds DelInbound cascaded hosts but left the deleted inbound id in every sub_balancers.InboundIds, so the balancer kept emitting a member no subscriber could resolve — a dangling outbound tag with no proxy behind it. Strip the id inside the existing delete transaction (same shape as the hosts cascade, #5648); with the last member gone the balancer stops emitting. * fix(sub-balancer): return not-found when deleting a missing balancer Delete returned the gorm result error only, which is nil when no row matched, so the controller reported success:true for an id that never existed — a stale UI row looked like a clean delete. Check RowsAffected and return a not-found error on 0 so the toast reflects reality. * style(sub): shorten leastPing/leastLoad observatory comments The observatory-emission guard comment and its test comment ran a few lines long; trim them to a couple of lines each without dropping the invariant that leastPing/leastLoad require a burst observatory. * fix(sub): validate observatory setting instead of silently dropping it SetObservatoryConfig applied whatever survived json.Unmarshal with no checks, so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even unparseable JSON was either silently applied or silently ignored. Validate each field: parse durations with time.ParseDuration, require http(s) URLs for destination/connectivity, and log a warning naming the field and the bad value on every fallback — including the unmarshal error, which was a quiet return. Bad values now keep the built-in defaults instead of leaking into the emitted burstObservatory. * fix(sub): deduplicate burst-observatory defaults across Go and frontend The burst-observatory ping defaults lived in three places that had drifted: Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity pre-check URL). Align them to one set: https probe destination, sampling 2, and empty connectivity (skip the direct pre-check). The settings tab now parses the stored JSON through PingConfigSchema and seeds its default from DEFAULT_BURST_OBSERVATORY instead of carrying its own literal. * refactor(sub): extract proxy outbounds once before the balancer loop buildBalancerConfig unmarshalled every inbound document and re-extracted its first outbound on each balancer, so with B balancers and N inbound docs the same document was parsed B*N times. Pull each doc's proxy outbound in a single pre-pass over the entries and cache it per entry; buildBalancerConfig now clones the cached map before retagging, so one parse serves every balancer. Output is byte-for-byte unchanged. * fix(sub): form balancer member tags from the inbound protocol, not tcp→vless balancerTransport derived the bal-N tag suffix from the outbound's transport network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was mislabelled "vless" in every client config — the tag lied about the proxy type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless, bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and dedup suffix are unchanged. Update the existing tag assertions and add a vmess case that fails under the old mapping. * fix(sub-balancer): default strategy to random in the create form The create-balancer form seeded strategy to 'leastLoad', but the service validate() defaults an empty strategy to 'random' and the API docs say the default is 'random' — so a freshly opened form showed leastLoad while saving without touching the field silently stored random. Align the form default to 'random' so what the admin sees is what gets persisted. * feat(api-docs): document the SubBalancer response schema The five sub-balancer endpoints carried no responseSchema, so the API docs page rendered them without a typed example. Add example: tags to every SubBalancer field, allow the struct through openapigen, and point the list (responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/. * style(sub-balancer): drop whitespace-only separator lines, add final newline subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as separators between statements and had no trailing newline. Replace them with clean empty blank lines and end each file with a newline. * fix(i18n): translate sub-balancer toasts and observatory note The sub-balancer toast messages (list/create/update/delete/invalidId) and the observatory note were left in English across 11 non-English locales (ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key in the subBalancers block was already translated. Translate them to match the meaning and terminology of the surrounding keys in each file; the JSON structure and keys are unchanged. * fix(sub-balancer): hide disabled inbounds from the member picker The picker offered every protocol-eligible inbound regardless of its enable flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled member is therefore dropped from every subscriber's entries, and when it was the balancer's only member the balancer document silently stops being emitted — with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll MembersDisabled already documents that backend behavior. Filter the way the sibling client picker has since #5645: hide disabled inbounds, but keep one that is already selected so editing an existing balancer cannot silently drop a member. Drop the `?? []` on the useWatch result so the new useMemo dependency stays referentially stable. * style(sub): trim the balancerMemberSuffix comment to the 2-line cap Comment blocks in committed Go are capped at 2 lines; the name already carries what the function picks, so keep only the why. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
854 lines
27 KiB
Go
854 lines
27 KiB
Go
package sub
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// writeSubError translates a service-layer result into an HTTP response.
|
|
// A nil error with no rows means the subId doesn't match anything (deleted
|
|
// client, never-existed id) and becomes 404. A real error becomes 500. No
|
|
// body — VPN clients only look at the status.
|
|
func writeSubError(c *gin.Context, err error) {
|
|
if err == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.Status(http.StatusInternalServerError)
|
|
}
|
|
|
|
// cachedSubTemplate holds a parsed custom subscription template together with
|
|
// the modification time of the file it was parsed from, so the cache can be
|
|
// invalidated when an admin edits the template on disk.
|
|
type cachedSubTemplate struct {
|
|
tmpl *template.Template
|
|
modTime time.Time
|
|
}
|
|
|
|
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
|
type SUBController struct {
|
|
subTitle string
|
|
subSupportUrl string
|
|
subProfileUrl string
|
|
subAnnounce string
|
|
subEnableRouting bool
|
|
subRoutingRules string
|
|
subHideSettings bool
|
|
|
|
subIncyEnableRouting bool
|
|
subIncyRoutingRules string
|
|
|
|
subPath string
|
|
subJsonPath string
|
|
subClashPath string
|
|
subClashAutoDetect bool
|
|
clashUserAgent *regexp.Regexp
|
|
jsonAutoDetect bool
|
|
jsonUserAgent *regexp.Regexp
|
|
jsonAlwaysArray bool
|
|
jsonEnabled bool
|
|
clashEnabled bool
|
|
subEncrypt bool
|
|
updateInterval string
|
|
|
|
subService *SubService
|
|
subJsonService *SubJsonService
|
|
subClashService *SubClashService
|
|
clientService service.ClientService
|
|
settingService service.SettingService
|
|
|
|
subTemplateMu sync.RWMutex
|
|
subTemplateCache map[string]*cachedSubTemplate
|
|
}
|
|
|
|
type subControllerConfig struct {
|
|
subPath string
|
|
subJsonPath string
|
|
subClashPath string
|
|
|
|
subClashAutoDetect bool
|
|
subClashUserAgentRegex string
|
|
subJsonAutoDetect bool
|
|
subJsonUserAgentRegex string
|
|
subJsonAlwaysArray bool
|
|
subJsonEnabled bool
|
|
subClashEnabled bool
|
|
|
|
subEncrypt bool
|
|
remarkTemplate string
|
|
updateInterval string
|
|
|
|
subJsonMux string
|
|
subJsonRules string
|
|
subJsonFinalMask string
|
|
subJsonObservatory string
|
|
subClashEnableRouting bool
|
|
subClashRules string
|
|
|
|
subTitle string
|
|
subSupportURL string
|
|
subProfileURL string
|
|
subAnnounce string
|
|
subEnableRouting bool
|
|
subRoutingRules string
|
|
subHideSettings bool
|
|
|
|
subIncyEnableRouting bool
|
|
subIncyRoutingRules string
|
|
}
|
|
|
|
type SUBControllerOption func(*subControllerConfig)
|
|
|
|
func WithSUBPath(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subPath = value }
|
|
}
|
|
|
|
func WithSUBJsonPath(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonPath = value }
|
|
}
|
|
|
|
func WithSUBClashPath(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashPath = value }
|
|
}
|
|
|
|
func WithSUBClashAutoDetect(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashAutoDetect = value }
|
|
}
|
|
|
|
func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
|
|
}
|
|
|
|
func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
|
|
}
|
|
|
|
func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
|
|
}
|
|
|
|
func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
|
|
}
|
|
|
|
func WithSUBJsonEnabled(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonEnabled = value }
|
|
}
|
|
|
|
func WithSUBClashEnabled(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashEnabled = value }
|
|
}
|
|
|
|
func WithSUBEncryption(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subEncrypt = value }
|
|
}
|
|
|
|
func WithSUBRemarkTemplate(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.remarkTemplate = value }
|
|
}
|
|
|
|
func WithSUBUpdateInterval(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.updateInterval = value }
|
|
}
|
|
|
|
func WithSUBJsonMux(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonMux = value }
|
|
}
|
|
|
|
func WithSUBJsonRules(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonRules = value }
|
|
}
|
|
|
|
func WithSUBJsonFinalMask(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
|
|
}
|
|
|
|
func WithSUBJsonObservatory(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subJsonObservatory = value }
|
|
}
|
|
|
|
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
|
|
}
|
|
|
|
func WithSUBClashRules(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subClashRules = value }
|
|
}
|
|
|
|
func WithSUBTitle(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subTitle = value }
|
|
}
|
|
|
|
func WithSUBSupportURL(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subSupportURL = value }
|
|
}
|
|
|
|
func WithSUBProfileURL(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subProfileURL = value }
|
|
}
|
|
|
|
func WithSUBAnnounce(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subAnnounce = value }
|
|
}
|
|
|
|
func WithSUBEnableRouting(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subEnableRouting = value }
|
|
}
|
|
|
|
func WithSUBRoutingRules(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subRoutingRules = value }
|
|
}
|
|
|
|
func WithSUBHideSettings(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subHideSettings = value }
|
|
}
|
|
|
|
func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
|
|
}
|
|
|
|
func WithSUBIncyRoutingRules(value string) SUBControllerOption {
|
|
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
|
|
}
|
|
|
|
func defaultSUBControllerConfig() subControllerConfig {
|
|
return subControllerConfig{
|
|
subPath: "/sub/",
|
|
subJsonPath: "/json/",
|
|
subClashPath: "/clash/",
|
|
subEncrypt: true,
|
|
remarkTemplate: service.DefaultRemarkTemplate,
|
|
updateInterval: "12",
|
|
}
|
|
}
|
|
|
|
// NewSUBController creates a new subscription controller with the given configuration.
|
|
func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
|
|
config := defaultSUBControllerConfig()
|
|
for _, option := range options {
|
|
option(&config)
|
|
}
|
|
|
|
sub := NewSubService(config.remarkTemplate)
|
|
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
|
|
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
|
a := &SUBController{
|
|
subTitle: config.subTitle,
|
|
subSupportUrl: config.subSupportURL,
|
|
subProfileUrl: config.subProfileURL,
|
|
subAnnounce: config.subAnnounce,
|
|
subEnableRouting: config.subEnableRouting,
|
|
subRoutingRules: config.subRoutingRules,
|
|
subHideSettings: config.subHideSettings,
|
|
|
|
subIncyEnableRouting: config.subIncyEnableRouting,
|
|
subIncyRoutingRules: config.subIncyRoutingRules,
|
|
|
|
subPath: config.subPath,
|
|
subJsonPath: config.subJsonPath,
|
|
subClashPath: config.subClashPath,
|
|
subClashAutoDetect: config.subClashAutoDetect,
|
|
clashUserAgent: compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
|
|
jsonAutoDetect: config.subJsonAutoDetect,
|
|
jsonUserAgent: compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
|
|
jsonAlwaysArray: config.subJsonAlwaysArray,
|
|
jsonEnabled: config.subJsonEnabled,
|
|
clashEnabled: config.subClashEnabled,
|
|
subEncrypt: config.subEncrypt,
|
|
updateInterval: config.updateInterval,
|
|
|
|
subService: sub,
|
|
subJsonService: subJsonSvc,
|
|
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
|
|
|
|
subTemplateCache: map[string]*cachedSubTemplate{},
|
|
}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
// initRouter registers HTTP routes for subscription links and JSON endpoints
|
|
// on the provided router group.
|
|
func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|
gLink := g.Group(a.subPath)
|
|
gLink.GET(":subid", a.subs)
|
|
gLink.HEAD(":subid", a.subs)
|
|
if a.jsonEnabled {
|
|
gJson := g.Group(a.subJsonPath)
|
|
gJson.GET(":subid", a.subJsons)
|
|
gJson.HEAD(":subid", a.subJsons)
|
|
}
|
|
if a.clashEnabled {
|
|
gClash := g.Group(a.subClashPath)
|
|
gClash.GET(":subid", a.subClashs)
|
|
gClash.HEAD(":subid", a.subClashs)
|
|
}
|
|
}
|
|
|
|
// maybeServeSubPage renders the HTML info page when the request comes from a
|
|
// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
|
|
// It reports whether the request was handled. The remark template's per-client
|
|
// info is for the content a client app imports — the raw subscription body. A
|
|
// browser viewing the HTML info page gets clean, name-only remarks (usage is
|
|
// shown in the page summary).
|
|
func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
|
|
accept := c.GetHeader("Accept")
|
|
wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
|
|
if !wantsHTML {
|
|
return false
|
|
}
|
|
page, ok := a.buildSubPageData(c)
|
|
if !ok {
|
|
return true
|
|
}
|
|
a.serveSubPage(c, page.BasePath, page)
|
|
return true
|
|
}
|
|
|
|
func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool {
|
|
if !strings.EqualFold(c.Query("format"), "info") {
|
|
return false
|
|
}
|
|
page, ok := a.buildSubPageData(c)
|
|
if !ok {
|
|
return true
|
|
}
|
|
info := a.subPageContext(page)
|
|
delete(info, "links")
|
|
info["emails"] = dedupeEmails(page.Emails)
|
|
setNoCacheHeaders(c)
|
|
c.JSON(http.StatusOK, info)
|
|
return true
|
|
}
|
|
|
|
func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
|
subId := c.Param("subid")
|
|
_, host, _, hostHeader := a.subService.ResolveRequest(c)
|
|
subReq := a.subService.ForRequest(host)
|
|
subReq.subscriptionBody = false
|
|
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
|
if err != nil || len(subs) == 0 {
|
|
writeSubError(c, err)
|
|
return PageData{}, false
|
|
}
|
|
subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
|
|
if !a.jsonEnabled {
|
|
subJsonURL = ""
|
|
}
|
|
if !a.clashEnabled {
|
|
subClashURL = ""
|
|
}
|
|
basePath, exists := c.Get("base_path")
|
|
if !exists {
|
|
basePath = "/"
|
|
}
|
|
basePathStr := basePath.(string)
|
|
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
|
|
}
|
|
|
|
func dedupeEmails(emails []string) []string {
|
|
out := make([]string, 0, len(emails))
|
|
seen := make(map[string]struct{}, len(emails))
|
|
for _, email := range emails {
|
|
if email == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[email]; dup {
|
|
continue
|
|
}
|
|
seen[email] = struct{}{}
|
|
out = append(out, email)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
|
func (a *SUBController) subs(c *gin.Context) {
|
|
userAgent := c.GetHeader("User-Agent")
|
|
if a.maybeServeSubInfo(c) {
|
|
logSubscriptionRoute(userAgent, "info")
|
|
return
|
|
}
|
|
if a.maybeServeSubPage(c) {
|
|
logSubscriptionRoute(userAgent, "html")
|
|
return
|
|
}
|
|
if !a.enforceHwid(c) {
|
|
return
|
|
}
|
|
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
|
|
a.recordSubscriptionFetch(c)
|
|
logSubscriptionRoute(userAgent, "clash")
|
|
return
|
|
}
|
|
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
|
|
a.recordSubscriptionFetch(c)
|
|
logSubscriptionRoute(userAgent, "json")
|
|
return
|
|
}
|
|
logSubscriptionRoute(userAgent, "raw")
|
|
subId := c.Param("subid")
|
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
|
subReq := a.subService.ForRequest(host)
|
|
subReq.subscriptionBody = true
|
|
subs, _, _, traffic, err := subReq.getSubs(subId)
|
|
if err != nil || len(subs) == 0 {
|
|
writeSubError(c, err)
|
|
} else {
|
|
var result strings.Builder
|
|
for _, sub := range subs {
|
|
result.WriteString(sub)
|
|
result.WriteString("\n")
|
|
}
|
|
|
|
// Add headers
|
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
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 != "" {
|
|
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
|
|
if err == nil && strings.TrimSpace(incyRules) != "" {
|
|
result.WriteString(incyRules)
|
|
result.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if a.subEncrypt {
|
|
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
|
|
} else {
|
|
c.String(200, result.String())
|
|
}
|
|
a.recordSubscriptionFetch(c)
|
|
}
|
|
}
|
|
|
|
func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
|
|
if c.Request == nil || c.Request.Method != http.MethodGet || c.Writer.Status() != http.StatusOK {
|
|
return
|
|
}
|
|
if err := a.subService.RecordSubscriptionFetch(c.Param("subid")); err != nil {
|
|
logger.Warning("Failed to record subscription fetch:", err)
|
|
}
|
|
}
|
|
|
|
func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
|
|
return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
|
|
}
|
|
|
|
func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
|
|
return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
|
|
}
|
|
|
|
func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
|
|
if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
|
|
return false
|
|
}
|
|
return userAgentRegex.MatchString(userAgent)
|
|
}
|
|
|
|
func logSubscriptionRoute(userAgent, branch string) {
|
|
logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
|
|
}
|
|
|
|
func sanitizeUserAgentForLog(userAgent string) string {
|
|
clean := strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) {
|
|
return ' '
|
|
}
|
|
return r
|
|
}, userAgent)
|
|
runes := []rune(clean)
|
|
if len(runes) > 512 {
|
|
return string(runes[:512])
|
|
}
|
|
return clean
|
|
}
|
|
|
|
func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
pattern = strings.TrimSpace(defaultPattern)
|
|
}
|
|
if pattern == "" {
|
|
return nil
|
|
}
|
|
compiled, err := regexp.Compile(pattern)
|
|
if err == nil {
|
|
return compiled
|
|
}
|
|
logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
|
|
if strings.TrimSpace(defaultPattern) == "" {
|
|
return nil
|
|
}
|
|
return regexp.MustCompile(defaultPattern)
|
|
}
|
|
|
|
// serveSubPage renders internal/web/dist/subpage.html for the current subscription
|
|
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
|
// we inject that here, along with window.X_UI_BASE_PATH so the
|
|
// page's static asset references resolve correctly when the panel runs
|
|
// behind a URL prefix.
|
|
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
|
var body []byte
|
|
if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
|
|
body = diskBody
|
|
} else {
|
|
readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
|
return
|
|
}
|
|
body = readBody
|
|
}
|
|
|
|
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
|
// installed under a custom URL prefix, rewrite them so the bundle
|
|
// loads from `<basePath>assets/...` where the static handler is
|
|
// actually mounted.
|
|
if basePath != "/" && basePath != "" {
|
|
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
|
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
|
}
|
|
|
|
subData := a.subPageContext(page)
|
|
|
|
// When an admin has configured a custom subscription theme, render it
|
|
// instead of the default SPA. We render into a buffer first so a template
|
|
// that fails mid-execution can't leave a partially-written (corrupt)
|
|
// response — on any error we log and fall through to the default page.
|
|
if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
|
|
if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
|
|
logger.Error("sub: custom template parse failed, using default page:", err)
|
|
} else if tmpl == nil {
|
|
logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
|
|
} else {
|
|
var buf bytes.Buffer
|
|
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
|
|
logger.Error("sub: custom template execution failed, using default page:", execErr)
|
|
} else {
|
|
setNoCacheHeaders(c)
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
subDataJSON, err := json.Marshal(subData)
|
|
if err != nil {
|
|
subDataJSON = []byte("{}")
|
|
}
|
|
|
|
// Defense-in-depth string-escape for the basePath embed — admin-
|
|
// controlled but cheap to harden.
|
|
jsEscape := strings.NewReplacer(
|
|
`\`, `\\`,
|
|
`"`, `\"`,
|
|
"\n", `\n`,
|
|
"\r", `\r`,
|
|
"<", `<`,
|
|
">", `>`,
|
|
"&", `&`,
|
|
)
|
|
escapedBase := jsEscape.Replace(basePath)
|
|
|
|
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
|
|
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
|
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
|
|
|
setNoCacheHeaders(c)
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
|
}
|
|
|
|
// subPageContext builds the shared view-model map: the template context for
|
|
// custom sub themes, the window.__SUB_PAGE_DATA__ payload the SPA reads, and
|
|
// (without links) the ?format=info JSON body. The panel's "Calendar Type"
|
|
// setting decides whether dates render Gregorian or Jalali — surfaced here so
|
|
// consumers match the rest of the panel without a round-trip.
|
|
func (a *SUBController) subPageContext(page PageData) map[string]any {
|
|
datepicker, _ := a.settingService.GetDatepicker()
|
|
if datepicker == "" {
|
|
datepicker = "gregorian"
|
|
}
|
|
|
|
return map[string]any{
|
|
"sId": page.SId,
|
|
"enabled": page.Enabled,
|
|
"isOnline": page.IsOnline,
|
|
"download": page.Download,
|
|
"upload": page.Upload,
|
|
"total": page.Total,
|
|
"used": page.Used,
|
|
"remained": page.Remained,
|
|
"expire": page.Expire,
|
|
"lastOnline": page.LastOnline,
|
|
"downloadByte": page.DownloadByte,
|
|
"uploadByte": page.UploadByte,
|
|
"totalByte": page.TotalByte,
|
|
"subUrl": page.SubUrl,
|
|
"subJsonUrl": page.SubJsonUrl,
|
|
"subClashUrl": page.SubClashUrl,
|
|
"subTitle": page.SubTitle,
|
|
"subSupportUrl": page.SubSupportUrl,
|
|
"links": page.Result,
|
|
"emails": page.Emails,
|
|
"datepicker": datepicker,
|
|
"announce": page.SubAnnounce,
|
|
}
|
|
}
|
|
|
|
func (a *SUBController) enforceHwid(c *gin.Context) bool {
|
|
result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
|
|
Hwid: c.GetHeader("X-HWID"),
|
|
UserAgent: c.GetHeader("User-Agent"),
|
|
DeviceOS: c.GetHeader("X-Device-OS"),
|
|
OsVersion: c.GetHeader("X-Ver-OS"),
|
|
DeviceModel: c.GetHeader("X-Device-Model"),
|
|
})
|
|
if err != nil {
|
|
writeSubError(c, err)
|
|
return false
|
|
}
|
|
applyHwidHeaders(c, result)
|
|
if !result.Allowed {
|
|
c.Status(http.StatusNotFound)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
|
|
if result.Active {
|
|
c.Header("X-Hwid-Active", "true")
|
|
}
|
|
if result.NotSupported {
|
|
c.Header("X-Hwid-Not-Supported", "true")
|
|
}
|
|
if result.LimitReached {
|
|
c.Header("X-Hwid-Limit", "true")
|
|
}
|
|
if result.MaxDevicesReached {
|
|
c.Header("X-Hwid-Max-Devices-Reached", "true")
|
|
}
|
|
}
|
|
|
|
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
|
|
// clients and browsers always fetch fresh traffic/expiry data.
|
|
func setNoCacheHeaders(c *gin.Context) {
|
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
c.Header("Pragma", "no-cache")
|
|
c.Header("Expires", "0")
|
|
}
|
|
|
|
// loadSubTemplate returns the parsed custom subscription template located in
|
|
// themeDir, preferring sub.html over index.html. Parsed templates are cached and
|
|
// only re-parsed when the underlying file's modification time changes, so admin
|
|
// edits are picked up without paying a disk read + HTML parse on every request.
|
|
//
|
|
// It returns (nil, nil) when themeDir is not a usable directory or contains no
|
|
// template file — the caller should fall back to the default page. A non-nil
|
|
// error means a template file exists but failed to parse.
|
|
func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
|
|
info, err := os.Stat(themeDir)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, nil
|
|
}
|
|
|
|
templatePath := filepath.Join(themeDir, "index.html")
|
|
if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
|
|
templatePath = filepath.Join(themeDir, "sub.html")
|
|
}
|
|
|
|
fi, err := os.Stat(templatePath)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
modTime := fi.ModTime()
|
|
|
|
a.subTemplateMu.RLock()
|
|
cached := a.subTemplateCache[templatePath]
|
|
a.subTemplateMu.RUnlock()
|
|
if cached != nil && cached.modTime.Equal(modTime) {
|
|
return cached.tmpl, nil
|
|
}
|
|
|
|
tmpl, err := template.ParseFiles(templatePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a.subTemplateMu.Lock()
|
|
a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
|
|
a.subTemplateMu.Unlock()
|
|
return tmpl, nil
|
|
}
|
|
|
|
// subJsons handles HTTP requests for JSON subscription configurations.
|
|
func (a *SUBController) subJsons(c *gin.Context) {
|
|
if strings.EqualFold(c.Query("view"), "raw") {
|
|
if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
|
|
writeSubError(c, nil)
|
|
}
|
|
a.recordSubscriptionFetch(c)
|
|
return
|
|
}
|
|
if a.maybeServeSubPage(c) {
|
|
return
|
|
}
|
|
if !a.enforceHwid(c) {
|
|
return
|
|
}
|
|
a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
|
|
}
|
|
|
|
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
|
|
if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
|
|
writeSubError(c, nil)
|
|
}
|
|
a.recordSubscriptionFetch(c)
|
|
}
|
|
|
|
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
|
|
subId := c.Param("subid")
|
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
|
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
|
|
if err != nil {
|
|
writeSubError(c, err)
|
|
return true
|
|
}
|
|
if len(jsonSub) == 0 {
|
|
return false
|
|
}
|
|
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"`)
|
|
}
|
|
|
|
c.Data(200, contentType, []byte(jsonSub))
|
|
return true
|
|
}
|
|
|
|
func (a *SUBController) subClashs(c *gin.Context) {
|
|
if strings.EqualFold(c.Query("view"), "raw") {
|
|
if !a.serveClashBody(c, true) {
|
|
writeSubError(c, nil)
|
|
}
|
|
a.recordSubscriptionFetch(c)
|
|
return
|
|
}
|
|
if a.maybeServeSubPage(c) {
|
|
return
|
|
}
|
|
if !a.enforceHwid(c) {
|
|
return
|
|
}
|
|
if !a.serveClashBody(c, false) {
|
|
writeSubError(c, nil)
|
|
}
|
|
a.recordSubscriptionFetch(c)
|
|
}
|
|
|
|
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
|
subId := c.Param("subid")
|
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
|
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
|
if err != nil {
|
|
writeSubError(c, err)
|
|
return true
|
|
}
|
|
if len(clashSub) == 0 {
|
|
return false
|
|
}
|
|
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 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(metadata.Title)))
|
|
}
|
|
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.
|
|
func (a *SUBController) ApplyCommonHeaders(
|
|
c *gin.Context,
|
|
header,
|
|
updateInterval,
|
|
profileTitle string,
|
|
profileSupportUrl string,
|
|
profileUrl string,
|
|
profileAnnounce string,
|
|
profileEnableRouting bool,
|
|
profileRoutingRules string,
|
|
profileHideSettings bool,
|
|
) {
|
|
c.Writer.Header().Set("Subscription-Userinfo", header)
|
|
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
|
|
|
// Basics
|
|
if profileTitle != "" {
|
|
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
|
}
|
|
if profileSupportUrl != "" {
|
|
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
|
}
|
|
if profileUrl != "" {
|
|
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
|
}
|
|
if profileAnnounce != "" {
|
|
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)
|
|
if profileEnableRouting {
|
|
c.Writer.Header().Set("Routing-Enable", "true")
|
|
}
|
|
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
|
|
c.Writer.Header().Set("Routing", rules)
|
|
}
|
|
if profileHideSettings {
|
|
c.Writer.Header().Set("Hide-Settings", "1")
|
|
}
|
|
}
|