controller.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. package sub
  2. import (
  3. "encoding/base64"
  4. "fmt"
  5. stdhtml "html"
  6. "html/template"
  7. "net/http"
  8. "net/url"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "strings"
  13. "sync"
  14. "time"
  15. "unicode"
  16. "github.com/gin-gonic/gin"
  17. "github.com/nicksnyder/go-i18n/v2/i18n"
  18. "golang.org/x/text/language"
  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 validates the subscription and renders a copy-only page.
  247. // The full page embeds share links and must never handle browser navigation.
  248. func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
  249. explicit := explicitSubPageRequest(c)
  250. if !explicit && !a.isBrowserSubscriptionRequest(c) {
  251. return false
  252. }
  253. _, ok := a.buildSubPageData(c)
  254. if !ok {
  255. return true
  256. }
  257. a.serveSubscriptionCopyPage(c)
  258. return true
  259. }
  260. func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool {
  261. if !strings.EqualFold(c.Query("format"), "info") {
  262. return false
  263. }
  264. page, ok := a.buildSubPageData(c)
  265. if !ok {
  266. return true
  267. }
  268. info := a.subPageContext(page)
  269. delete(info, "links")
  270. info["emails"] = dedupeEmails(page.Emails)
  271. setNoCacheHeaders(c)
  272. c.JSON(http.StatusOK, info)
  273. return true
  274. }
  275. func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
  276. subId := c.Param("subid")
  277. _, host, _, hostHeader := a.subService.ResolveRequest(c)
  278. subReq := a.subService.ForRequest(host)
  279. subReq.subscriptionBody = false
  280. subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
  281. if err != nil || len(subs) == 0 {
  282. writeSubError(c, err)
  283. return PageData{}, false
  284. }
  285. subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
  286. if !a.jsonEnabled {
  287. subJsonURL = ""
  288. }
  289. if !a.clashEnabled {
  290. subClashURL = ""
  291. }
  292. basePath, exists := c.Get("base_path")
  293. if !exists {
  294. basePath = "/"
  295. }
  296. basePathStr := basePath.(string)
  297. metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
  298. page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
  299. page.SubAnnounce = metadata.Announce
  300. return page, true
  301. }
  302. func dedupeEmails(emails []string) []string {
  303. out := make([]string, 0, len(emails))
  304. seen := make(map[string]struct{}, len(emails))
  305. for _, email := range emails {
  306. if email == "" {
  307. continue
  308. }
  309. if _, dup := seen[email]; dup {
  310. continue
  311. }
  312. seen[email] = struct{}{}
  313. out = append(out, email)
  314. }
  315. return out
  316. }
  317. // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
  318. func (a *SUBController) subs(c *gin.Context) {
  319. userAgent := c.GetHeader("User-Agent")
  320. if a.maybeServeSubInfo(c) {
  321. logSubscriptionRoute(userAgent, "info")
  322. return
  323. }
  324. if a.maybeServeSubPage(c) {
  325. logSubscriptionRoute(userAgent, "html")
  326. return
  327. }
  328. if !a.enforceHwid(c) {
  329. return
  330. }
  331. if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
  332. a.recordSubscriptionFetch(c)
  333. logSubscriptionRoute(userAgent, "clash")
  334. return
  335. }
  336. if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
  337. a.recordSubscriptionFetch(c)
  338. logSubscriptionRoute(userAgent, "json")
  339. return
  340. }
  341. logSubscriptionRoute(userAgent, "raw")
  342. subId := c.Param("subid")
  343. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  344. subReq := a.subService.ForRequest(host)
  345. subReq.subscriptionBody = true
  346. subs, _, _, traffic, err := subReq.getSubs(subId)
  347. if err != nil || len(subs) == 0 {
  348. writeSubError(c, err)
  349. } else {
  350. var result strings.Builder
  351. for _, sub := range subs {
  352. result.WriteString(sub)
  353. result.WriteString("\n")
  354. }
  355. // Add headers
  356. header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
  357. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  358. metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
  359. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  360. if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
  361. result.WriteString(a.subIncyRoutingRules)
  362. result.WriteString("\n")
  363. }
  364. if a.subEncrypt {
  365. c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
  366. } else {
  367. c.String(200, result.String())
  368. }
  369. a.recordSubscriptionFetch(c)
  370. }
  371. }
  372. func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
  373. if c.Request == nil || c.Request.Method != http.MethodGet || c.Writer.Status() != http.StatusOK {
  374. return
  375. }
  376. if err := a.subService.RecordSubscriptionFetch(c.Param("subid")); err != nil {
  377. logger.Warning("Failed to record subscription fetch:", err)
  378. }
  379. }
  380. func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  381. return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
  382. }
  383. func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  384. return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
  385. }
  386. func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
  387. if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
  388. return false
  389. }
  390. return userAgentRegex.MatchString(userAgent)
  391. }
  392. func logSubscriptionRoute(userAgent, branch string) {
  393. logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
  394. }
  395. func sanitizeUserAgentForLog(userAgent string) string {
  396. clean := strings.Map(func(r rune) rune {
  397. if unicode.IsControl(r) {
  398. return ' '
  399. }
  400. return r
  401. }, userAgent)
  402. runes := []rune(clean)
  403. if len(runes) > 512 {
  404. return string(runes[:512])
  405. }
  406. return clean
  407. }
  408. func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
  409. pattern = strings.TrimSpace(pattern)
  410. if pattern == "" {
  411. pattern = strings.TrimSpace(defaultPattern)
  412. }
  413. if pattern == "" {
  414. return nil
  415. }
  416. compiled, err := regexp.Compile(pattern)
  417. if err == nil {
  418. return compiled
  419. }
  420. logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
  421. if strings.TrimSpace(defaultPattern) == "" {
  422. return nil
  423. }
  424. return regexp.MustCompile(defaultPattern)
  425. }
  426. // explicitSubPageRequest reports whether the caller explicitly asked for HTML.
  427. func explicitSubPageRequest(c *gin.Context) bool {
  428. return c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
  429. }
  430. func (a *SUBController) isBrowserSubscriptionRequest(c *gin.Context) bool {
  431. accept := strings.ToLower(c.GetHeader("Accept"))
  432. if strings.Contains(accept, "text/html") {
  433. return true
  434. }
  435. fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
  436. fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
  437. if fetchDest == "document" || fetchMode == "navigate" {
  438. return true
  439. }
  440. rawUA := c.GetHeader("User-Agent")
  441. ua := strings.ToLower(rawUA)
  442. if rawUA == "" {
  443. return false
  444. }
  445. if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, rawUA, a.clashUserAgent) ||
  446. shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, rawUA, a.jsonUserAgent) {
  447. return false
  448. }
  449. if strings.Contains(ua, "mozilla/") {
  450. vpnClients := []string{
  451. "clash", "mihomo", "sing-box", "v2ray", "xray", "hiddify",
  452. "nekobox", "shadowrocket", "streisand", "v2box", "incy", "happ",
  453. }
  454. for _, client := range vpnClients {
  455. if strings.Contains(ua, client) {
  456. return false
  457. }
  458. }
  459. return true
  460. }
  461. return false
  462. }
  463. func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) {
  464. setNoCacheHeaders(c)
  465. title := localizeRequest(c, "subCopyPageTitle")
  466. heading := localizeRequest(c, "subCopyPageHeading")
  467. instructions := localizeRequest(c, "subCopyPageInstructions")
  468. lang := requestLanguage(c)
  469. page := `<!doctype html>
  470. <html lang="{{LANG}}">
  471. <head>
  472. <meta charset="utf-8">
  473. <meta name="viewport" content="width=device-width, initial-scale=1">
  474. <meta name="robots" content="noindex,nofollow">
  475. <title>{{TITLE}}</title>
  476. <style>
  477. html, body { margin: 0; min-height: 100%; background: #050505; color: #f2f2f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
  478. body { min-height: 100vh; display: flex; align-items: center; justify-content: center; text-align: center; }
  479. main { max-width: 520px; padding: 32px; }
  480. h1 { margin: 0 0 14px; font-size: 24px; font-weight: 650; letter-spacing: -0.02em; }
  481. p { margin: 0; color: #b8b8b8; font-size: 16px; line-height: 1.55; }
  482. </style>
  483. </head>
  484. <body>
  485. <main>
  486. <h1>{{HEADING}}</h1>
  487. <p>{{INSTRUCTIONS}}</p>
  488. </main>
  489. </body>
  490. </html>`
  491. page = strings.NewReplacer(
  492. "{{LANG}}", stdhtml.EscapeString(lang),
  493. "{{TITLE}}", stdhtml.EscapeString(title),
  494. "{{HEADING}}", stdhtml.EscapeString(heading),
  495. "{{INSTRUCTIONS}}", stdhtml.EscapeString(instructions),
  496. ).Replace(page)
  497. c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
  498. }
  499. func localizeRequest(c *gin.Context, key string) string {
  500. if value, ok := c.Get("localizer"); ok {
  501. if localizer, ok := value.(*i18n.Localizer); ok {
  502. if msg, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: key}); err == nil {
  503. return msg
  504. }
  505. }
  506. }
  507. fallbacks := map[string]string{
  508. "subCopyPageTitle": "Subscription link",
  509. "subCopyPageHeading": "This is a subscription link",
  510. "subCopyPageInstructions": "You do not need to open it in a browser. Copy this page address and paste it into the app.",
  511. }
  512. return fallbacks[key]
  513. }
  514. func requestLanguage(c *gin.Context) string {
  515. tag, _, _ := language.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
  516. if len(tag) == 0 {
  517. return "en-US"
  518. }
  519. return tag[0].String()
  520. }
  521. // subPageContext builds the shared view-model map: the template context for
  522. // custom sub themes, the window.__SUB_PAGE_DATA__ payload the SPA reads, and
  523. // (without links) the ?format=info JSON body. The panel's "Calendar Type"
  524. // setting decides whether dates render Gregorian or Jalali — surfaced here so
  525. // consumers match the rest of the panel without a round-trip.
  526. func (a *SUBController) subPageContext(page PageData) map[string]any {
  527. datepicker, _ := a.settingService.GetDatepicker()
  528. if datepicker == "" {
  529. datepicker = "gregorian"
  530. }
  531. return map[string]any{
  532. "sId": page.SId,
  533. "enabled": page.Enabled,
  534. "isOnline": page.IsOnline,
  535. "download": page.Download,
  536. "upload": page.Upload,
  537. "total": page.Total,
  538. "used": page.Used,
  539. "remained": page.Remained,
  540. "expire": page.Expire,
  541. "lastOnline": page.LastOnline,
  542. "downloadByte": page.DownloadByte,
  543. "uploadByte": page.UploadByte,
  544. "totalByte": page.TotalByte,
  545. "subUrl": page.SubUrl,
  546. "subJsonUrl": page.SubJsonUrl,
  547. "subClashUrl": page.SubClashUrl,
  548. "subTitle": page.SubTitle,
  549. "subSupportUrl": page.SubSupportUrl,
  550. "links": page.Result,
  551. "emails": page.Emails,
  552. "datepicker": datepicker,
  553. "announce": page.SubAnnounce,
  554. }
  555. }
  556. func (a *SUBController) enforceHwid(c *gin.Context) bool {
  557. result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
  558. Hwid: c.GetHeader("X-HWID"),
  559. UserAgent: c.GetHeader("User-Agent"),
  560. DeviceOS: c.GetHeader("X-Device-OS"),
  561. OsVersion: c.GetHeader("X-Ver-OS"),
  562. DeviceModel: c.GetHeader("X-Device-Model"),
  563. })
  564. if err != nil {
  565. writeSubError(c, err)
  566. return false
  567. }
  568. applyHwidHeaders(c, result)
  569. if !result.Allowed {
  570. c.Status(http.StatusNotFound)
  571. return false
  572. }
  573. return true
  574. }
  575. func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
  576. if result.Active {
  577. c.Header("X-Hwid-Active", "true")
  578. }
  579. if result.NotSupported {
  580. c.Header("X-Hwid-Not-Supported", "true")
  581. }
  582. if result.LimitReached {
  583. c.Header("X-Hwid-Limit", "true")
  584. }
  585. if result.MaxDevicesReached {
  586. c.Header("X-Hwid-Max-Devices-Reached", "true")
  587. }
  588. }
  589. // setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
  590. // clients and browsers always fetch fresh traffic/expiry data.
  591. func setNoCacheHeaders(c *gin.Context) {
  592. c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
  593. c.Header("Pragma", "no-cache")
  594. c.Header("Expires", "0")
  595. }
  596. // loadSubTemplate returns the parsed custom subscription template located in
  597. // themeDir, preferring sub.html over index.html. Parsed templates are cached and
  598. // only re-parsed when the underlying file's modification time changes, so admin
  599. // edits are picked up without paying a disk read + HTML parse on every request.
  600. //
  601. // It returns (nil, nil) when themeDir is not a usable directory or contains no
  602. // template file — the caller should fall back to the default page. A non-nil
  603. // error means a template file exists but failed to parse.
  604. func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
  605. info, err := os.Stat(themeDir)
  606. if err != nil || !info.IsDir() {
  607. return nil, nil
  608. }
  609. templatePath := filepath.Join(themeDir, "index.html")
  610. if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
  611. templatePath = filepath.Join(themeDir, "sub.html")
  612. }
  613. fi, err := os.Stat(templatePath)
  614. if err != nil {
  615. return nil, nil
  616. }
  617. modTime := fi.ModTime()
  618. a.subTemplateMu.RLock()
  619. cached := a.subTemplateCache[templatePath]
  620. a.subTemplateMu.RUnlock()
  621. if cached != nil && cached.modTime.Equal(modTime) {
  622. return cached.tmpl, nil
  623. }
  624. tmpl, err := template.ParseFiles(templatePath)
  625. if err != nil {
  626. return nil, err
  627. }
  628. a.subTemplateMu.Lock()
  629. a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
  630. a.subTemplateMu.Unlock()
  631. return tmpl, nil
  632. }
  633. // subJsons handles HTTP requests for JSON subscription configurations.
  634. func (a *SUBController) subJsons(c *gin.Context) {
  635. if strings.EqualFold(c.Query("view"), "raw") {
  636. if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
  637. writeSubError(c, nil)
  638. }
  639. a.recordSubscriptionFetch(c)
  640. return
  641. }
  642. if a.maybeServeSubPage(c) {
  643. return
  644. }
  645. if !a.enforceHwid(c) {
  646. return
  647. }
  648. a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
  649. }
  650. func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
  651. if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
  652. writeSubError(c, nil)
  653. }
  654. a.recordSubscriptionFetch(c)
  655. }
  656. func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
  657. subId := c.Param("subid")
  658. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  659. jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
  660. if err != nil {
  661. writeSubError(c, err)
  662. return true
  663. }
  664. if len(jsonSub) == 0 {
  665. return false
  666. }
  667. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  668. var subReq *SubService
  669. metadata := a.metadataForSubRequest(func() *SubService {
  670. if subReq == nil {
  671. subReq = a.subService.ForRequest(host)
  672. }
  673. return subReq
  674. }, subId, profileURL)
  675. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  676. if rawDownload {
  677. c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
  678. }
  679. c.Data(200, contentType, []byte(jsonSub))
  680. return true
  681. }
  682. func (a *SUBController) subClashs(c *gin.Context) {
  683. if strings.EqualFold(c.Query("view"), "raw") {
  684. if !a.serveClashBody(c, true) {
  685. writeSubError(c, nil)
  686. }
  687. a.recordSubscriptionFetch(c)
  688. return
  689. }
  690. if a.maybeServeSubPage(c) {
  691. return
  692. }
  693. if !a.enforceHwid(c) {
  694. return
  695. }
  696. if !a.serveClashBody(c, false) {
  697. writeSubError(c, nil)
  698. }
  699. a.recordSubscriptionFetch(c)
  700. }
  701. func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
  702. subId := c.Param("subid")
  703. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  704. clashSub, header, err := a.subClashService.GetClash(subId, host)
  705. if err != nil {
  706. writeSubError(c, err)
  707. return true
  708. }
  709. if len(clashSub) == 0 {
  710. return false
  711. }
  712. profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  713. var subReq *SubService
  714. metadata := a.metadataForSubRequest(func() *SubService {
  715. if subReq == nil {
  716. subReq = a.subService.ForRequest(host)
  717. }
  718. return subReq
  719. }, subId, profileURL)
  720. a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
  721. if rawDownload {
  722. c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
  723. } else if metadata.Title != "" {
  724. // Clash clients commonly use Content-Disposition to choose the imported profile name.
  725. c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
  726. }
  727. c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
  728. return true
  729. }
  730. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
  731. func (a *SUBController) ApplyCommonHeaders(
  732. c *gin.Context,
  733. header,
  734. updateInterval,
  735. profileTitle string,
  736. profileSupportUrl string,
  737. profileUrl string,
  738. profileAnnounce string,
  739. profileEnableRouting bool,
  740. profileRoutingRules string,
  741. profileHideSettings bool,
  742. ) {
  743. c.Writer.Header().Set("Subscription-Userinfo", header)
  744. c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
  745. // Basics
  746. if profileTitle != "" {
  747. c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
  748. }
  749. if profileSupportUrl != "" {
  750. c.Writer.Header().Set("Support-Url", profileSupportUrl)
  751. }
  752. if profileUrl != "" {
  753. c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
  754. }
  755. if profileAnnounce != "" {
  756. c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
  757. }
  758. // Advanced (Happ)
  759. if profileEnableRouting {
  760. c.Writer.Header().Set("Routing-Enable", "true")
  761. }
  762. if profileRoutingRules != "" {
  763. c.Writer.Header().Set("Routing", profileRoutingRules)
  764. }
  765. if profileHideSettings {
  766. c.Writer.Header().Set("Hide-Settings", "1")
  767. }
  768. }