mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
dcb923b4a1
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.
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
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)
|
||
}
|