Просмотр исходного кода

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 10 часов назад
Родитель
Сommit
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).
 * `{{ .sId }}`: Subscription ID (UUID).
 * `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
 * `{{ .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").
 * `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
 * `{{ .upload }}`: Formatted upload traffic.
 * `{{ .upload }}`: Formatted upload traffic.
 * `{{ .total }}`: Formatted total traffic limit.
 * `{{ .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.
 * `{{ .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.
 * `{{ .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 }}`.
 * `{{ .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").
 * `{{ .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": [
         "tags": [
           "Subscription Server"
           "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",
         "operationId": "get_subPath_subid",
         "parameters": [
         "parameters": [
           {
           {
@@ -10993,6 +10993,15 @@
               "type": "string"
               "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",
             "name": "subPath",
             "in": "path",
             "in": "path",

+ 2 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -1463,9 +1463,10 @@ export const sections: readonly Section[] = [
       {
       {
         method: 'GET',
         method: 'GET',
         path: '/{subPath}:subid',
         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: [
         params: [
           { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
           { 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 {
 	if !wantsHTML {
 		return false
 		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")
 	subId := c.Param("subid")
 	_, host, _, hostHeader := a.subService.ResolveRequest(c)
 	_, host, _, hostHeader := a.subService.ResolveRequest(c)
 	subReq := a.subService.ForRequest(host)
 	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)
 	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
 	if err != nil || len(subs) == 0 {
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
 		writeSubError(c, err)
-		return true
+		return PageData{}, false
 	}
 	}
 	subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
 	subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
 	if !a.jsonEnabled {
 	if !a.jsonEnabled {
@@ -329,13 +354,32 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
 	}
 	}
 	basePathStr := basePath.(string)
 	basePathStr := basePath.(string)
 	page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
 	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.
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
 func (a *SUBController) subs(c *gin.Context) {
 func (a *SUBController) subs(c *gin.Context) {
 	userAgent := c.GetHeader("User-Agent")
 	userAgent := c.GetHeader("User-Agent")
+	if a.maybeServeSubInfo(c) {
+		logSubscriptionRoute(userAgent, "info")
+		return
+	}
 	if a.maybeServeSubPage(c) {
 	if a.maybeServeSubPage(c) {
 		logSubscriptionRoute(userAgent, "html")
 		logSubscriptionRoute(userAgent, "html")
 		return
 		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/`))
 		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
 	// 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
 	// 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)
 	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
 // setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
 // clients and browsers always fetch fresh traffic/expiry data.
 // clients and browsers always fetch fresh traffic/expiry data.
 func setNoCacheHeaders(c *gin.Context) {
 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)
 		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
 	BasePath      string
 	SId           string
 	SId           string
 	Enabled       bool
 	Enabled       bool
+	IsOnline      bool
 	Download      string
 	Download      string
 	Upload        string
 	Upload        string
 	Total         string
 	Total         string
@@ -2618,6 +2619,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		BasePath:      basePath,
 		BasePath:      basePath,
 		SId:           subId,
 		SId:           subId,
 		Enabled:       traffic.Enable,
 		Enabled:       traffic.Enable,
+		IsOnline:      subIsOnline(emails, s.inboundService.GetOnlineClients()),
 		Download:      download,
 		Download:      download,
 		Upload:        upload,
 		Upload:        upload,
 		Total:         total,
 		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) {
 func getHostFromXFH(s string) (string, error) {
 	if strings.Contains(s, ":") {
 	if strings.Contains(s, ":") {
 		realHost, _, err := net.SplitHostPort(s)
 		realHost, _, err := net.SplitHostPort(s)