diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 33385c8ad..ef0e1fc10 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2715,6 +2715,43 @@ } } }, + "/panel/api/inbounds/allLinks": { + "get": { + "tags": [ + "Inbounds" + ], + "summary": "Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s \"Export all inbound links\" action.", + "operationId": "get_panel_api_inbounds_allLinks", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + "vless://uuid@host:443?security=reality&...#Germany-alice", + "vmess://eyJ2IjoyLC..." + ] + } + } + } + } + } + } + }, "/panel/api/inbounds/get/{id}": { "get": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 28f377de6..bca727ee7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -126,6 +126,14 @@ export const sections: readonly Section[] = [ responseSchema: 'InboundOption', responseSchemaArray: true, }, + { + method: 'GET', + path: '/panel/api/inbounds/allLinks', + summary: + 'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.', + response: + '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#Germany-alice",\n "vmess://eyJ2IjoyLC..."\n ]\n}', + }, { method: 'GET', path: '/panel/api/inbounds/get/:id', diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index e72aaa5e5..f1ce3a624 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -292,21 +292,10 @@ export default function InboundsPage() { }, [subSettings, openText, t]); const exportAllLinks = useCallback(async () => { - const hydrated = await Promise.all( - dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)), - ); - const out: string[] = []; - for (const ib of hydrated) { - const projected = checkFallback(ib); - out.push(genInboundLinks({ - inbound: inboundFromDb(projected), - remark: projected.remark, - hostOverride: hostOverrideFor(ib), - fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost), - })); - } - openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') }); - }, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]); + const msg = await HttpUtil.get('/panel/api/inbounds/allLinks'); + const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : []; + openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') }); + }, [openText, t]); const exportAllSubs = useCallback(async () => { const hydrated = await Promise.all( diff --git a/internal/sub/export_all_links_test.go b/internal/sub/export_all_links_test.go new file mode 100644 index 000000000..0de6c8b25 --- /dev/null +++ b/internal/sub/export_all_links_test.go @@ -0,0 +1,43 @@ +package sub + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// inboundLinks (the "Export all inbound links" path) must render the remark +// template's whole Client token group per client, name-only — the same engine +// the client/QR pages use. +func TestInboundLinks_RemarkTemplateClientTokens(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + settings := `{"clients":[{"id":"11111111-2222-4333-8444-000000000001","email":"john@e","subId":"subABC","comment":"vip","tgId":777,"enable":true}],"decryption":"none"}` + ib := &model.Inbound{ + UserId: 1, Tag: "t", Enable: true, Listen: "203.0.113.5", Port: 4431, + Protocol: model.VLESS, Remark: "Germany", Settings: settings, + StreamSettings: `{"network":"ws","security":"tls","wsSettings":{"path":"/","host":""},"tlsSettings":{"serverName":"sni"}}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + svc := NewSubService("{{INBOUND}}-{{EMAIL}}-{{COMMENT}}-{{SUB_ID}}-{{TELEGRAM_ID}}-{{SHORT_ID}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") + svc.PrepareForRequest("req.example.com") + links := svc.inboundLinks(ib) + + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + frag := links[0] + for _, want := range []string{"Germany-john", "vip", "subABC", "777", "11111111"} { + if !strings.Contains(frag, want) { + t.Fatalf("remark missing client token %q: %s", want, frag) + } + } + if strings.Contains(frag, "GB") || strings.ContainsRune(frag, '⏳') { + t.Fatalf("display mode must drop the traffic/expiry segments: %s", frag) + } +} diff --git a/internal/sub/links.go b/internal/sub/links.go index 56d565962..e281df7b6 100644 --- a/internal/sub/links.go +++ b/internal/sub/links.go @@ -41,6 +41,15 @@ func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email return splitLinkLines(svc.GetLink(inbound, email)) } +func (p *LinkProvider) LinksForInbounds(host string, inbounds []*model.Inbound) []string { + svc := p.build(host) + var out []string + for _, inbound := range inbounds { + out = append(out, svc.inboundLinks(inbound)...) + } + return out +} + func splitLinkLines(raw string) []string { if raw == "" { return nil diff --git a/internal/sub/service.go b/internal/sub/service.go index f75f15a5a..d93d4c273 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -242,6 +242,37 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie return result, emails, lastOnline, traffic, nil } +// inboundLinks builds the share links for every distinct client of one inbound +// the same way getSubs does — managed Host endpoints win over the plain link so +// {{HOST}} and per-host variants render — but across all clients rather than a +// single subId. Dedups duplicate client JSON entries by email (#5134). Backs the +// panel's "Export all inbound links" so it matches the client/QR pages. +func (s *SubService) inboundLinks(inbound *model.Inbound) []string { + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + return nil + } + s.projectThroughFallbackMaster(inbound) + hostEps := s.hostEndpoints(inbound, "raw") + var out []string + seen := make(map[string]struct{}, len(clients)) + for _, client := range clients { + key := strings.ToLower(client.Email) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + var link string + if len(hostEps) > 0 { + link = s.linkFromHosts(inbound, client, hostEps) + } else { + link = s.GetLink(inbound, client.Email) + } + out = append(out, splitLinkLines(link)...) + } + return out +} + // AggregateTrafficByEmails resolves traffic for every email in one // query and folds the rows into a single ClientTraffic + lastOnline. // xray.ClientTraffic.Email is globally unique, so a multi-inbound diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index d6322f56c..fe95b075c 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -65,6 +65,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.getInbounds) g.GET("/list/slim", a.getInboundsSlim) g.GET("/options", a.getInboundOptions) + g.GET("/allLinks", a.getAllInboundLinks) g.GET("/get/:id", a.getInbound) g.GET("/:id/fallbacks", a.getFallbacks) @@ -104,6 +105,19 @@ func (a *InboundController) getInboundsSlim(c *gin.Context) { jsonObj(c, inbounds, nil) } +// getAllInboundLinks returns every inbound's share links across all clients, +// rendered through the same subscription engine the client pages use so the +// remark template (name-only display part) is applied consistently. +func (a *InboundController) getAllInboundLinks(c *gin.Context) { + user := session.GetLoginUser(c) + links, err := a.inboundService.GetAllInboundLinks(resolveHost(c), user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, links, nil) +} + // getInboundOptions returns a lightweight projection of the user's inbounds // (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI. // Avoids shipping per-client settings and traffic stats just to fill a dropdown. diff --git a/internal/web/service/inbound_sublink.go b/internal/web/service/inbound_sublink.go index 6ad24f387..7fe8b886a 100644 --- a/internal/web/service/inbound_sublink.go +++ b/internal/web/service/inbound_sublink.go @@ -8,6 +8,7 @@ import ( type SubLinkProvider interface { SubLinksForSubId(host, subId string) ([]string, error) LinksForClient(host string, inbound *model.Inbound, email string) []string + LinksForInbounds(host string, inbounds []*model.Inbound) []string } var registeredSubLinkProvider SubLinkProvider @@ -23,6 +24,17 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) { return registeredSubLinkProvider.SubLinksForSubId(host, subId) } +func (s *InboundService) GetAllInboundLinks(host string, userId int) ([]string, error) { + if registeredSubLinkProvider == nil { + return nil, common.NewError("sub link provider not registered") + } + inbounds, err := s.GetInbounds(userId) + if err != nil { + return nil, err + } + return registeredSubLinkProvider.LinksForInbounds(host, inbounds), nil +} + func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) { if email == "" { return nil, common.NewError("client email is required")