feat(sub): per-client external links and remote subscriptions

Add a Links tab to the client form for attaching third-party share
links and remote subscription URLs per client. They are merged into
the client's raw/JSON/Clash subscription output: links are emitted
verbatim and parsed for JSON/Clash; subscription URLs are fetched
(cached, with a short timeout) and their configs merged in.

i18n keys added across all 13 locales.
This commit is contained in:
MHSanaei
2026-06-14 20:57:14 +02:00
parent 7c2598fae9
commit dcb923b4a1
33 changed files with 1204 additions and 28 deletions
+160
View File
@@ -0,0 +1,160 @@
package sub
import (
"encoding/base64"
"net/url"
"strings"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
)
// externalLinkEntry is one client × external-link row, resolved for a
// subscription request. Email/Enable come from the owning client.
type externalLinkEntry struct {
Kind string
Value string
Remark string
Email string
Enable bool
}
// expandedLink is a single share link contributed by an entry, with the display
// name to use (empty → keep the link's own remark / fall back to the email).
type expandedLink struct {
Link string
Name string
}
// getClientExternalLinksBySubId returns every external-link row attached to a
// client that carries the given subId, in stable order. Stays inside
// internal/sub + database + util/link — no dependency on the panel service layer.
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
db := database.GetDB()
var recs []model.ClientRecord
if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
return nil, err
}
if len(recs) == 0 {
return nil, nil
}
clientIds := make([]int, 0, len(recs))
byId := make(map[int]model.ClientRecord, len(recs))
for _, rec := range recs {
clientIds = append(clientIds, rec.Id)
byId[rec.Id] = rec
}
var rows []model.ClientExternalLink
if err := db.Where("client_id IN ?", clientIds).
Order("client_id ASC, sort_index ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
out := make([]externalLinkEntry, 0, len(rows))
for _, r := range rows {
rec := byId[r.ClientId]
out = append(out, externalLinkEntry{
Kind: r.Kind,
Value: r.Value,
Remark: r.Remark,
Email: rec.Email,
Enable: rec.Enable,
})
}
return out, nil
}
// expandEntry turns one entry into the concrete share links it contributes. A
// "subscription" entry is fetched (cached) and its links are kept with their own
// names; a "link" entry yields the single link with the row's remark.
func expandEntry(e externalLinkEntry) []expandedLink {
if e.Kind == model.ExternalLinkKindSubscription {
links := fetchSubscriptionLinks(e.Value)
out := make([]expandedLink, 0, len(links))
for _, l := range links {
out = append(out, expandedLink{Link: l, Name: ""})
}
return out
}
return []expandedLink{{Link: e.Value, Name: e.Remark}}
}
// applyRemarkToLink rewrites a share link's display name to remark (when set),
// leaving everything else byte-for-byte. vmess carries its remark in the base64
// JSON `ps`; every other scheme carries it in the URL #fragment.
func applyRemarkToLink(rawLink, remark string) string {
rawLink = strings.TrimSpace(rawLink)
if remark == "" {
return rawLink
}
if strings.HasPrefix(rawLink, "vmess://") {
return applyVmessRemark(rawLink, remark)
}
if i := strings.IndexByte(rawLink, '#'); i >= 0 {
rawLink = rawLink[:i]
}
return rawLink + "#" + url.PathEscape(remark)
}
func applyVmessRemark(rawLink, remark string) string {
b64 := strings.TrimPrefix(rawLink, "vmess://")
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
}
if err != nil {
return rawLink
}
var j map[string]any
if err := json.Unmarshal(raw, &j); err != nil {
return rawLink
}
j["ps"] = remark
nb, err := json.Marshal(j)
if err != nil {
return rawLink
}
return "vmess://" + base64.StdEncoding.EncodeToString(nb)
}
func padBase64Sub(s string) string {
for len(s)%4 != 0 {
s += "="
}
return s
}
// parsedExternalOutbound turns a pasted share link into a structured Xray
// outbound (tagged "proxy") for the JSON subscription. Returns nil when the
// link can't be parsed — the caller skips it.
func parsedExternalOutbound(rawLink string) json_util.RawMessage {
ob := parseExternalLink(rawLink)
if ob == nil {
return nil
}
ob["tag"] = "proxy"
b, err := json.MarshalIndent(ob, "", " ")
if err != nil {
return nil
}
return b
}
// parseExternalLink parses a share link into the Xray outbound wire shape
// (map), or nil if unsupported/invalid.
func parseExternalLink(rawLink string) map[string]any {
res, err := link.ParseLink(strings.TrimSpace(rawLink))
if err != nil || res == nil || res.Outbound == nil {
return nil
}
return map[string]any(res.Outbound)
}