1
0

controller.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  1. package sub
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "encoding/json"
  6. "fmt"
  7. "html/template"
  8. "io/fs"
  9. "net/http"
  10. "net/url"
  11. "os"
  12. "path/filepath"
  13. "regexp"
  14. "strings"
  15. "sync"
  16. "time"
  17. "unicode"
  18. "github.com/gin-gonic/gin"
  19. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  20. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  21. )
  22. // writeSubError translates a service-layer result into an HTTP response.
  23. // A nil error with no rows means the subId doesn't match anything (deleted
  24. // client, never-existed id) and becomes 404. A real error becomes 500. No
  25. // body — VPN clients only look at the status.
  26. func writeSubError(c *gin.Context, err error) {
  27. if err == nil {
  28. c.Status(http.StatusNotFound)
  29. return
  30. }
  31. c.Status(http.StatusInternalServerError)
  32. }
  33. // cachedSubTemplate holds a parsed custom subscription template together with
  34. // the modification time of the file it was parsed from, so the cache can be
  35. // invalidated when an admin edits the template on disk.
  36. type cachedSubTemplate struct {
  37. tmpl *template.Template
  38. modTime time.Time
  39. }
  40. // SUBController handles HTTP requests for subscription links and JSON configurations.
  41. type SUBController struct {
  42. subTitle string
  43. subSupportUrl string
  44. subProfileUrl string
  45. subAnnounce string
  46. subEnableRouting bool
  47. subRoutingRules string
  48. subHideSettings bool
  49. subIncyEnableRouting bool
  50. subIncyRoutingRules string
  51. subPath string
  52. subJsonPath string
  53. subClashPath string
  54. subClashAutoDetect bool
  55. clashUserAgent *regexp.Regexp
  56. jsonAutoDetect bool
  57. jsonUserAgent *regexp.Regexp
  58. jsonAlwaysArray bool
  59. jsonEnabled bool
  60. clashEnabled bool
  61. subEncrypt bool
  62. updateInterval string
  63. subService *SubService
  64. subJsonService *SubJsonService
  65. subClashService *SubClashService
  66. clientService service.ClientService
  67. settingService service.SettingService
  68. subTemplateMu sync.RWMutex
  69. subTemplateCache map[string]*cachedSubTemplate
  70. }
  71. type subControllerConfig struct {
  72. subPath string
  73. subJsonPath string
  74. subClashPath string
  75. subClashAutoDetect bool
  76. subClashUserAgentRegex string
  77. subJsonAutoDetect bool
  78. subJsonUserAgentRegex string
  79. subJsonAlwaysArray bool
  80. subJsonEnabled bool
  81. subClashEnabled bool
  82. subEncrypt bool
  83. remarkTemplate string
  84. updateInterval string
  85. subJsonMux string
  86. subJsonRules string
  87. subJsonFinalMask string
  88. subClashEnableRouting bool
  89. subClashRules string
  90. subTitle string
  91. subSupportURL string
  92. subProfileURL string
  93. subAnnounce string
  94. subEnableRouting bool
  95. subRoutingRules string
  96. subHideSettings bool
  97. subIncyEnableRouting bool
  98. subIncyRoutingRules string
  99. }
  100. type SUBControllerOption func(*subControllerConfig)
  101. func WithSUBPath(value string) SUBControllerOption {
  102. return func(config *subControllerConfig) { config.subPath = value }
  103. }
  104. func WithSUBJsonPath(value string) SUBControllerOption {
  105. return func(config *subControllerConfig) { config.subJsonPath = value }
  106. }
  107. func WithSUBClashPath(value string) SUBControllerOption {
  108. return func(config *subControllerConfig) { config.subClashPath = value }
  109. }
  110. func WithSUBClashAutoDetect(value bool) SUBControllerOption {
  111. return func(config *subControllerConfig) { config.subClashAutoDetect = value }
  112. }
  113. func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
  114. return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
  115. }
  116. func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
  117. return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
  118. }
  119. func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
  120. return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
  121. }
  122. func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
  123. return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
  124. }
  125. func WithSUBJsonEnabled(value bool) SUBControllerOption {
  126. return func(config *subControllerConfig) { config.subJsonEnabled = value }
  127. }
  128. func WithSUBClashEnabled(value bool) SUBControllerOption {
  129. return func(config *subControllerConfig) { config.subClashEnabled = value }
  130. }
  131. func WithSUBEncryption(value bool) SUBControllerOption {
  132. return func(config *subControllerConfig) { config.subEncrypt = value }
  133. }
  134. func WithSUBRemarkTemplate(value string) SUBControllerOption {
  135. return func(config *subControllerConfig) { config.remarkTemplate = value }
  136. }
  137. func WithSUBUpdateInterval(value string) SUBControllerOption {
  138. return func(config *subControllerConfig) { config.updateInterval = value }
  139. }
  140. func WithSUBJsonMux(value string) SUBControllerOption {
  141. return func(config *subControllerConfig) { config.subJsonMux = value }
  142. }
  143. func WithSUBJsonRules(value string) SUBControllerOption {
  144. return func(config *subControllerConfig) { config.subJsonRules = value }
  145. }
  146. func WithSUBJsonFinalMask(value string) SUBControllerOption {
  147. return func(config *subControllerConfig) { config.subJsonFinalMask = value }
  148. }
  149. func WithSUBClashEnableRouting(value bool) SUBControllerOption {
  150. return func(config *subControllerConfig) { config.subClashEnableRouting = value }
  151. }
  152. func WithSUBClashRules(value string) SUBControllerOption {
  153. return func(config *subControllerConfig) { config.subClashRules = value }
  154. }
  155. func WithSUBTitle(value string) SUBControllerOption {
  156. return func(config *subControllerConfig) { config.subTitle = value }
  157. }
  158. func WithSUBSupportURL(value string) SUBControllerOption {
  159. return func(config *subControllerConfig) { config.subSupportURL = value }
  160. }
  161. func WithSUBProfileURL(value string) SUBControllerOption {
  162. return func(config *subControllerConfig) { config.subProfileURL = value }
  163. }
  164. func WithSUBAnnounce(value string) SUBControllerOption {
  165. return func(config *subControllerConfig) { config.subAnnounce = value }
  166. }
  167. func WithSUBEnableRouting(value bool) SUBControllerOption {
  168. return func(config *subControllerConfig) { config.subEnableRouting = value }
  169. }
  170. func WithSUBRoutingRules(value string) SUBControllerOption {
  171. return func(config *subControllerConfig) { config.subRoutingRules = value }
  172. }
  173. func WithSUBHideSettings(value bool) SUBControllerOption {
  174. return func(config *subControllerConfig) { config.subHideSettings = value }
  175. }
  176. func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
  177. return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
  178. }
  179. func WithSUBIncyRoutingRules(value string) SUBControllerOption {
  180. return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
  181. }
  182. func defaultSUBControllerConfig() subControllerConfig {
  183. return subControllerConfig{
  184. subPath: "/sub/",
  185. subJsonPath: "/json/",
  186. subClashPath: "/clash/",
  187. subEncrypt: true,
  188. remarkTemplate: service.DefaultRemarkTemplate,
  189. updateInterval: "12",
  190. }
  191. }
  192. // NewSUBController creates a new subscription controller with the given configuration.
  193. func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
  194. config := defaultSUBControllerConfig()
  195. for _, option := range options {
  196. option(&config)
  197. }
  198. sub := NewSubService(config.remarkTemplate)
  199. a := &SUBController{
  200. subTitle: config.subTitle,
  201. subSupportUrl: config.subSupportURL,
  202. subProfileUrl: config.subProfileURL,
  203. subAnnounce: config.subAnnounce,
  204. subEnableRouting: config.subEnableRouting,
  205. subRoutingRules: config.subRoutingRules,
  206. subHideSettings: config.subHideSettings,
  207. subIncyEnableRouting: config.subIncyEnableRouting,
  208. subIncyRoutingRules: config.subIncyRoutingRules,
  209. subPath: config.subPath,
  210. subJsonPath: config.subJsonPath,
  211. subClashPath: config.subClashPath,
  212. subClashAutoDetect: config.subClashAutoDetect,
  213. clashUserAgent: compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
  214. jsonAutoDetect: config.subJsonAutoDetect,
  215. jsonUserAgent: compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
  216. jsonAlwaysArray: config.subJsonAlwaysArray,
  217. jsonEnabled: config.subJsonEnabled,
  218. clashEnabled: config.subClashEnabled,
  219. subEncrypt: config.subEncrypt,
  220. updateInterval: config.updateInterval,
  221. subService: sub,
  222. subJsonService: NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
  223. subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
  224. subTemplateCache: map[string]*cachedSubTemplate{},
  225. }
  226. a.initRouter(g)
  227. return a
  228. }
  229. // initRouter registers HTTP routes for subscription links and JSON endpoints
  230. // on the provided router group.
  231. func (a *SUBController) initRouter(g *gin.RouterGroup) {
  232. gLink := g.Group(a.subPath)
  233. gLink.GET(":subid", a.subs)
  234. gLink.HEAD(":subid", a.subs)
  235. if a.jsonEnabled {
  236. gJson := g.Group(a.subJsonPath)
  237. gJson.GET(":subid", a.subJsons)
  238. gJson.HEAD(":subid", a.subJsons)
  239. }
  240. if a.clashEnabled {
  241. gClash := g.Group(a.subClashPath)
  242. gClash.GET(":subid", a.subClashs)
  243. gClash.HEAD(":subid", a.subClashs)
  244. }
  245. }
  246. // maybeServeSubPage renders the HTML info page when the request comes from a
  247. // browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
  248. // It reports whether the request was handled. The remark template's per-client
  249. // info is for the content a client app imports — the raw subscription body. A
  250. // browser viewing the HTML info page gets clean, name-only remarks (usage is
  251. // shown in the page summary).
  252. func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
  253. accept := c.GetHeader("Accept")
  254. wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
  255. if !wantsHTML {
  256. return false
  257. }
  258. page, ok := a.buildSubPageData(c)
  259. if !ok {
  260. return true
  261. }
  262. a.serveSubPage(c, page.BasePath, page)
  263. return true
  264. }
  265. func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool {
  266. if !strings.EqualFold(c.Query("format"), "info") {
  267. return false
  268. }
  269. page, ok := a.buildSubPageData(c)
  270. if !ok {
  271. return true
  272. }
  273. info := a.subPageContext(page)
  274. delete(info, "links")
  275. info["emails"] = dedupeEmails(page.Emails)
  276. setNoCacheHeaders(c)
  277. c.JSON(http.StatusOK, info)
  278. return true
  279. }
  280. func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
  281. subId := c.Param("subid")
  282. _, host, _, hostHeader := a.subService.ResolveRequest(c)
  283. subReq := a.subService.ForRequest(host)
  284. subReq.subscriptionBody = false
  285. subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
  286. if err != nil || len(subs) == 0 {
  287. writeSubError(c, err)
  288. return PageData{}, false
  289. }
  290. subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
  291. if !a.jsonEnabled {
  292. subJsonURL = ""
  293. }
  294. if !a.clashEnabled {
  295. subClashURL = ""
  296. }
  297. basePath, exists := c.Get("base_path")
  298. if !exists {
  299. basePath = "/"
  300. }
  301. basePathStr := basePath.(string)
  302. metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
  303. page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
  304. page.SubAnnounce = metadata.Announce
  305. return page, true
  306. }
  307. func dedupeEmails(emails []string) []string {
  308. out := make([]string, 0, len(emails))
  309. seen := make(map[string]struct{}, len(emails))
  310. for _, email := range emails {
  311. if email == "" {
  312. continue
  313. }
  314. if _, dup := seen[email]; dup {
  315. continue
  316. }
  317. seen[email] = struct{}{}
  318. out = append(out, email)
  319. }
  320. return out
  321. }
  322. // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
  323. func (a *SUBController) subs(c *gin.Context) {
  324. userAgent := c.GetHeader("User-Agent")
  325. if a.maybeServeSubInfo(c) {
  326. logSubscriptionRoute(userAgent, "info")
  327. return
  328. }
  329. if a.maybeServeSubPage(c) {
  330. logSubscriptionRoute(userAgent, "html")
  331. return
  332. }
  333. if !a.enforceHwid(c) {
  334. return
  335. }
  336. if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
  337. a.recordSubscriptionFetch(c)
  338. logSubscriptionRoute(userAgent, "clash")
  339. return
  340. }
  341. if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
  342. a.recordSubscriptionFetch(c)
  343. logSubscriptionRoute(userAgent, "json")
  344. return
  345. }
  346. logSubscriptionRoute(userAgent, "raw")
  347. subId := c.Param("subid")
  348. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  349. subReq := a.subService.ForRequest(host)
  350. subReq.subscriptionBody = true
  351. subs, _, _, traffic, err := subReq.getSubs(subId)
  352. if err != nil || len(subs) == 0 {
  353. writeSubError(c, err)
  354. } else {
  355. var result strings.Builder
  356. for _, sub := range subs {
  357. result.WriteString(sub)
  358. result.WriteString("\n")
  359. }
  360. // Add headers
  361. header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
  362. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  363. metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
  364. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  365. if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
  366. result.WriteString(a.subIncyRoutingRules)
  367. result.WriteString("\n")
  368. }
  369. if a.subEncrypt {
  370. c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
  371. } else {
  372. c.String(200, result.String())
  373. }
  374. a.recordSubscriptionFetch(c)
  375. }
  376. }
  377. func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
  378. if c.Request == nil || c.Request.Method != http.MethodGet || c.Writer.Status() != http.StatusOK {
  379. return
  380. }
  381. if err := a.subService.RecordSubscriptionFetch(c.Param("subid")); err != nil {
  382. logger.Warning("Failed to record subscription fetch:", err)
  383. }
  384. }
  385. func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  386. return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
  387. }
  388. func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  389. return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
  390. }
  391. func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  392. if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
  393. return false
  394. }
  395. return userAgentRegex.MatchString(userAgent)
  396. }
  397. func logSubscriptionRoute(userAgent, branch string) {
  398. logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
  399. }
  400. func sanitizeUserAgentForLog(userAgent string) string {
  401. clean := strings.Map(func(r rune) rune {
  402. if unicode.IsControl(r) {
  403. return ' '
  404. }
  405. return r
  406. }, userAgent)
  407. runes := []rune(clean)
  408. if len(runes) > 512 {
  409. return string(runes[:512])
  410. }
  411. return clean
  412. }
  413. func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
  414. pattern = strings.TrimSpace(pattern)
  415. if pattern == "" {
  416. pattern = strings.TrimSpace(defaultPattern)
  417. }
  418. if pattern == "" {
  419. return nil
  420. }
  421. compiled, err := regexp.Compile(pattern)
  422. if err == nil {
  423. return compiled
  424. }
  425. logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
  426. if strings.TrimSpace(defaultPattern) == "" {
  427. return nil
  428. }
  429. return regexp.MustCompile(defaultPattern)
  430. }
  431. // serveSubPage renders internal/web/dist/subpage.html for the current subscription
  432. // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
  433. // we inject that here, along with window.X_UI_BASE_PATH so the
  434. // page's static asset references resolve correctly when the panel runs
  435. // behind a URL prefix.
  436. func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
  437. var body []byte
  438. if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
  439. body = diskBody
  440. } else {
  441. readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
  442. if err != nil {
  443. c.String(http.StatusInternalServerError, "missing embedded subpage")
  444. return
  445. }
  446. body = readBody
  447. }
  448. // Vite emits absolute asset URLs (`/assets/...`); when the panel is
  449. // installed under a custom URL prefix, rewrite them so the bundle
  450. // loads from `<basePath>assets/...` where the static handler is
  451. // actually mounted.
  452. if basePath != "/" && basePath != "" {
  453. body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
  454. body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
  455. }
  456. subData := a.subPageContext(page)
  457. // When an admin has configured a custom subscription theme, render it
  458. // instead of the default SPA. We render into a buffer first so a template
  459. // that fails mid-execution can't leave a partially-written (corrupt)
  460. // response — on any error we log and fall through to the default page.
  461. if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
  462. if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
  463. logger.Error("sub: custom template parse failed, using default page:", err)
  464. } else if tmpl == nil {
  465. logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
  466. } else {
  467. var buf bytes.Buffer
  468. if execErr := tmpl.Execute(&buf, subData); execErr != nil {
  469. logger.Error("sub: custom template execution failed, using default page:", execErr)
  470. } else {
  471. setNoCacheHeaders(c)
  472. c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
  473. return
  474. }
  475. }
  476. }
  477. subDataJSON, err := json.Marshal(subData)
  478. if err != nil {
  479. subDataJSON = []byte("{}")
  480. }
  481. // Defense-in-depth string-escape for the basePath embed — admin-
  482. // controlled but cheap to harden.
  483. jsEscape := strings.NewReplacer(
  484. `\`, `\\`,
  485. `"`, `\"`,
  486. "\n", `\n`,
  487. "\r", `\r`,
  488. "<", `<`,
  489. ">", `>`,
  490. "&", `&`,
  491. )
  492. escapedBase := jsEscape.Replace(basePath)
  493. inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
  494. `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
  495. out := bytes.Replace(body, []byte("</head>"), inject, 1)
  496. setNoCacheHeaders(c)
  497. c.Data(http.StatusOK, "text/html; charset=utf-8", out)
  498. }
  499. // subPageContext builds the shared view-model map: the template context for
  500. // custom sub themes, the window.__SUB_PAGE_DATA__ payload the SPA reads, and
  501. // (without links) the ?format=info JSON body. The panel's "Calendar Type"
  502. // setting decides whether dates render Gregorian or Jalali — surfaced here so
  503. // consumers match the rest of the panel without a round-trip.
  504. func (a *SUBController) subPageContext(page PageData) map[string]any {
  505. datepicker, _ := a.settingService.GetDatepicker()
  506. if datepicker == "" {
  507. datepicker = "gregorian"
  508. }
  509. return map[string]any{
  510. "sId": page.SId,
  511. "enabled": page.Enabled,
  512. "isOnline": page.IsOnline,
  513. "download": page.Download,
  514. "upload": page.Upload,
  515. "total": page.Total,
  516. "used": page.Used,
  517. "remained": page.Remained,
  518. "expire": page.Expire,
  519. "lastOnline": page.LastOnline,
  520. "downloadByte": page.DownloadByte,
  521. "uploadByte": page.UploadByte,
  522. "totalByte": page.TotalByte,
  523. "subUrl": page.SubUrl,
  524. "subJsonUrl": page.SubJsonUrl,
  525. "subClashUrl": page.SubClashUrl,
  526. "subTitle": page.SubTitle,
  527. "subSupportUrl": page.SubSupportUrl,
  528. "links": page.Result,
  529. "emails": page.Emails,
  530. "datepicker": datepicker,
  531. "announce": page.SubAnnounce,
  532. }
  533. }
  534. func (a *SUBController) enforceHwid(c *gin.Context) bool {
  535. result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
  536. Hwid: c.GetHeader("X-HWID"),
  537. UserAgent: c.GetHeader("User-Agent"),
  538. DeviceOS: c.GetHeader("X-Device-OS"),
  539. OsVersion: c.GetHeader("X-Ver-OS"),
  540. DeviceModel: c.GetHeader("X-Device-Model"),
  541. })
  542. if err != nil {
  543. writeSubError(c, err)
  544. return false
  545. }
  546. applyHwidHeaders(c, result)
  547. if !result.Allowed {
  548. c.Status(http.StatusNotFound)
  549. return false
  550. }
  551. return true
  552. }
  553. func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
  554. if result.Active {
  555. c.Header("X-Hwid-Active", "true")
  556. }
  557. if result.NotSupported {
  558. c.Header("X-Hwid-Not-Supported", "true")
  559. }
  560. if result.LimitReached {
  561. c.Header("X-Hwid-Limit", "true")
  562. }
  563. if result.MaxDevicesReached {
  564. c.Header("X-Hwid-Max-Devices-Reached", "true")
  565. }
  566. }
  567. // setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
  568. // clients and browsers always fetch fresh traffic/expiry data.
  569. func setNoCacheHeaders(c *gin.Context) {
  570. c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
  571. c.Header("Pragma", "no-cache")
  572. c.Header("Expires", "0")
  573. }
  574. // loadSubTemplate returns the parsed custom subscription template located in
  575. // themeDir, preferring sub.html over index.html. Parsed templates are cached and
  576. // only re-parsed when the underlying file's modification time changes, so admin
  577. // edits are picked up without paying a disk read + HTML parse on every request.
  578. //
  579. // It returns (nil, nil) when themeDir is not a usable directory or contains no
  580. // template file — the caller should fall back to the default page. A non-nil
  581. // error means a template file exists but failed to parse.
  582. func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
  583. info, err := os.Stat(themeDir)
  584. if err != nil || !info.IsDir() {
  585. return nil, nil
  586. }
  587. templatePath := filepath.Join(themeDir, "index.html")
  588. if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
  589. templatePath = filepath.Join(themeDir, "sub.html")
  590. }
  591. fi, err := os.Stat(templatePath)
  592. if err != nil {
  593. return nil, nil
  594. }
  595. modTime := fi.ModTime()
  596. a.subTemplateMu.RLock()
  597. cached := a.subTemplateCache[templatePath]
  598. a.subTemplateMu.RUnlock()
  599. if cached != nil && cached.modTime.Equal(modTime) {
  600. return cached.tmpl, nil
  601. }
  602. tmpl, err := template.ParseFiles(templatePath)
  603. if err != nil {
  604. return nil, err
  605. }
  606. a.subTemplateMu.Lock()
  607. a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
  608. a.subTemplateMu.Unlock()
  609. return tmpl, nil
  610. }
  611. // subJsons handles HTTP requests for JSON subscription configurations.
  612. func (a *SUBController) subJsons(c *gin.Context) {
  613. if strings.EqualFold(c.Query("view"), "raw") {
  614. if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
  615. writeSubError(c, nil)
  616. }
  617. a.recordSubscriptionFetch(c)
  618. return
  619. }
  620. if a.maybeServeSubPage(c) {
  621. return
  622. }
  623. if !a.enforceHwid(c) {
  624. return
  625. }
  626. a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
  627. }
  628. func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
  629. if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
  630. writeSubError(c, nil)
  631. }
  632. a.recordSubscriptionFetch(c)
  633. }
  634. func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
  635. subId := c.Param("subid")
  636. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  637. jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
  638. if err != nil {
  639. writeSubError(c, err)
  640. return true
  641. }
  642. if len(jsonSub) == 0 {
  643. return false
  644. }
  645. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  646. var subReq *SubService
  647. metadata := a.metadataForSubRequest(func() *SubService {
  648. if subReq == nil {
  649. subReq = a.subService.ForRequest(host)
  650. }
  651. return subReq
  652. }, subId, profileURL)
  653. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  654. if rawDownload {
  655. c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
  656. }
  657. c.Data(200, contentType, []byte(jsonSub))
  658. return true
  659. }
  660. func (a *SUBController) subClashs(c *gin.Context) {
  661. if strings.EqualFold(c.Query("view"), "raw") {
  662. if !a.serveClashBody(c, true) {
  663. writeSubError(c, nil)
  664. }
  665. a.recordSubscriptionFetch(c)
  666. return
  667. }
  668. if a.maybeServeSubPage(c) {
  669. return
  670. }
  671. if !a.enforceHwid(c) {
  672. return
  673. }
  674. if !a.serveClashBody(c, false) {
  675. writeSubError(c, nil)
  676. }
  677. a.recordSubscriptionFetch(c)
  678. }
  679. func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
  680. subId := c.Param("subid")
  681. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  682. clashSub, header, err := a.subClashService.GetClash(subId, host)
  683. if err != nil {
  684. writeSubError(c, err)
  685. return true
  686. }
  687. if len(clashSub) == 0 {
  688. return false
  689. }
  690. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  691. var subReq *SubService
  692. metadata := a.metadataForSubRequest(func() *SubService {
  693. if subReq == nil {
  694. subReq = a.subService.ForRequest(host)
  695. }
  696. return subReq
  697. }, subId, profileURL)
  698. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  699. if rawDownload {
  700. c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
  701. } else if metadata.Title != "" {
  702. // Clash clients commonly use Content-Disposition to choose the imported profile name.
  703. c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
  704. }
  705. c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
  706. return true
  707. }
  708. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
  709. func (a *SUBController) ApplyCommonHeaders(
  710. c *gin.Context,
  711. header,
  712. updateInterval,
  713. profileTitle string,
  714. profileSupportUrl string,
  715. profileUrl string,
  716. profileAnnounce string,
  717. profileEnableRouting bool,
  718. profileRoutingRules string,
  719. profileHideSettings bool,
  720. ) {
  721. c.Writer.Header().Set("Subscription-Userinfo", header)
  722. c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
  723. // Basics
  724. if profileTitle != "" {
  725. c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
  726. }
  727. if profileSupportUrl != "" {
  728. c.Writer.Header().Set("Support-Url", profileSupportUrl)
  729. }
  730. if profileUrl != "" {
  731. c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
  732. }
  733. if profileAnnounce != "" {
  734. c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
  735. }
  736. // Advanced (Happ)
  737. if profileEnableRouting {
  738. c.Writer.Header().Set("Routing-Enable", "true")
  739. }
  740. if profileRoutingRules != "" {
  741. c.Writer.Header().Set("Routing", profileRoutingRules)
  742. }
  743. if profileHideSettings {
  744. c.Writer.Header().Set("Hide-Settings", "1")
  745. }
  746. }