Browse Source

feat(sub): expose live online status and add ?format=info endpoint

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.
Sanaei 9 hours ago
parent
commit
cd674c8d4f

+ 38 - 1
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 <sub URL>?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
+<span id="status"></span>
+<script>
+  async function refreshStatus() {
+    const res = await fetch(window.location.pathname + '?format=info');
+    if (!res.ok) return;
+    const info = await res.json();
+    document.getElementById('status').textContent = info.isOnline ? 'Online' : 'Offline';
+  }
+  refreshStatus();
+  setInterval(refreshStatus, 10000);
+</script>
+```

+ 10 - 1
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",

+ 2 - 1
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.' },
         ],
       },
       {

+ 85 - 35
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) {

+ 127 - 0
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://[email protected]: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())
+	}
+}

+ 32 - 0
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")
+	}
+}

+ 18 - 0
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)