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.
This commit is contained in:
Sanaei
2026-07-23 23:57:29 +02:00
parent b319dd0c3a
commit cd674c8d4f
7 changed files with 312 additions and 38 deletions
+38 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
}
+32
View File
@@ -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
View File
@@ -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)