feat(panel): add sponsor slots fed from sponsors.sanaei.dev

Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
This commit is contained in:
MHSanaei
2026-09-26 03:31:51 +02:00
parent 89e200ead4
commit fd7b3559bc
47 changed files with 2323 additions and 10 deletions
+26
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
@@ -28,6 +29,7 @@ type IndexController struct {
settingService service.SettingService
userService panel.UserService
panelService panel.PanelService
tgbot tgbot.Tgbot
}
@@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/csrf-token", a.csrfToken)
g.GET("/sponsors", a.sponsors)
g.GET("/sponsors/logo/:name", a.sponsorLogo)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
}
// sponsors is public so the login page can render its slot; failures stay silent.
func (a *IndexController) sponsors(c *gin.Context) {
list, err := a.panelService.GetSponsors()
if err != nil {
logger.Debug("sponsors fetch failed:", err)
c.JSON(http.StatusOK, entity.Msg{Success: false})
return
}
jsonObj(c, list, nil)
}
func (a *IndexController) sponsorLogo(c *gin.Context) {
data, contentType, err := a.panelService.GetSponsorLogo(c.Param("name"))
if err != nil {
logger.Debug("sponsor logo failed:", err)
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=3600")
c.Data(http.StatusOK, contentType, data)
}
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
func (a *IndexController) index(c *gin.Context) {
if session.IsLogin(c) {
+5 -4
View File
@@ -23,16 +23,17 @@ and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
/ws). SPA page routes are UI, not API, and stay out; registry paths that
start with "/{" describe the standalone subscription server, which this
engine does not serve.
registry also documents (/login, /logout, /csrf-token, /sponsors,
/getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out;
registry paths that start with "/{" describe the standalone subscription
server, which this engine does not serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"GET /sponsors": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
}
+246
View File
@@ -0,0 +1,246 @@
package panel
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// Sponsor is one paid placement published in the repo's sponsors.json.
type Sponsor struct {
ID string `json:"id" example:"acme-2026-10"`
Name string `json:"name" example:"Acme VPS"`
Enable *bool `json:"enable,omitempty" example:"true"`
Slots []string `json:"slots"`
Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
Title map[string]string `json:"title"`
Text map[string]string `json:"text"`
Link string `json:"link" example:"https://acme.example/?utm_source=3x-ui"`
}
// SponsorList is the active sponsor set plus the contact link for new sponsors.
type SponsorList struct {
Contact string `json:"contact,omitempty" example:"https://t.me/example"`
Sponsors []Sponsor `json:"sponsors"`
}
const (
sponsorsTTL = time.Hour
sponsorsErrTTL = 10 * time.Minute
maxSponsorsBytes = 256 << 10
maxLogoBytes = 256 << 10
maxSidebarSlots = 3
localSponsorsDir = "../sponsors/3X"
sponsorLogoPath = "/sponsors/logo/"
)
// ErrSponsorLogoUnknown rejects logo names not used by an active sponsor.
var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo")
type sponsorLogo struct {
data []byte
contentType string
err error
retryAt time.Time
}
var (
sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
sponsorNow = time.Now
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
sponsorsMu sync.Mutex
sponsorsRaw *SponsorList
sponsorsErr error
sponsorsRetryAt time.Time
logosMu sync.Mutex
logos = map[string]sponsorLogo{}
)
// GetSponsors returns the currently active sponsors. The remote file is cached,
// but expiry is re-checked on every call so a slot ends exactly at Until.
func (s *PanelService) GetSponsors() (*SponsorList, error) {
raw, err := cachedSponsors()
if err != nil {
return nil, err
}
return activeSponsors(raw, sponsorNow()), nil
}
func cachedSponsors() (*SponsorList, error) {
sponsorsMu.Lock()
defer sponsorsMu.Unlock()
now := sponsorNow()
if !config.IsDebug() && now.Before(sponsorsRetryAt) {
return sponsorsRaw, sponsorsErr
}
list, err := fetchSponsors()
switch {
case err == nil:
sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL)
case sponsorsRaw != nil:
// An upstream blip keeps the last good list, so paid slots do not blink out.
logger.Debug("sponsors refresh failed, keeping last list:", err)
sponsorsRetryAt = now.Add(sponsorsErrTTL)
default:
sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL)
}
return sponsorsRaw, sponsorsErr
}
// GetSponsorLogo returns the image bytes for a logo of a currently active sponsor.
func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
sponsors, err := s.GetSponsors()
if err != nil {
return nil, "", err
}
if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
return nil, "", ErrSponsorLogoUnknown
}
logosMu.Lock()
defer logosMu.Unlock()
now := sponsorNow()
l, ok := logos[name]
if ok && !config.IsDebug() && now.Before(l.retryAt) {
return l.data, l.contentType, l.err
}
// Failures are cached too: this route is public and each miss is an outbound fetch.
data, contentType, err := fetchSponsorLogo(name)
switch {
case err == nil:
l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
case l.data != nil:
l.retryAt = now.Add(sponsorsErrTTL)
default:
l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
}
logos[name] = l
return l.data, l.contentType, l.err
}
func fetchSponsorLogo(name string) ([]byte, string, error) {
data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
if err != nil {
return nil, "", err
}
contentType := http.DetectContentType(data)
switch contentType {
case "image/png", "image/webp", "image/jpeg":
return data, contentType, nil
default:
return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
}
}
// readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
// sponsor edits can be previewed locally before they are pushed.
func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
if !config.IsDebug() {
return fetchLimited(url, limit)
}
data, err := os.ReadFile(localPath)
if err != nil {
return nil, err
}
if len(data) > limit {
return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
}
return data, nil
}
func fetchLimited(url string, limit int) ([]byte, error) {
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
if err != nil {
return nil, err
}
if len(body) > limit {
return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
}
return body, nil
}
func fetchSponsors() (*SponsorList, error) {
body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
if err != nil {
return nil, err
}
var list SponsorList
if err := json.Unmarshal(body, &list); err != nil {
return nil, err
}
return &list, nil
}
func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
out := &SponsorList{Sponsors: []Sponsor{}}
if raw == nil {
return out
}
if strings.HasPrefix(raw.Contact, "https://") {
out.Contact = raw.Contact
}
sidebarTaken := 0
for _, sp := range raw.Sponsors {
// A missing enable counts as on, so a forgotten field never hides a paid slot.
disabled := sp.Enable != nil && !*sp.Enable
if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
continue
}
// A bad logo name drops only the logo; the paid slot still renders with its initial.
if sponsorLogoRe.MatchString(sp.Logo) {
sp.Logo = sponsorLogoPath + sp.Logo
} else {
sp.Logo = ""
}
slots := make([]string, 0, len(sp.Slots))
for _, slot := range sp.Slots {
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
continue
}
slots = append(slots, slot)
}
if len(slots) == 0 {
continue
}
if slices.Contains(slots, "sidebar") {
sidebarTaken++
}
sp.Slots = slots
out.Sponsors = append(out.Sponsors, sp)
}
return out
}
+334
View File
@@ -0,0 +1,334 @@
package panel
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
func validSponsor() Sponsor {
return Sponsor{
ID: "acme",
Name: "Acme",
Slots: []string{"dashboard", "sidebar"},
Until: sponsorTestNow.Add(24 * time.Hour),
Logo: "acme.png",
Link: "https://acme.example/",
}
}
func TestActiveSponsorsFilters(t *testing.T) {
cases := []struct {
name string
mutate func(*Sponsor)
kept bool
}{
{"valid", func(*Sponsor) {}, true},
{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
{"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
{"missing id", func(s *Sponsor) { s.ID = "" }, false},
{"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false},
{"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false},
{"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sp := validSponsor()
tc.mutate(&sp)
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
if kept := len(got.Sponsors) == 1; kept != tc.kept {
t.Fatalf("kept = %v, want %v", kept, tc.kept)
}
})
}
}
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
raw := &SponsorList{}
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
sp := validSponsor()
sp.ID = id
sp.Slots = []string{"sidebar", "page"}
if id == "expired" {
sp.Until = sponsorTestNow
}
if id == "e" {
sp.Slots = []string{"sidebar"}
}
raw.Sponsors = append(raw.Sponsors, sp)
}
got := activeSponsors(raw, sponsorTestNow)
want := map[string][]string{
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
}
if len(got.Sponsors) != len(want) {
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
}
for _, sp := range got.Sponsors {
if !slices.Equal(sp.Slots, want[sp.ID]) {
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
}
}
}
func TestActiveSponsorsLogoName(t *testing.T) {
cases := []struct{ logo, want string }{
{"acme.png", "/sponsors/logo/acme.png"},
{"VPS.png", "/sponsors/logo/VPS.png"},
{"../x.png", ""},
{"https://evil.example/x.png", ""},
{"..png", ""},
{"logo.svg", ""},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.logo, func(t *testing.T) {
sp := validSponsor()
sp.Logo = tc.logo
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo)
}
if got.Sponsors[0].Logo != tc.want {
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want)
}
})
}
}
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
sp := validSponsor()
sp.Slots = []string{"subpage", "login"}
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
}
if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want {
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want)
}
if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" {
t.Errorf("slots = %v, want [login]", s)
}
if got.Contact != "" {
t.Errorf("contact = %q, want empty for non-https", got.Contact)
}
}
func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
t.Helper()
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(config.GetDBPath()); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
isLogo := strings.HasPrefix(r.URL.Path, "/media/")
if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) {
w.WriteHeader(http.StatusBadGateway)
return
}
switch r.URL.Path {
case "/media/acme.png":
_, _ = w.Write(pngMagic)
case "/media/fake.png":
_, _ = w.Write([]byte("<svg onload=alert(1)>"))
default:
_, _ = w.Write([]byte(body))
}
}))
t.Cleanup(srv.Close)
prevURL, prevBase, prevNow := sponsorsURL, sponsorLogoBase, sponsorNow
sponsorsURL, sponsorLogoBase = srv.URL+"/sponsors.json", srv.URL+"/media/"
resetSponsorCache()
t.Cleanup(func() {
sponsorsURL, sponsorLogoBase, sponsorNow = prevURL, prevBase, prevNow
failSponsorList.Store(false)
failSponsorLogos.Store(false)
resetSponsorCache()
})
return &hits
}
var failSponsorList, failSponsorLogos atomic.Bool
func TestGetSponsorsKeepsLastListWhenRefreshFails(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
"until":"2099-01-01T00:00:00Z","link":"https://acme.example/"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
if got, err := svc.GetSponsors(); err != nil || len(got.Sponsors) != 1 {
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
}
failSponsorList.Store(true)
now = now.Add(sponsorsTTL)
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != "acme" {
t.Fatalf("after failed refresh = %+v, %v; want the last good sponsor kept", got, err)
}
now = now.Add(sponsorsErrTTL - time.Second)
if _, err := svc.GetSponsors(); err != nil {
t.Fatal(err)
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits = %d, want 2 (failed refresh retried only after sponsorsErrTTL)", n)
}
}
func TestGetSponsorLogoCachesFailuresAndKeepsLastImage(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
const wantErr = "sponsor logo fake.png has content type text/plain; charset=utf-8"
for range 2 {
if _, _, err := svc.GetSponsorLogo("fake.png"); err == nil || err.Error() != wantErr {
t.Fatalf("fake.png err = %v, want %q", err, wantErr)
}
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits = %d, want 2 (list + one fake.png fetch; the failure must be cached)", n)
}
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
t.Fatal(err)
}
failSponsorLogos.Store(true)
now = now.Add(sponsorsTTL)
data, ctype, err := svc.GetSponsorLogo("acme.png")
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
t.Fatalf("acme.png after failed refresh = %q, %q, %v; want the last good image", data, ctype, err)
}
}
func resetSponsorCache() {
sponsorsMu.Lock()
defer sponsorsMu.Unlock()
sponsorsRaw, sponsorsErr, sponsorsRetryAt = nil, nil, time.Time{}
logosMu.Lock()
defer logosMu.Unlock()
logos = map[string]sponsorLogo{}
}
var pngMagic = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
func TestGetSponsorLogo(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"},
{"id":"old","slots":["page"],"until":"2000-01-01T00:00:00Z","link":"https://o.example/","logo":"old.png"}]}`)
svc := &PanelService{}
data, ctype, err := svc.GetSponsorLogo("acme.png")
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
t.Fatalf("acme.png = %q, %q, %v; want png bytes", data, ctype, err)
}
before := hits.Load()
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
t.Fatal(err)
}
if hits.Load() != before {
t.Fatalf("second logo fetch hit the remote; want cached")
}
for _, name := range []string{"old.png", "other.png", "../sponsors.json"} {
if _, _, err := svc.GetSponsorLogo(name); !errors.Is(err, ErrSponsorLogoUnknown) {
t.Errorf("%s: err = %v, want ErrSponsorLogoUnknown", name, err)
}
}
_, _, err = svc.GetSponsorLogo("fake.png")
if want := "sponsor logo fake.png has content type text/plain; charset=utf-8"; err == nil || err.Error() != want {
t.Errorf("fake.png: err = %v, want %q", err, want)
}
}
func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 {
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
}
now = now.Add(45 * time.Minute)
got, err = svc.GetSponsors()
if err != nil || len(got.Sponsors) != 0 {
t.Fatalf("after until = %+v, %v; want 0 sponsors", got, err)
}
if n := hits.Load(); n != 1 {
t.Fatalf("remote hits = %d, want 1 (cached within TTL)", n)
}
now = now.Add(sponsorsTTL)
if _, err := svc.GetSponsors(); err != nil {
t.Fatal(err)
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits after TTL = %d, want 2", n)
}
}
func TestGetSponsorsRejectsOversizeBody(t *testing.T) {
setupSponsorServer(t, `{"contact":"`+strings.Repeat("a", maxSponsorsBytes)+`"}`)
_, err := (&PanelService{}).GetSponsors()
want := sponsorsURL + " exceeds 262144 bytes"
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
}
func TestGetSponsorsDebugReadsLocalCheckout(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[]}`)
t.Setenv("XUI_DEBUG", "true")
root := t.TempDir()
local := filepath.Join(root, "sponsors", "3X")
if err := os.MkdirAll(local, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "3x-ui"), 0o700); err != nil {
t.Fatal(err)
}
t.Chdir(filepath.Join(root, "3x-ui"))
write := func(id string) {
t.Helper()
body := `{"sponsors":[{"id":"` + id + `","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/"}]}`
if err := os.WriteFile(filepath.Join(local, "sponsors.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
svc := &PanelService{}
for _, id := range []string{"first", "edited"} {
write(id)
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != id {
t.Fatalf("GetSponsors() = %+v, %v; want local sponsor %q", got, err, id)
}
}
if n := hits.Load(); n != 0 {
t.Fatalf("remote hits = %d, want 0 in debug mode", n)
}
}
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "الصادرات",
"apiDocs": "توثيق API",
"donate": "تبرع",
"sponsors": "الرعاة",
"hosts": "المضيفات",
"docs": "التوثيق",
"openMenu": "فتح القائمة",
@@ -2256,6 +2257,27 @@
"badTag": "وسم غير صالح",
"badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
}
},
"sponsors": {
"title": "الرعاة",
"intro": "شركات تدعم تطوير 3X-UI. تُفتح روابط الرعاة في علامة تبويب جديدة.",
"become": "كن راعيًا",
"tag": "راعٍ",
"visit": "زيارة",
"yourBrand": "علامتك التجارية هنا",
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
"placements": "المواضع المتاحة",
"placementDashboard": "لافتة لوحة المعلومات",
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
"placementSidebar": "بطاقة الشريط الجانبي",
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
"placementLogin": "صفحة تسجيل الدخول",
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
"placementPage": "صفحة الرعاة",
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
"available": "متاح",
"takenUntil": "محجوز حتى {date}",
"activeCount": "{count} نشط"
}
},
"tgbot": {
+22
View File
@@ -121,6 +121,7 @@
"outbounds": "Outbounds",
"apiDocs": "API Docs",
"donate": "Donate",
"sponsors": "Sponsors",
"docs": "Documentation",
"openMenu": "Open menu",
"pinSidebar": "Pin sidebar",
@@ -2256,6 +2257,27 @@
},
"defaultOutbound": "Default Outbound",
"defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
},
"sponsors": {
"title": "Sponsors",
"intro": "Companies that support 3X-UI development. Sponsored links open in a new tab.",
"become": "Become a sponsor",
"tag": "Sponsor",
"visit": "Visit",
"yourBrand": "Your brand here",
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
"placements": "Available placements",
"placementDashboard": "Overview banner",
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
"placementSidebar": "Sidebar card",
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
"placementLogin": "Login page",
"placementLoginDesc": "Seen by the admin at every sign-in.",
"placementPage": "Sponsors page",
"placementPageDesc": "Listed here with logo, description and link.",
"available": "Available",
"takenUntil": "Taken until {date}",
"activeCount": "{count} active"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Salidas",
"apiDocs": "Documentación de la API",
"donate": "Donar",
"sponsors": "Patrocinadores",
"hosts": "Hosts",
"docs": "Documentación",
"openMenu": "Abrir menú",
@@ -2256,6 +2257,27 @@
"badTag": "Etiqueta no válida",
"badVlessRoute": "Introduce un único número entre 0 y 65535"
}
},
"sponsors": {
"title": "Patrocinadores",
"intro": "Empresas que apoyan el desarrollo de 3X-UI. Los enlaces patrocinados se abren en una pestaña nueva.",
"become": "Hazte patrocinador",
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Tu marca aquí",
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
"placements": "Espacios disponibles",
"placementDashboard": "Banner del panel",
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
"placementSidebar": "Tarjeta lateral",
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
"placementLogin": "Página de inicio de sesión",
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
"available": "Disponible",
"takenUntil": "Ocupado hasta {date}",
"activeCount": "{count} activos"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "خروجی‌ها",
"apiDocs": "مستندات API",
"donate": "حمایت مالی",
"sponsors": "اسپانسرها",
"hosts": "میزبان‌ها",
"docs": "مستندات",
"openMenu": "باز کردن منو",
@@ -2256,6 +2257,27 @@
"badTag": "برچسب نامعتبر",
"badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
}
},
"sponsors": {
"title": "اسپانسرها",
"intro": "شرکت‌هایی که از توسعه‌ی 3X-UI حمایت می‌کنند. لینک‌های اسپانسر در تب جدید باز می‌شوند.",
"become": "اسپانسر شوید",
"tag": "اسپانسر",
"visit": "مشاهده",
"yourBrand": "جای برند شما",
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند.",
"placements": "جایگاه‌های موجود",
"placementDashboard": "بنر داشبورد",
"placementDashboardDesc": "جایگاه ویژه بالای صفحه‌ی داشبورد، هر بار فقط یک اسپانسر.",
"placementSidebar": "کارت سایدبار",
"placementSidebarDesc": "در همه‌ی صفحات پنل نمایش داده می‌شود و بین اسپانسرها چرخش دارد.",
"placementLogin": "صفحه‌ی ورود",
"placementLoginDesc": "ادمین در هر بار ورود آن را می‌بیند.",
"placementPage": "صفحه‌ی اسپانسرها",
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
"available": "خالی",
"takenUntil": "پر شده تا {date}",
"activeCount": "{count} فعال"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Outbound",
"apiDocs": "Dokumentasi API",
"donate": "Donasi",
"sponsors": "Sponsor",
"hosts": "Host",
"docs": "Dokumentasi",
"openMenu": "Buka menu",
@@ -2256,6 +2257,27 @@
"badTag": "Tag tidak valid",
"badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
}
},
"sponsors": {
"title": "Sponsor",
"intro": "Perusahaan yang mendukung pengembangan 3X-UI. Tautan sponsor dibuka di tab baru.",
"become": "Jadi sponsor",
"tag": "Sponsor",
"visit": "Kunjungi",
"yourBrand": "Merek Anda di sini",
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
"placements": "Slot yang tersedia",
"placementDashboard": "Banner dasbor",
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
"placementSidebar": "Kartu sidebar",
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
"placementLogin": "Halaman login",
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
"placementPage": "Halaman sponsor",
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
"available": "Tersedia",
"takenUntil": "Terisi hingga {date}",
"activeCount": "{count} aktif"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "アウトバウンド",
"apiDocs": "API ドキュメント",
"donate": "寄付",
"sponsors": "スポンサー",
"hosts": "ホスト",
"docs": "ドキュメント",
"openMenu": "メニューを開く",
@@ -2256,6 +2257,27 @@
"badTag": "無効なタグ",
"badVlessRoute": "0〜65535 の単一の数値を入力してください"
}
},
"sponsors": {
"title": "スポンサー",
"intro": "3X-UI の開発を支援している企業です。スポンサーリンクは新しいタブで開きます。",
"become": "スポンサーになる",
"tag": "スポンサー",
"visit": "訪問",
"yourBrand": "ここにあなたのブランドを",
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
"placements": "掲載枠",
"placementDashboard": "ダッシュボードバナー",
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
"placementSidebar": "サイドバーカード",
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
"placementLogin": "ログインページ",
"placementLoginDesc": "管理者がログインするたびに表示されます。",
"placementPage": "スポンサーページ",
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
"available": "空きあり",
"takenUntil": "{date} まで予約済み",
"activeCount": "{count} 件掲載中"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Saídas",
"apiDocs": "Documentação da API",
"donate": "Doar",
"sponsors": "Patrocinadores",
"hosts": "Hosts",
"docs": "Documentação",
"openMenu": "Abrir menu",
@@ -2256,6 +2257,27 @@
"badTag": "Tag inválida",
"badVlessRoute": "Insira um único número entre 0 e 65535"
}
},
"sponsors": {
"title": "Patrocinadores",
"intro": "Empresas que apoiam o desenvolvimento do 3X-UI. Links patrocinados abrem em uma nova aba.",
"become": "Seja um patrocinador",
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Sua marca aqui",
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
"placements": "Espaços disponíveis",
"placementDashboard": "Banner do painel",
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
"placementSidebar": "Cartão lateral",
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
"placementLogin": "Página de login",
"placementLoginDesc": "Visto pelo administrador a cada login.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Listado aqui com logo, descrição e link.",
"available": "Disponível",
"takenUntil": "Ocupado até {date}",
"activeCount": "{count} ativos"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Исходящие",
"apiDocs": "Документация API",
"donate": "Поддержать",
"sponsors": "Спонсоры",
"hosts": "Хосты",
"docs": "Документация",
"openMenu": "Открыть меню",
@@ -2256,6 +2257,27 @@
"badTag": "Недопустимый тег",
"badVlessRoute": "Введите одно число от 0 до 65535"
}
},
"sponsors": {
"title": "Спонсоры",
"intro": "Компании, поддерживающие разработку 3X-UI. Спонсорские ссылки открываются в новой вкладке.",
"become": "Стать спонсором",
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Здесь может быть ваш бренд",
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
"placements": "Доступные места",
"placementDashboard": "Баннер на главной",
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
"placementSidebar": "Карточка в боковой панели",
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
"placementLogin": "Страница входа",
"placementLoginDesc": "Администратор видит её при каждом входе.",
"placementPage": "Страница спонсоров",
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
"available": "Свободно",
"takenUntil": "Занято до {date}",
"activeCount": "Активных: {count}"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Giden Bağlantılar",
"apiDocs": "API Belgeleri",
"donate": "Bağış Yap",
"sponsors": "Sponsorlar",
"hosts": "Host'lar",
"docs": "Belgeler",
"openMenu": "Menüyü aç",
@@ -2256,6 +2257,27 @@
"badTag": "Geçersiz etiket",
"badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
}
},
"sponsors": {
"title": "Sponsorlar",
"intro": "3X-UI geliştirmesini destekleyen şirketler. Sponsor bağlantıları yeni sekmede açılır.",
"become": "Sponsor ol",
"tag": "Sponsor",
"visit": "Ziyaret et",
"yourBrand": "Markanız burada",
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
"placements": "Mevcut yerleşimler",
"placementDashboard": "Genel bakış afişi",
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
"placementSidebar": "Kenar çubuğu kartı",
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
"placementLogin": "Giriş sayfası",
"placementLoginDesc": "Yönetici her girişte görür.",
"placementPage": "Sponsorlar sayfası",
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
"available": "Müsait",
"takenUntil": "{date} tarihine kadar dolu",
"activeCount": "{count} aktif"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Вихідні",
"apiDocs": "Документація API",
"donate": "Підтримати",
"sponsors": "Спонсори",
"hosts": "Хости",
"docs": "Документація",
"openMenu": "Відкрити меню",
@@ -2256,6 +2257,27 @@
"badTag": "Недійсний тег",
"badVlessRoute": "Введіть одне число від 0 до 65535"
}
},
"sponsors": {
"title": "Спонсори",
"intro": "Компанії, що підтримують розробку 3X-UI. Спонсорські посилання відкриваються в новій вкладці.",
"become": "Стати спонсором",
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Тут може бути ваш бренд",
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
"placements": "Доступні місця",
"placementDashboard": "Банер на головній",
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
"placementSidebar": "Картка в бічній панелі",
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
"placementLogin": "Сторінка входу",
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
"placementPage": "Сторінка спонсорів",
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
"available": "Вільно",
"takenUntil": "Зайнято до {date}",
"activeCount": "Активних: {count}"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "Outbound",
"apiDocs": "Tài liệu API",
"donate": "Quyên góp",
"sponsors": "Nhà tài trợ",
"hosts": "Hosts",
"docs": "Tài liệu",
"openMenu": "Mở menu",
@@ -2256,6 +2257,27 @@
"badTag": "Tag không hợp lệ",
"badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
}
},
"sponsors": {
"title": "Nhà tài trợ",
"intro": "Các công ty hỗ trợ phát triển 3X-UI. Liên kết tài trợ mở trong tab mới.",
"become": "Trở thành nhà tài trợ",
"tag": "Tài trợ",
"visit": "Truy cập",
"yourBrand": "Thương hiệu của bạn ở đây",
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
"placements": "Vị trí hiện có",
"placementDashboard": "Banner tổng quan",
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
"placementSidebar": "Thẻ thanh bên",
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
"placementLogin": "Trang đăng nhập",
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
"placementPage": "Trang nhà tài trợ",
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
"available": "Còn trống",
"takenUntil": "Đã đặt đến {date}",
"activeCount": "{count} đang hoạt động"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "出站",
"apiDocs": "API 文档",
"donate": "捐赠",
"sponsors": "赞助商",
"hosts": "主机",
"docs": "文档",
"openMenu": "打开菜单",
@@ -2256,6 +2257,27 @@
"badTag": "无效的标签",
"badVlessRoute": "请输入 0 到 65535 之间的单个数字"
}
},
"sponsors": {
"title": "赞助商",
"intro": "支持 3X-UI 开发的公司。赞助链接将在新标签页中打开。",
"become": "成为赞助商",
"tag": "赞助",
"visit": "访问",
"yourBrand": "您的品牌展示位",
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "概览横幅",
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
"placementSidebar": "侧边栏卡片",
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
"placementLogin": "登录页",
"placementLoginDesc": "管理员每次登录时都能看到。",
"placementPage": "赞助商页面",
"placementPageDesc": "在此页面展示徽标、简介和链接。",
"available": "可预订",
"takenUntil": "已预订至 {date}",
"activeCount": "{count} 个进行中"
}
},
"tgbot": {
+22
View File
@@ -120,6 +120,7 @@
"outbounds": "出站",
"apiDocs": "API 文件",
"donate": "捐贈",
"sponsors": "贊助商",
"hosts": "主機",
"docs": "文件",
"openMenu": "開啟選單",
@@ -2256,6 +2257,27 @@
"badTag": "無效的標籤",
"badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
}
},
"sponsors": {
"title": "贊助商",
"intro": "支持 3X-UI 開發的公司。贊助連結將在新分頁中開啟。",
"become": "成為贊助商",
"tag": "贊助",
"visit": "造訪",
"yourBrand": "您的品牌展示位",
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "總覽橫幅",
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
"placementSidebar": "側邊欄卡片",
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
"placementLogin": "登入頁",
"placementLoginDesc": "管理員每次登入時都能看到。",
"placementPage": "贊助商頁面",
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
"available": "可預訂",
"takenUntil": "已預訂至 {date}",
"activeCount": "{count} 個進行中"
}
},
"tgbot": {