mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 08:40:59 +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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user