1
0

subController.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. package sub
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. webpkg "github.com/mhsanaei/3x-ui/v2/web"
  11. "github.com/mhsanaei/3x-ui/v2/web/service"
  12. "github.com/gin-gonic/gin"
  13. )
  14. // SUBController handles HTTP requests for subscription links and JSON configurations.
  15. type SUBController struct {
  16. subTitle string
  17. subSupportUrl string
  18. subProfileUrl string
  19. subAnnounce string
  20. subEnableRouting bool
  21. subRoutingRules string
  22. subPath string
  23. subJsonPath string
  24. subClashPath string
  25. jsonEnabled bool
  26. clashEnabled bool
  27. subEncrypt bool
  28. updateInterval string
  29. subService *SubService
  30. subJsonService *SubJsonService
  31. subClashService *SubClashService
  32. settingService service.SettingService
  33. }
  34. // NewSUBController creates a new subscription controller with the given configuration.
  35. func NewSUBController(
  36. g *gin.RouterGroup,
  37. subPath string,
  38. jsonPath string,
  39. clashPath string,
  40. jsonEnabled bool,
  41. clashEnabled bool,
  42. encrypt bool,
  43. showInfo bool,
  44. rModel string,
  45. update string,
  46. jsonFragment string,
  47. jsonNoise string,
  48. jsonMux string,
  49. jsonRules string,
  50. subTitle string,
  51. subSupportUrl string,
  52. subProfileUrl string,
  53. subAnnounce string,
  54. subEnableRouting bool,
  55. subRoutingRules string,
  56. ) *SUBController {
  57. sub := NewSubService(showInfo, rModel)
  58. a := &SUBController{
  59. subTitle: subTitle,
  60. subSupportUrl: subSupportUrl,
  61. subProfileUrl: subProfileUrl,
  62. subAnnounce: subAnnounce,
  63. subEnableRouting: subEnableRouting,
  64. subRoutingRules: subRoutingRules,
  65. subPath: subPath,
  66. subJsonPath: jsonPath,
  67. subClashPath: clashPath,
  68. jsonEnabled: jsonEnabled,
  69. clashEnabled: clashEnabled,
  70. subEncrypt: encrypt,
  71. updateInterval: update,
  72. subService: sub,
  73. subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
  74. subClashService: NewSubClashService(sub),
  75. }
  76. a.initRouter(g)
  77. return a
  78. }
  79. // initRouter registers HTTP routes for subscription links and JSON endpoints
  80. // on the provided router group.
  81. func (a *SUBController) initRouter(g *gin.RouterGroup) {
  82. gLink := g.Group(a.subPath)
  83. gLink.GET(":subid", a.subs)
  84. if a.jsonEnabled {
  85. gJson := g.Group(a.subJsonPath)
  86. gJson.GET(":subid", a.subJsons)
  87. }
  88. if a.clashEnabled {
  89. gClash := g.Group(a.subClashPath)
  90. gClash.GET(":subid", a.subClashs)
  91. }
  92. }
  93. // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
  94. func (a *SUBController) subs(c *gin.Context) {
  95. subId := c.Param("subid")
  96. scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
  97. subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
  98. if err != nil || len(subs) == 0 {
  99. c.String(400, "Error!")
  100. } else {
  101. result := ""
  102. for _, sub := range subs {
  103. result += sub + "\n"
  104. }
  105. // If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
  106. accept := c.GetHeader("Accept")
  107. if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
  108. subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
  109. if !a.jsonEnabled {
  110. subJsonURL = ""
  111. }
  112. if !a.clashEnabled {
  113. subClashURL = ""
  114. }
  115. basePath, exists := c.Get("base_path")
  116. if !exists {
  117. basePath = "/"
  118. }
  119. basePathStr := basePath.(string)
  120. page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
  121. a.serveSubPage(c, basePathStr, page)
  122. return
  123. }
  124. // Add headers
  125. header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
  126. profileUrl := a.subProfileUrl
  127. if profileUrl == "" {
  128. profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  129. }
  130. a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
  131. if a.subEncrypt {
  132. c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
  133. } else {
  134. c.String(200, result)
  135. }
  136. }
  137. }
  138. // serveSubPage renders web/dist/subpage.html for the current subscription
  139. // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
  140. // we inject that here, along with window.__X_UI_BASE_PATH__ so the
  141. // page's static asset references resolve correctly when the panel runs
  142. // behind a URL prefix.
  143. func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
  144. dist := webpkg.EmbeddedDist()
  145. body, err := dist.ReadFile("dist/subpage.html")
  146. if err != nil {
  147. c.String(http.StatusInternalServerError, "missing embedded subpage")
  148. return
  149. }
  150. // Vite emits absolute asset URLs (`/assets/...`); when the panel is
  151. // installed under a custom URL prefix, rewrite them so the bundle
  152. // loads from `<basePath>assets/...` where the static handler is
  153. // actually mounted.
  154. if basePath != "/" && basePath != "" {
  155. body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
  156. body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
  157. }
  158. // JSON-marshal the view-model so the SPA can read it as a plain
  159. // object on mount. PageData fields are already in the shape the Vue
  160. // component expects, plus a `links` array carrying the rendered
  161. // share URLs.
  162. // The panel's "Calendar Type" setting decides whether the SubPage
  163. // renders dates in Gregorian or Jalali — surface it here so the SPA
  164. // can match the rest of the panel without a round-trip.
  165. datepicker, _ := a.settingService.GetDatepicker()
  166. if datepicker == "" {
  167. datepicker = "gregorian"
  168. }
  169. subData := map[string]any{
  170. "sId": page.SId,
  171. "enabled": page.Enabled,
  172. "download": page.Download,
  173. "upload": page.Upload,
  174. "total": page.Total,
  175. "used": page.Used,
  176. "remained": page.Remained,
  177. "expire": page.Expire,
  178. "lastOnline": page.LastOnline,
  179. "downloadByte": page.DownloadByte,
  180. "uploadByte": page.UploadByte,
  181. "totalByte": page.TotalByte,
  182. "subUrl": page.SubUrl,
  183. "subJsonUrl": page.SubJsonUrl,
  184. "subClashUrl": page.SubClashUrl,
  185. "links": page.Result,
  186. "datepicker": datepicker,
  187. }
  188. subDataJSON, err := json.Marshal(subData)
  189. if err != nil {
  190. subDataJSON = []byte("{}")
  191. }
  192. // Defense-in-depth string-escape for the basePath embed — admin-
  193. // controlled but cheap to harden.
  194. jsEscape := strings.NewReplacer(
  195. `\`, `\\`,
  196. `"`, `\"`,
  197. "\n", `\n`,
  198. "\r", `\r`,
  199. "<", `<`,
  200. ">", `>`,
  201. "&", `&`,
  202. )
  203. escapedBase := jsEscape.Replace(basePath)
  204. inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
  205. `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
  206. out := bytes.Replace(body, []byte("</head>"), inject, 1)
  207. c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
  208. c.Header("Pragma", "no-cache")
  209. c.Header("Expires", "0")
  210. c.Data(http.StatusOK, "text/html; charset=utf-8", out)
  211. }
  212. // subJsons handles HTTP requests for JSON subscription configurations.
  213. func (a *SUBController) subJsons(c *gin.Context) {
  214. subId := c.Param("subid")
  215. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  216. jsonSub, header, err := a.subJsonService.GetJson(subId, host)
  217. if err != nil || len(jsonSub) == 0 {
  218. c.String(400, "Error!")
  219. } else {
  220. profileUrl := a.subProfileUrl
  221. if profileUrl == "" {
  222. profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  223. }
  224. a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
  225. c.String(200, jsonSub)
  226. }
  227. }
  228. func (a *SUBController) subClashs(c *gin.Context) {
  229. subId := c.Param("subid")
  230. scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
  231. clashSub, header, err := a.subClashService.GetClash(subId, host)
  232. if err != nil || len(clashSub) == 0 {
  233. c.String(400, "Error!")
  234. } else {
  235. profileUrl := a.subProfileUrl
  236. if profileUrl == "" {
  237. profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
  238. }
  239. a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
  240. c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
  241. }
  242. }
  243. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
  244. func (a *SUBController) ApplyCommonHeaders(
  245. c *gin.Context,
  246. header,
  247. updateInterval,
  248. profileTitle string,
  249. profileSupportUrl string,
  250. profileUrl string,
  251. profileAnnounce string,
  252. profileEnableRouting bool,
  253. profileRoutingRules string,
  254. ) {
  255. c.Writer.Header().Set("Subscription-Userinfo", header)
  256. c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
  257. //Basics
  258. if profileTitle != "" {
  259. c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
  260. }
  261. if profileSupportUrl != "" {
  262. c.Writer.Header().Set("Support-Url", profileSupportUrl)
  263. }
  264. if profileUrl != "" {
  265. c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
  266. }
  267. if profileAnnounce != "" {
  268. c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
  269. }
  270. //Advanced (Happ)
  271. c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
  272. if profileRoutingRules != "" {
  273. c.Writer.Header().Set("Routing", profileRoutingRules)
  274. }
  275. }