Files
3x-ui/internal/web/service/client_external_link.go
T
jason zhang abd320994a Add per-client external link controls (#5650)
* 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>
2026-08-18 13:55:04 +02:00

132 lines
3.9 KiB
Go

package service
import (
"net/url"
"strings"
"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/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
"gorm.io/gorm"
)
// ExternalLinkInput is one row from the client form's Links tab.
type ExternalLinkInput struct {
Kind string `json:"kind"`
Value string `json:"value"`
Remark string `json:"remark"`
Enable *bool `json:"enable"`
ExpiryTime int64 `json:"expiryTime"`
NamePrefix string `json:"namePrefix"`
}
func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
var rows []model.ClientExternalLink
if err := database.GetDB().
Where("client_id = ?", id).
Order("sort_index ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// normalizeExternalLinks validates and orders the incoming rows. A "link" must
// parse to a supported share-link scheme; a "subscription" must be an http(s)
// URL. Blank values are dropped; an invalid value is a hard error so the
// operator gets immediate feedback instead of a silently missing config.
func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalLink, error) {
out := make([]model.ClientExternalLink, 0, len(inputs))
for _, in := range inputs {
value := strings.TrimSpace(in.Value)
if value == "" {
continue
}
kind := strings.TrimSpace(in.Kind)
switch kind {
case model.ExternalLinkKindSubscription:
if !isHTTPURL(value) {
return nil, common.NewError("external subscription must be an http(s) URL: " + value)
}
case model.ExternalLinkKindLink, "":
kind = model.ExternalLinkKindLink
if _, err := link.ParseLink(value); err != nil {
return nil, common.NewError("unsupported or invalid share link: " + value)
}
default:
return nil, common.NewError("unknown external link kind: " + kind)
}
if in.ExpiryTime < 0 {
return nil, common.NewError("external link expiryTime must be 0 (never) or a future unix millisecond timestamp: " + value)
}
enable := true
if in.Enable != nil {
enable = *in.Enable
}
out = append(out, model.ClientExternalLink{
Kind: kind,
Value: value,
Remark: strings.TrimSpace(in.Remark),
Enable: &enable,
ExpiryTime: in.ExpiryTime,
NamePrefix: in.NamePrefix,
SortIndex: len(out),
})
}
return out, nil
}
func isHTTPURL(s string) bool {
u, err := url.Parse(s)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// SetExternalLinksForRecord replaces a client's entire external-link set.
func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkInput) error {
rows, err := normalizeExternalLinks(inputs)
if err != nil {
return err
}
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
var existing []model.ClientExternalLink
if err := tx.Where("client_id = ?", id).Find(&existing).Error; err != nil {
return err
}
byKindValue := make(map[string]model.ClientExternalLink, len(existing))
for _, row := range existing {
key := row.Kind + "\x00" + row.Value
if _, ok := byKindValue[key]; !ok {
byKindValue[key] = row
}
}
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
return err
}
for i := range rows {
if old, ok := byKindValue[rows[i].Kind+"\x00"+rows[i].Value]; ok {
rows[i].LastFetchAt = old.LastFetchAt
rows[i].LastFetchError = old.LastFetchError
}
rows[i].ClientId = id
if err := tx.Create(&rows[i]).Error; err != nil {
return err
}
}
return nil
})
}
func (s *ClientService) SetExternalLinksByEmail(email string, inputs []ExternalLinkInput) error {
if strings.TrimSpace(email) == "" {
return common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return err
}
return s.SetExternalLinksForRecord(rec.Id, inputs)
}