diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 47929738..baf2bda5 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -43,7 +43,7 @@ export const sections = [ id: 'inbounds', title: 'Inbounds API', description: - 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token.', + 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.', endpoints: [ { method: 'GET', @@ -210,6 +210,29 @@ export const sections = [ path: '/panel/api/inbounds/lastOnline', summary: 'Map of client email → last-seen unix timestamp.', }, + { + method: 'GET', + path: '/panel/api/inbounds/getSubLinks/:subId', + summary: + 'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.', + params: [ + { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." }, + ], + response: + '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}', + }, + { + method: 'GET', + path: '/panel/api/inbounds/getClientLinks/:id/:email', + summary: + "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.", + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email.' }, + ], + response: + '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}', + }, { method: 'POST', path: '/panel/api/inbounds/updateClientTraffic/:email', diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue index e4e54507..fab7ba9d 100644 --- a/frontend/src/pages/login/LoginPage.vue +++ b/frontend/src/pages/login/LoginPage.vue @@ -18,7 +18,6 @@ const { t } = useI18n(); const fetched = ref(false); const submitting = ref(false); const twoFactorEnable = ref(false); -const version = computed(() => window.X_UI_CUR_VER || ''); const user = reactive({ username: '', @@ -178,7 +177,6 @@ function cycleTheme() { -
v{{ version }}
@@ -475,13 +473,6 @@ function cycleTheme() { margin-bottom: 0; } -.version { - text-align: center; - font-size: 12px; - color: var(--color-text-subtle); - margin-top: 16px; -} - .settings-popover { min-width: 220px; } diff --git a/main.go b/main.go index 4d593898..9bb1d0b9 100644 --- a/main.go +++ b/main.go @@ -61,6 +61,8 @@ func runWebServer() { } var subServer *sub.Server + sub.SetDistFS(web.EmbeddedDist()) + service.RegisterSubLinkProvider(sub.NewLinkProvider()) subServer = sub.NewServer() global.SetSubServer(subServer) err = subServer.Start() @@ -101,6 +103,7 @@ func runWebServer() { } log.Println("Web server restarted successfully.") + sub.SetDistFS(web.EmbeddedDist()) subServer = sub.NewServer() global.SetSubServer(subServer) err = subServer.Start() diff --git a/sub/dist.go b/sub/dist.go new file mode 100644 index 00000000..ebd66036 --- /dev/null +++ b/sub/dist.go @@ -0,0 +1,16 @@ +package sub + +import "embed" + +// distFS holds the Vite-built frontend filesystem, injected from main at +// startup. The `web` package owns the //go:embed directive (because dist/ +// is at web/dist/), and hands the FS over via SetDistFS so the sub package +// doesn't import web — that would create an import cycle once any +// web/controller handler reuses sub's link-building service. +var distFS embed.FS + +// SetDistFS installs the embedded frontend filesystem the sub server uses +// for its info page assets. Must be called before NewServer().Start(). +func SetDistFS(fs embed.FS) { + distFS = fs +} diff --git a/sub/links.go b/sub/links.go new file mode 100644 index 00000000..234f8d79 --- /dev/null +++ b/sub/links.go @@ -0,0 +1,59 @@ +package sub + +import ( + "strings" + + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/web/service" +) + +type LinkProvider struct { + settingService service.SettingService +} + +func NewLinkProvider() *LinkProvider { + return &LinkProvider{} +} + +func (p *LinkProvider) build(host string) *SubService { + showInfo, _ := p.settingService.GetSubShowInfo() + rModel, err := p.settingService.GetRemarkModel() + if err != nil { + rModel = "-ieo" + } + svc := NewSubService(showInfo, rModel) + svc.PrepareForRequest(host) + return svc +} + +func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) { + svc := p.build(host) + links, _, _, err := svc.GetSubs(subId, host) + if err != nil { + return nil, err + } + out := make([]string, 0, len(links)) + for _, l := range links { + out = append(out, splitLinkLines(l)...) + } + return out, nil +} + +func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string { + svc := p.build(host) + return splitLinkLines(svc.GetLink(inbound, email)) +} + +func splitLinkLines(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, "\n") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/sub/sub.go b/sub/sub.go index 5f9d509d..eb3fece7 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -16,7 +16,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/util/common" - webpkg "github.com/mhsanaei/3x-ui/v3/web" "github.com/mhsanaei/3x-ui/v3/web/locale" "github.com/mhsanaei/3x-ui/v3/web/middleware" "github.com/mhsanaei/3x-ui/v3/web/network" @@ -189,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { var assetsFS http.FileSystem if _, err := os.Stat("web/dist/assets"); err == nil { assetsFS = http.FS(os.DirFS("web/dist/assets")) - } else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil { + } else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil { assetsFS = http.FS(subFS) } else { logger.Error("sub: failed to mount embedded dist assets:", err) diff --git a/sub/subController.go b/sub/subController.go index 1f2be30b..9c7414c5 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" - webpkg "github.com/mhsanaei/3x-ui/v3/web" "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/gin-gonic/gin" @@ -159,8 +158,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil { body = diskBody } else { - dist := webpkg.EmbeddedDist() - readBody, err := dist.ReadFile("dist/subpage.html") + readBody, err := distFS.ReadFile("dist/subpage.html") if err != nil { c.String(http.StatusInternalServerError, "missing embedded subpage") return diff --git a/sub/subService.go b/sub/subService.go index 2ec33e5f..887cf87c 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if client.Enable { hasEnabledClient = true } - result = append(result, s.getLink(inbound, client.Email)) + result = append(result, s.GetLink(inbound, client.Email)) var ct xray.ClientTraffic ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) if ct.LastOnline > lastOnline { @@ -198,7 +198,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri return inbound.Listen, inbound.Port, string(modifiedStream), nil } -func (s *SubService) getLink(inbound *model.Inbound, email string) string { +// GetLink dispatches to the protocol-specific generator for one (inbound, client) +// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL +// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may +// contain multiple `\n`-separated URLs when the inbound has externalProxy set. +func (s *SubService) GetLink(inbound *model.Inbound, email string) string { switch inbound.Protocol { case "vmess": return s.genVmessLink(inbound, email) diff --git a/web/controller/dist.go b/web/controller/dist.go index 8bcfb84a..51bd3574 100644 --- a/web/controller/dist.go +++ b/web/controller/dist.go @@ -50,7 +50,6 @@ func serveDistPage(c *gin.Context, name string) { "&", `&`, ) escapedBase := jsEscape.Replace(basePath) - escapedVer := jsEscape.Replace(config.GetVersion()) csrfToken, err := session.EnsureCSRFToken(c) if err != nil { logger.Warning("Unable to mint CSRF token for", name+":", err) @@ -58,8 +57,13 @@ func serveDistPage(c *gin.Context, name string) { } csrfMeta := []byte(``) - inject := []byte(``) + script := `` + inject := []byte(script) inject = append(inject, csrfMeta...) inject = append(inject, []byte(``)...) out := bytes.Replace(body, []byte(""), inject, 1) diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 00d26cc1..79f5d4eb 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -3,7 +3,9 @@ package controller import ( "encoding/json" "fmt" + "net" "strconv" + "strings" "time" "github.com/mhsanaei/3x-ui/v3/database/model" @@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/get/:id", a.getInbound) g.GET("/getClientTraffics/:email", a.getClientTraffics) g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById) + g.GET("/getSubLinks/:subId", a.getSubLinks) + g.GET("/getClientLinks/:id/:email", a.getClientLinks) g.POST("/add", a.addInbound) g.POST("/del/:id", a.delInbound) @@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) { a.xrayService.SetToNeedRestart() } } + +// resolveHost mirrors what sub.SubService.ResolveRequest does for the host +// field: prefers X-Forwarded-Host (first entry of any list, port stripped), +// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the +// controller layer means the service interface stays HTTP-agnostic — service +// methods receive a plain host string instead of a *gin.Context. +func resolveHost(c *gin.Context) string { + if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" { + if i := strings.Index(h, ","); i >= 0 { + h = strings.TrimSpace(h[:i]) + } + if hp, _, err := net.SplitHostPort(h); err == nil { + return hp + } + return h + } + if h := c.GetHeader("X-Real-IP"); h != "" { + return h + } + if h, _, err := net.SplitHostPort(c.Request.Host); err == nil { + return h + } + return c.Request.Host +} + +// getSubLinks returns every protocol URL produced for the given subscription +// ID — the JSON-array equivalent of /sub/ (no base64 wrap). +func (a *InboundController) getSubLinks(c *gin.Context) { + links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId")) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, links, nil) +} + +// getClientLinks returns the URL(s) for one client on one inbound — the same +// string the Copy URL button copies in the panel UI. Empty array when the +// protocol has no URL form, or when the email isn't found on the inbound. +func (a *InboundController) getClientLinks(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email")) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, links, nil) +} diff --git a/web/service/inbound.go b/web/service/inbound.go index b0d42533..ac7481a2 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -3866,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b return needRestart, db.Save(oldInbound).Error } + +type SubLinkProvider interface { + SubLinksForSubId(host, subId string) ([]string, error) + LinksForClient(host string, inbound *model.Inbound, email string) []string +} + +var registeredSubLinkProvider SubLinkProvider + +func RegisterSubLinkProvider(p SubLinkProvider) { + registeredSubLinkProvider = p +} + +func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) { + if registeredSubLinkProvider == nil { + return nil, common.NewError("sub link provider not registered") + } + return registeredSubLinkProvider.SubLinksForSubId(host, subId) +} +func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) { + inbound, err := s.GetInbound(id) + if err != nil { + return nil, err + } + if registeredSubLinkProvider == nil { + return nil, common.NewError("sub link provider not registered") + } + return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil +}