mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-24 13:36:24 +00:00
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:
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user