浏览代码

feat(inbounds): apply remark template to Export all inbound links

Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark.

Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user.
MHSanaei 16 小时之前
父节点
当前提交
439245d42b

+ 37 - 0
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": [

+ 8 - 0
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',

+ 4 - 15
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(

+ 43 - 0
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)
+	}
+}

+ 9 - 0
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

+ 31 - 0
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

+ 14 - 0
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.

+ 12 - 0
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")