1
0

controller.go 27 KB

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