mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
abd320994a
* Add enable toggle for external client links * Document external link enable API fields * Extend external client link metadata * Fix external subscription cache status updates * fix(sub): address the review on per-client external link controls Blocking: the expiry filter dropped legacy rows. expiry_time was added without a default, so AutoMigrate makes it nullable and backfills NULL, and `expiry_time = 0 OR expiry_time > ?` is false for NULL under three-valued logic — every external link written before the upgrade vanished from all subscriptions. Add `default:0` on expiry_time and last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs a pre-fix build could already have written. Rework fetch-status recording. It ran inside the singleflight in-flight window, so every goroutine parked on the shared fetch waited for a DB write to commit on the public, unauthenticated subscription path — and because it was keyed on the row id, waiters and cache hits recorded nothing, leaving rows that lost the race stuck on "Not fetched yet" forever. fetchSubscriptionLinks now reports whether it did the network fetch and expandEntry records afterwards, off the serving path, keyed on kind+value so every row sharing the URL is stamped by the one fetch. Keying on value also closes the recycled-rowid hazard: saves delete and re-insert rows, and SQLite reuses rowids, so an in-flight write could land on an unrelated client's row. The write no longer discards its error either. Drop the inert id round-trip. The panel never sent it, and the byId branch was guarded by the exact kind+value equality that byKindValue already keys on, so it could not change an outcome. Matching on kind+value alone is what actually preserves fetch status across saves. Reject a negative expiryTime instead of storing a row that is silently invisible in every subscription — elsewhere a negative expiryTime means "a duration from first use", so an API caller reusing that convention got no error and no links. Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that no component renders; it is leftover from the WireGuard PR this one was split from. i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire instead of shipping an English duplicate under pages.clients, and translate namePrefix, lastFetchAt, lastFetchError and neverFetched into all 12 non-English locales. Cover the persistence path that had no test: the fetch-status writer over a real DB against a failing then a succeeding server, a cache hit writing nothing, and the negative-expiry rejection. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
package sub
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"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
|
||
NamePrefix 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
|
||
now := time.Now().UnixMilli()
|
||
if err := db.Where("client_id IN ?", clientIds).
|
||
Where("(enable IS NULL OR enable = ?)", true).
|
||
Where("(expiry_time IS NULL OR expiry_time <= 0 OR expiry_time > ?)", now).
|
||
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,
|
||
NamePrefix: r.NamePrefix,
|
||
Email: rec.Email,
|
||
Enable: rec.Enable,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// expandEntry turns one entry into the concrete share links it contributes.
|
||
// Names are never blank, so Clash/JSON do not fall back to the client email.
|
||
func expandEntry(e externalLinkEntry) []expandedLink {
|
||
if e.Kind == model.ExternalLinkKindSubscription {
|
||
res := fetchSubscriptionLinks(e.Value)
|
||
if res.fetched {
|
||
recordExternalSubscriptionFetch(e.Value, res.err)
|
||
}
|
||
out := make([]expandedLink, 0, len(res.links))
|
||
for _, l := range res.links {
|
||
out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)})
|
||
}
|
||
return out
|
||
}
|
||
name := strings.TrimSpace(e.Remark)
|
||
if name == "" {
|
||
name = linkDisplayName(e.Value)
|
||
}
|
||
return []expandedLink{{Link: e.Value, Name: name}}
|
||
}
|
||
|
||
// linkDisplayName extracts the human-readable name already carried by a share
|
||
// link: vmess JSON `ps`, or the URL #fragment for every other scheme.
|
||
func linkDisplayName(rawLink string) string {
|
||
rawLink = strings.TrimSpace(rawLink)
|
||
if rawLink == "" {
|
||
return ""
|
||
}
|
||
if after, ok := strings.CutPrefix(rawLink, "vmess://"); ok {
|
||
b64 := after
|
||
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
|
||
if err != nil {
|
||
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
|
||
}
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
var j map[string]any
|
||
if err := json.Unmarshal(raw, &j); err != nil {
|
||
return ""
|
||
}
|
||
if ps, ok := j["ps"].(string); ok {
|
||
return strings.TrimSpace(ps)
|
||
}
|
||
return ""
|
||
}
|
||
if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
|
||
frag := rawLink[i+1:]
|
||
if decoded, err := url.PathUnescape(frag); err == nil {
|
||
return strings.TrimSpace(decoded)
|
||
}
|
||
return strings.TrimSpace(frag)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// prefixedLinkName falls back to the client email so a prefixed row never
|
||
// renders as the bare prefix when the link carries no name of its own.
|
||
func prefixedLinkName(displayName, prefix, fallback string) string {
|
||
if strings.TrimSpace(prefix) == "" {
|
||
return displayName
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = strings.TrimSpace(fallback)
|
||
}
|
||
return prefix + name
|
||
}
|
||
|
||
// 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)
|
||
}
|