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>
This commit is contained in:
jason zhang
2026-08-18 19:55:04 +08:00
committed by GitHub
parent 708a69acde
commit abd320994a
29 changed files with 728 additions and 84 deletions
+35 -7
View File
@@ -14,9 +14,12 @@ import (
// 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"`
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) {
@@ -55,11 +58,21 @@ func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalL
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),
SortIndex: len(out),
Kind: kind,
Value: value,
Remark: strings.TrimSpace(in.Remark),
Enable: &enable,
ExpiryTime: in.ExpiryTime,
NamePrefix: in.NamePrefix,
SortIndex: len(out),
})
}
return out, nil
@@ -78,10 +91,25 @@ func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkI
}
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
@@ -0,0 +1,124 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func externalLinkBool(v bool) *bool {
return &v
}
func TestSetExternalLinksPersistsEnableState(t *testing.T) {
setupBulkDB(t)
db := database.GetDB()
svc := &ClientService{}
rec := model.ClientRecord{Email: "links@example.com", SubID: "sub-links", UUID: "uuid", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.com:443#on", Remark: "Primary", Enable: externalLinkBool(true), ExpiryTime: 1767225600000},
{Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: externalLinkBool(false), NamePrefix: "[zjh] "},
{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.net:443#default"},
}); err != nil {
t.Fatalf("set external links: %v", err)
}
rows, err := svc.GetExternalLinksForRecord(rec.Id)
if err != nil {
t.Fatalf("get external links: %v", err)
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3", len(rows))
}
if rows[0].Enable == nil || *rows[0].Enable != true {
t.Fatalf("first row enable = %#v, want true", rows[0].Enable)
}
if rows[1].Enable == nil || *rows[1].Enable != false {
t.Fatalf("second row enable = %#v, want false", rows[1].Enable)
}
if rows[2].Enable == nil || *rows[2].Enable != true {
t.Fatalf("omitted enable should default true, got %#v", rows[2].Enable)
}
if rows[0].Remark != "Primary" || rows[0].ExpiryTime != 1767225600000 {
t.Fatalf("first row fields not persisted: %#v", rows[0])
}
if rows[1].Remark != "Provider" || rows[1].NamePrefix != "[zjh] " {
t.Fatalf("subscription fields not persisted: %#v", rows[1])
}
}
func TestSetExternalLinksPreservesFetchStatus(t *testing.T) {
setupBulkDB(t)
db := database.GetDB()
svc := &ClientService{}
rec := model.ClientRecord{Email: "status@example.com", SubID: "sub-status", UUID: "uuid", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
row := model.ClientExternalLink{
ClientId: rec.Id,
Kind: model.ExternalLinkKindSubscription,
Value: "https://provider.example/sub",
Remark: "old",
LastFetchAt: 1767220000000,
LastFetchError: "timeout",
SortIndex: 0,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create external link: %v", err)
}
if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
{Kind: row.Kind, Value: row.Value, Remark: "new", Enable: externalLinkBool(true)},
}); err != nil {
t.Fatalf("set external links: %v", err)
}
rows, err := svc.GetExternalLinksForRecord(rec.Id)
if err != nil {
t.Fatalf("get external links: %v", err)
}
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
if rows[0].LastFetchAt != row.LastFetchAt || rows[0].LastFetchError != row.LastFetchError {
t.Fatalf("fetch status not preserved: %#v", rows[0])
}
if rows[0].Remark != "new" {
t.Fatalf("editable fields not updated: %#v", rows[0])
}
}
func TestSetExternalLinksRejectsNegativeExpiry(t *testing.T) {
setupBulkDB(t)
db := database.GetDB()
svc := &ClientService{}
rec := model.ClientRecord{Email: "negative@example.com", SubID: "sub-negative", UUID: "uuid", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@example.com:443#neg", ExpiryTime: -86400000},
})
want := "external link expiryTime must be 0 (never) or a future unix millisecond timestamp: trojan://pw@example.com:443#neg\n"
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
rows, err := svc.GetExternalLinksForRecord(rec.Id)
if err != nil {
t.Fatalf("get external links: %v", err)
}
if len(rows) != 0 {
t.Fatalf("rows = %d, want the rejected save to persist nothing", len(rows))
}
}
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "إضافة اشتراك خارجي",
"noExternalLinks": "لا توجد روابط خارجية بعد.",
"noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
"namePrefix": "بادئة الاسم",
"lastFetchAt": "آخر جلب",
"lastFetchError": "خطأ في الجلب",
"neverFetched": "لم يتم الجلب بعد",
"submitEdit": "حفظ التغييرات",
"clientCount": "عدد العملاء",
"bulk": "إضافة مجمعة",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Add External Subscription",
"noExternalLinks": "No external links yet.",
"noExternalSubscriptions": "No external subscriptions yet.",
"namePrefix": "Name prefix",
"lastFetchAt": "Last fetch",
"lastFetchError": "Fetch error",
"neverFetched": "Not fetched yet",
"submitEdit": "Save Changes",
"clientCount": "Number of Clients",
"bulk": "Add Bulk",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Añadir suscripción externa",
"noExternalLinks": "Aún no hay enlaces externos.",
"noExternalSubscriptions": "Aún no hay suscripciones externas.",
"namePrefix": "Prefijo de nombre",
"lastFetchAt": "Última obtención",
"lastFetchError": "Error de obtención",
"neverFetched": "Aún no obtenido",
"submitEdit": "Guardar cambios",
"clientCount": "Número de clientes",
"bulk": "Añadir en lote",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "افزودن سابسکریپشن خارجی",
"noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
"noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
"namePrefix": "پیشوند نام",
"lastFetchAt": "آخرین دریافت",
"lastFetchError": "خطای دریافت",
"neverFetched": "هنوز دریافت نشده",
"submitEdit": "ذخیره تغییرات",
"clientCount": "تعداد کلاینت‌ها",
"bulk": "افزودن گروهی",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Tambah Langganan Eksternal",
"noExternalLinks": "Belum ada tautan eksternal.",
"noExternalSubscriptions": "Belum ada langganan eksternal.",
"namePrefix": "Awalan nama",
"lastFetchAt": "Pengambilan terakhir",
"lastFetchError": "Galat pengambilan",
"neverFetched": "Belum diambil",
"submitEdit": "Simpan perubahan",
"clientCount": "Jumlah klien",
"bulk": "Tambah massal",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "外部サブスクリプションを追加",
"noExternalLinks": "外部リンクはまだありません。",
"noExternalSubscriptions": "外部サブスクリプションはまだありません。",
"namePrefix": "名前の接頭辞",
"lastFetchAt": "最終取得",
"lastFetchError": "取得エラー",
"neverFetched": "未取得",
"submitEdit": "変更を保存",
"clientCount": "クライアント数",
"bulk": "一括追加",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Adicionar assinatura externa",
"noExternalLinks": "Ainda não há links externos.",
"noExternalSubscriptions": "Ainda não há assinaturas externas.",
"namePrefix": "Prefixo do nome",
"lastFetchAt": "Última busca",
"lastFetchError": "Erro na busca",
"neverFetched": "Ainda não buscado",
"submitEdit": "Salvar alterações",
"clientCount": "Número de clientes",
"bulk": "Adicionar em lote",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Добавить внешнюю подписку",
"noExternalLinks": "Пока нет внешних ссылок.",
"noExternalSubscriptions": "Пока нет внешних подписок.",
"namePrefix": "Префикс имени",
"lastFetchAt": "Последнее обновление",
"lastFetchError": "Ошибка обновления",
"neverFetched": "Ещё не загружено",
"submitEdit": "Сохранить изменения",
"clientCount": "Количество клиентов",
"bulk": "Массовое добавление",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Harici Abonelik Ekle",
"noExternalLinks": "Henüz harici bağlantı yok.",
"noExternalSubscriptions": "Henüz harici abonelik yok.",
"namePrefix": "Ad öneki",
"lastFetchAt": "Son çekme",
"lastFetchError": "Çekme hatası",
"neverFetched": "Henüz çekilmedi",
"submitEdit": "Değişiklikleri Kaydet",
"clientCount": "Kullanıcı Sayısı",
"bulk": "Toplu Ekle",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Додати зовнішню підписку",
"noExternalLinks": "Зовнішніх посилань ще немає.",
"noExternalSubscriptions": "Зовнішніх підписок ще немає.",
"namePrefix": "Префікс імені",
"lastFetchAt": "Останнє оновлення",
"lastFetchError": "Помилка оновлення",
"neverFetched": "Ще не завантажено",
"submitEdit": "Зберегти зміни",
"clientCount": "Кількість клієнтів",
"bulk": "Масове додавання",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "Thêm đăng ký ngoài",
"noExternalLinks": "Chưa có liên kết ngoài.",
"noExternalSubscriptions": "Chưa có đăng ký ngoài.",
"namePrefix": "Tiền tố tên",
"lastFetchAt": "Lần tải gần nhất",
"lastFetchError": "Lỗi tải",
"neverFetched": "Chưa tải",
"submitEdit": "Lưu thay đổi",
"clientCount": "Số lượng khách hàng",
"bulk": "Thêm hàng loạt",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "添加外部订阅",
"noExternalLinks": "暂无外部链接。",
"noExternalSubscriptions": "暂无外部订阅。",
"namePrefix": "名称前缀",
"lastFetchAt": "最后拉取",
"lastFetchError": "拉取失败",
"neverFetched": "尚未拉取",
"submitEdit": "保存更改",
"clientCount": "客户端数量",
"bulk": "批量添加",
+4
View File
@@ -693,6 +693,10 @@
"addExternalSubscription": "新增外部訂閱",
"noExternalLinks": "尚無外部連結。",
"noExternalSubscriptions": "尚無外部訂閱。",
"namePrefix": "名稱前綴",
"lastFetchAt": "最後拉取",
"lastFetchError": "拉取失敗",
"neverFetched": "尚未拉取",
"submitEdit": "儲存變更",
"clientCount": "客戶端數量",
"bulk": "批次新增",