From cd674c8d4f821184e6e5e1257d5843df25d4c4af Mon Sep 17 00:00:00 2001 From: Sanaei Date: Thu, 23 Jul 2026 23:57:29 +0200 Subject: [PATCH] feat(sub): expose live online status and add ?format=info endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom subscription templates only received the lastOnline timestamp, so template authors had to fake an online indicator by comparing it against the current time, and the page was a one-shot server render with no way to refresh usage without reloading the whole HTML. The template context (and window.__SUB_PAGE_DATA__) now carries isOnline, computed from the panel's own online-client tracking (local xray plus remote nodes) at render time. The subscription URL also answers ?format=info with the page view-model as JSON — minus the links, with emails deduplicated — so templates can poll live status cheaply. The shared view-model construction moved into buildSubPageData/subPageContext so the HTML page, the SPA payload and the info JSON cannot drift apart. Also documents the previously injected but undocumented announce template variable. --- docs/custom-subscription-templates.md | 39 ++++++- frontend/public/openapi.json | 11 +- frontend/src/pages/api-docs/endpoints.ts | 3 +- internal/sub/controller.go | 120 ++++++++++++++------- internal/sub/info_endpoint_test.go | 127 +++++++++++++++++++++++ internal/sub/page_data_test.go | 32 ++++++ internal/sub/service.go | 18 ++++ 7 files changed, 312 insertions(+), 38 deletions(-) create mode 100644 internal/sub/info_endpoint_test.go diff --git a/docs/custom-subscription-templates.md b/docs/custom-subscription-templates.md index f18a1922c..8a11e8e5a 100644 --- a/docs/custom-subscription-templates.md +++ b/docs/custom-subscription-templates.md @@ -24,6 +24,7 @@ When rendering the template, the following variables are injected into the templ * `{{ .sId }}`: Subscription ID (UUID). * `{{ .enabled }}`: Whether the subscription/client is enabled (boolean). +* `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time. * `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB"). * `{{ .upload }}`: Formatted upload traffic. * `{{ .total }}`: Formatted total traffic limit. @@ -40,5 +41,41 @@ When rendering the template, the following variables are injected into the templ * `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty. * `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty. * `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`. -* `{{ .emails }}`: A list (slice) of emails related to the subscription. +* `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index *i* owns the link at index *i*. May contain duplicates when one client has several links. +* `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty. * `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali"). + +## Live Status JSON (`?format=info`) + +Every subscription URL also answers `GET ?format=info` with the same view-model as JSON — +minus `links`, and with `emails` deduplicated — so a template can poll it and update usage or +online status live without reloading the page: + +```json +{ + "sId": "…", + "enabled": true, + "isOnline": true, + "used": "1.2 GB", + "remained": "8.8 GB", + "expire": 0, + "lastOnline": 1735680000000, + "…": "…" +} +``` + +Example polling snippet for a template: + +```html + + +``` diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 01dce8c18..38eae4a3d 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -10981,7 +10981,7 @@ "tags": [ "Subscription Server" ], - "summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.", + "summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.", "operationId": "get_subPath_subid", "parameters": [ { @@ -10993,6 +10993,15 @@ "type": "string" } }, + { + "name": "format", + "in": "query", + "required": false, + "description": "Set to \"info\" to get the subscription status view-model as JSON instead of the links.", + "schema": { + "type": "string" + } + }, { "name": "subPath", "in": "path", diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 0709d11af..1d2c3c3b9 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1463,9 +1463,10 @@ export const sections: readonly Section[] = [ { method: 'GET', path: '/{subPath}:subid', - summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.', + summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.', params: [ { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, + { name: 'format', in: 'query', type: 'string', optional: true, desc: 'Set to "info" to get the subscription status view-model as JSON instead of the links.' }, ], }, { diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 5b32dfd12..65931d107 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -307,6 +307,31 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool { if !wantsHTML { return false } + page, ok := a.buildSubPageData(c) + if !ok { + return true + } + a.serveSubPage(c, page.BasePath, page) + return true +} + +func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool { + if !strings.EqualFold(c.Query("format"), "info") { + return false + } + page, ok := a.buildSubPageData(c) + if !ok { + return true + } + info := a.subPageContext(page) + delete(info, "links") + info["emails"] = dedupeEmails(page.Emails) + setNoCacheHeaders(c) + c.JSON(http.StatusOK, info) + return true +} + +func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) { subId := c.Param("subid") _, host, _, hostHeader := a.subService.ResolveRequest(c) subReq := a.subService.ForRequest(host) @@ -314,7 +339,7 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool { subs, emails, lastOnline, traffic, err := subReq.getSubs(subId) if err != nil || len(subs) == 0 { writeSubError(c, err) - return true + return PageData{}, false } subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId) if !a.jsonEnabled { @@ -329,13 +354,32 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool { } basePathStr := basePath.(string) page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl) - a.serveSubPage(c, basePathStr, page) - return true + return page, true +} + +func dedupeEmails(emails []string) []string { + out := make([]string, 0, len(emails)) + seen := make(map[string]struct{}, len(emails)) + for _, email := range emails { + if email == "" { + continue + } + if _, dup := seen[email]; dup { + continue + } + seen[email] = struct{}{} + out = append(out, email) + } + return out } // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data. func (a *SUBController) subs(c *gin.Context) { userAgent := c.GetHeader("User-Agent") + if a.maybeServeSubInfo(c) { + logSubscriptionRoute(userAgent, "info") + return + } if a.maybeServeSubPage(c) { logSubscriptionRoute(userAgent, "html") return @@ -463,38 +507,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`)) } - // JSON-marshal the view-model so the SPA can read it as a plain - // The panel's "Calendar Type" setting decides whether the SubPage - // renders dates in Gregorian or Jalali — surface it here so the SPA - // can match the rest of the panel without a round-trip. - datepicker, _ := a.settingService.GetDatepicker() - if datepicker == "" { - datepicker = "gregorian" - } - - subData := map[string]any{ - "sId": page.SId, - "enabled": page.Enabled, - "download": page.Download, - "upload": page.Upload, - "total": page.Total, - "used": page.Used, - "remained": page.Remained, - "expire": page.Expire, - "lastOnline": page.LastOnline, - "downloadByte": page.DownloadByte, - "uploadByte": page.UploadByte, - "totalByte": page.TotalByte, - "subUrl": page.SubUrl, - "subJsonUrl": page.SubJsonUrl, - "subClashUrl": page.SubClashUrl, - "subTitle": page.SubTitle, - "subSupportUrl": page.SubSupportUrl, - "links": page.Result, - "emails": page.Emails, - "datepicker": datepicker, - "announce": a.subAnnounce, - } + subData := a.subPageContext(page) // When an admin has configured a custom subscription theme, render it // instead of the default SPA. We render into a buffer first so a template @@ -543,6 +556,43 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD c.Data(http.StatusOK, "text/html; charset=utf-8", out) } +// subPageContext builds the shared view-model map: the template context for +// custom sub themes, the window.__SUB_PAGE_DATA__ payload the SPA reads, and +// (without links) the ?format=info JSON body. The panel's "Calendar Type" +// setting decides whether dates render Gregorian or Jalali — surfaced here so +// consumers match the rest of the panel without a round-trip. +func (a *SUBController) subPageContext(page PageData) map[string]any { + datepicker, _ := a.settingService.GetDatepicker() + if datepicker == "" { + datepicker = "gregorian" + } + + return map[string]any{ + "sId": page.SId, + "enabled": page.Enabled, + "isOnline": page.IsOnline, + "download": page.Download, + "upload": page.Upload, + "total": page.Total, + "used": page.Used, + "remained": page.Remained, + "expire": page.Expire, + "lastOnline": page.LastOnline, + "downloadByte": page.DownloadByte, + "uploadByte": page.UploadByte, + "totalByte": page.TotalByte, + "subUrl": page.SubUrl, + "subJsonUrl": page.SubJsonUrl, + "subClashUrl": page.SubClashUrl, + "subTitle": page.SubTitle, + "subSupportUrl": page.SubSupportUrl, + "links": page.Result, + "emails": page.Emails, + "datepicker": datepicker, + "announce": a.subAnnounce, + } +} + // setNoCacheHeaders marks a subscription page response as non-cacheable so VPN // clients and browsers always fetch fresh traffic/expiry data. func setNoCacheHeaders(c *gin.Context) { diff --git a/internal/sub/info_endpoint_test.go b/internal/sub/info_endpoint_test.go new file mode 100644 index 000000000..a8b0ec96d --- /dev/null +++ b/internal/sub/info_endpoint_test.go @@ -0,0 +1,127 @@ +package sub + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func seedInfoEndpointSub(t *testing.T, subId, email string) { + t.Helper() + db := database.GetDB() + rec := &model.ClientRecord{Email: email, SubID: subId, UUID: "info-uuid", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + link := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#orig" + if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, Remark: "DE-Provider", SortIndex: 1}).Error; err != nil { + t.Fatalf("seed external link: %v", err) + } +} + +func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) { + gin.SetMode(gin.TestMode) + initSubDB(t) + seedInfoEndpointSub(t, "info-sub", "info@x") + + router := gin.New() + NewSUBController(router.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/sub/info-sub?format=info", nil) + req.Host = "sub.example.com" + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Fatalf("Cache-Control = %q, want a no-store directive", cc) + } + + var info map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatalf("body is not valid JSON: %v; body=%s", err, w.Body.String()) + } + if info["sId"] != "info-sub" { + t.Fatalf("sId = %v, want %q", info["sId"], "info-sub") + } + if _, hasLinks := info["links"]; hasLinks { + t.Fatal("info payload must not include the links list") + } + if isOnline, ok := info["isOnline"].(bool); !ok || isOnline { + t.Fatalf("isOnline = %v, want false with no live xray", info["isOnline"]) + } + emails, ok := info["emails"].([]any) + if !ok || len(emails) != 1 || emails[0] != "info@x" { + t.Fatalf("emails = %v, want [info@x]", info["emails"]) + } + subUrl, _ := info["subUrl"].(string) + if !strings.HasSuffix(subUrl, "/sub/info-sub") { + t.Fatalf("subUrl = %q, want a /sub/info-sub suffix", subUrl) + } + for _, key := range []string{"enabled", "used", "remained", "expire", "lastOnline", "datepicker", "announce"} { + if _, present := info[key]; !present { + t.Fatalf("info payload missing %q; body=%s", key, w.Body.String()) + } + } +} + +func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) { + gin.SetMode(gin.TestMode) + initSubDB(t) + + router := gin.New() + NewSUBController(router.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/sub/does-not-exist?format=info", nil) + req.Host = "sub.example.com" + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +func TestSubInfoEndpoint_HTMLPageStillWinsWithoutFormatParam(t *testing.T) { + gin.SetMode(gin.TestMode) + initSubDB(t) + seedInfoEndpointSub(t, "html-sub", "html@x") + oldDistFS := distFS + distFS = testDistFS + t.Cleanup(func() { distFS = oldDistFS }) + + router := gin.New() + NewSUBController(router.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/sub/html-sub", nil) + req.Host = "sub.example.com" + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html for a browser request", ct) + } + if !strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") { + t.Fatal("browser request must still get the SPA page with injected page data") + } + if !strings.Contains(w.Body.String(), `"isOnline":false`) { + t.Fatalf("injected page data must carry isOnline; body=%s", w.Body.String()) + } +} diff --git a/internal/sub/page_data_test.go b/internal/sub/page_data_test.go index de95a1a4e..9edaf82a7 100644 --- a/internal/sub/page_data_test.go +++ b/internal/sub/page_data_test.go @@ -35,3 +35,35 @@ func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) { t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails) } } + +func TestSubIsOnline(t *testing.T) { + tests := []struct { + name string + sub []string + online []string + want bool + }{ + {name: "nobody online", sub: []string{"a@x"}, online: nil, want: false}, + {name: "no sub emails", sub: nil, online: []string{"a@x"}, want: false}, + {name: "sub client online", sub: []string{"a@x"}, online: []string{"z@x", "a@x"}, want: true}, + {name: "only other clients online", sub: []string{"a@x"}, online: []string{"z@x"}, want: false}, + {name: "any of several sub entries online", sub: []string{"a@x", "b@x"}, online: []string{"b@x"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := subIsOnline(tt.sub, tt.online); got != tt.want { + t.Fatalf("subIsOnline(%v, %v) = %v, want %v", tt.sub, tt.online, got, tt.want) + } + }) + } +} + +func TestBuildPageData_IsOnlineFalseWithoutLiveConnections(t *testing.T) { + s := &SubService{} + + page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, []string{"vless://a@h1:443?type=tcp#DE-john@x"}, []string{"john@x"}, "", "", "", "/", "", "") + + if page.IsOnline { + t.Fatal("IsOnline must be false when the subscription's client has no live connection") + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index e5c777b92..3df68744c 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2454,6 +2454,7 @@ type PageData struct { BasePath string SId string Enabled bool + IsOnline bool Download string Upload string Total string @@ -2618,6 +2619,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray BasePath: basePath, SId: subId, Enabled: traffic.Enable, + IsOnline: subIsOnline(emails, s.inboundService.GetOnlineClients()), Download: download, Upload: upload, Total: total, @@ -2639,6 +2641,22 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray } } +func subIsOnline(subEmails, onlineEmails []string) bool { + if len(subEmails) == 0 || len(onlineEmails) == 0 { + return false + } + onlineSet := make(map[string]struct{}, len(onlineEmails)) + for _, email := range onlineEmails { + onlineSet[email] = struct{}{} + } + for _, email := range subEmails { + if _, online := onlineSet[email]; online { + return true + } + } + return false +} + func getHostFromXFH(s string) (string, error) { if strings.Contains(s, ":") { realHost, _, err := net.SplitHostPort(s)