controller.go 28 KB

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