subController.go 9.6 KB

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