mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +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>
220 lines
6.1 KiB
Go
220 lines
6.1 KiB
Go
package sub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// External subscription fetching: a "subscription" external link is a remote
|
|
// URL whose body is a (often base64-encoded) newline list of share links. We
|
|
// fetch it on demand, cache the decoded links briefly, and bound the request
|
|
// with a short timeout so a slow/dead provider can't stall a client's sub.
|
|
|
|
const (
|
|
subscriptionCacheTTL = 5 * time.Minute
|
|
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
|
subscriptionCacheCapacity = 256
|
|
)
|
|
|
|
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
|
|
|
type subscriptionCacheEntry struct {
|
|
links []string
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
type subscriptionFetch struct {
|
|
done chan struct{}
|
|
links []string
|
|
}
|
|
|
|
var subscriptionCache = struct {
|
|
sync.Mutex
|
|
m map[string]subscriptionCacheEntry
|
|
inflight map[string]*subscriptionFetch
|
|
}{
|
|
m: make(map[string]subscriptionCacheEntry),
|
|
inflight: make(map[string]*subscriptionFetch),
|
|
}
|
|
|
|
// subscriptionFetchResult reports whether this caller performed the network
|
|
// fetch, so only it records status and cache hits stay read-only.
|
|
type subscriptionFetchResult struct {
|
|
links []string
|
|
fetched bool
|
|
err error
|
|
}
|
|
|
|
// fetchSubscriptionLinks returns the share links contained in a remote
|
|
// subscription URL, using a short-lived cache. On any failure it returns the
|
|
// last cached value (if present) or nil — never an error, so the rest of the
|
|
// client's subscription still renders.
|
|
func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return subscriptionFetchResult{}
|
|
}
|
|
|
|
subscriptionCache.Lock()
|
|
cached, ok := subscriptionCache.m[rawURL]
|
|
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
|
|
subscriptionCache.Unlock()
|
|
return subscriptionFetchResult{links: cached.links}
|
|
}
|
|
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
|
|
subscriptionCache.Unlock()
|
|
<-fetch.done
|
|
return subscriptionFetchResult{links: fetch.links}
|
|
}
|
|
fetch := &subscriptionFetch{done: make(chan struct{})}
|
|
subscriptionCache.inflight[rawURL] = fetch
|
|
subscriptionCache.Unlock()
|
|
defer func() {
|
|
subscriptionCache.Lock()
|
|
close(fetch.done)
|
|
delete(subscriptionCache.inflight, rawURL)
|
|
subscriptionCache.Unlock()
|
|
}()
|
|
|
|
links, err := doFetchSubscriptionLinks(rawURL)
|
|
if err != nil {
|
|
if ok {
|
|
fetch.links = cached.links
|
|
}
|
|
return subscriptionFetchResult{links: fetch.links, fetched: true, err: err}
|
|
}
|
|
|
|
subscriptionCache.Lock()
|
|
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
|
|
trimSubscriptionCacheLocked(rawURL)
|
|
subscriptionCache.Unlock()
|
|
fetch.links = links
|
|
return subscriptionFetchResult{links: links, fetched: true}
|
|
}
|
|
|
|
func trimSubscriptionCacheLocked(keep string) {
|
|
for len(subscriptionCache.m) > subscriptionCacheCapacity {
|
|
var oldestURL string
|
|
var oldest time.Time
|
|
for rawURL, entry := range subscriptionCache.m {
|
|
if rawURL == keep {
|
|
continue
|
|
}
|
|
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
|
|
oldestURL = rawURL
|
|
oldest = entry.fetchedAt
|
|
}
|
|
}
|
|
if oldestURL == "" {
|
|
return
|
|
}
|
|
delete(subscriptionCache.m, oldestURL)
|
|
}
|
|
}
|
|
|
|
// recordExternalSubscriptionFetch stamps status on every row holding this URL,
|
|
// keyed by value because row ids churn on save and the cache is per URL.
|
|
func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return
|
|
}
|
|
lastFetchError := ""
|
|
if fetchErr != nil {
|
|
lastFetchError = fetchErr.Error()
|
|
}
|
|
if err := database.GetDB().
|
|
Model(&model.ClientExternalLink{}).
|
|
Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL).
|
|
Updates(map[string]any{
|
|
"last_fetch_at": time.Now().UnixMilli(),
|
|
"last_fetch_error": lastFetchError,
|
|
}).Error; err != nil {
|
|
logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
|
|
}
|
|
}
|
|
|
|
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Some providers gate the link body on a known client User-Agent.
|
|
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
|
|
resp, err := subscriptionHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, errBadStatus
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > subscriptionMaxBytes {
|
|
return nil, errSubscriptionBodyTooLarge
|
|
}
|
|
return decodeSubscriptionBody(body), nil
|
|
}
|
|
|
|
var (
|
|
errBadStatus = &subError{"non-2xx subscription response"}
|
|
errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
|
|
)
|
|
|
|
type subError struct{ msg string }
|
|
|
|
func (e *subError) Error() string { return e.msg }
|
|
|
|
// decodeSubscriptionBody handles the common base64-encoded newline list as well
|
|
// as a plain-text body, returning only the lines that look like share links.
|
|
func decodeSubscriptionBody(body []byte) []string {
|
|
text := strings.TrimSpace(string(body))
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
if decoded, ok := tryDecodeBase64Body(text); ok {
|
|
text = strings.TrimSpace(decoded)
|
|
}
|
|
lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
|
|
out := make([]string, 0, len(lines))
|
|
for _, ln := range lines {
|
|
ln = strings.TrimSpace(ln)
|
|
if ln == "" || strings.HasPrefix(ln, "#") {
|
|
continue
|
|
}
|
|
if strings.Contains(ln, "://") {
|
|
out = append(out, ln)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tryDecodeBase64Body(s string) (string, bool) {
|
|
clean := strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case ' ', '\n', '\r', '\t':
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
|
|
return string(b), true
|
|
}
|
|
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
|
|
return string(b), true
|
|
}
|
|
return "", false
|
|
}
|