mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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.
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user