mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 04:41:58 +00:00
feat(sub): expose live online status and add ?format=info endpoint
Some checks are pending
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
Some checks are pending
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
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:
parent
b319dd0c3a
commit
cd674c8d4f
7 changed files with 312 additions and 38 deletions
|
|
@ -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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
internal/sub/info_endpoint_test.go
Normal file
127
internal/sub/info_endpoint_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue