mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-17 01:56:06 +00:00
feat(sub): per-client external links and remote subscriptions
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.
This commit is contained in:
@@ -59,6 +59,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
g.POST("/:email/externalLinks", a.setExternalLinks)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
g.POST("/bulkAdjust", a.bulkAdjust)
|
||||
@@ -112,6 +113,11 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
@@ -125,7 +131,7 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
@@ -185,6 +191,10 @@ type attachDetachBody struct {
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
type externalLinksBody struct {
|
||||
ExternalLinks []service.ExternalLinkInput `json:"externalLinks"`
|
||||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
@@ -204,6 +214,21 @@ func (a *ClientController) attach(c *gin.Context) {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) setExternalLinks(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body externalLinksBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.clientService.SetExternalLinksByEmail(email, body.ExternalLinks); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
||||
needRestart, err := a.clientService.ResetAllTraffics()
|
||||
if err != nil {
|
||||
|
||||
@@ -407,6 +407,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
out = append(out, model.ClientExternalLink{
|
||||
Kind: kind,
|
||||
Value: value,
|
||||
Remark: strings.TrimSpace(in.Remark),
|
||||
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 {
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
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)
|
||||
}
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "أساسي",
|
||||
"tabCredentials": "بيانات الاعتماد",
|
||||
"tabLinks": "الروابط",
|
||||
"linksHint": "أضف روابط مشاركة من جهات خارجية وعناوين اشتراك خارجية لتضمينها في اشتراك هذا العميل.",
|
||||
"addExternalLink": "إضافة رابط خارجي",
|
||||
"addExternalSubscription": "إضافة اشتراك خارجي",
|
||||
"noExternalLinks": "لا توجد روابط خارجية بعد.",
|
||||
"noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
|
||||
"add": "إضافة عميل",
|
||||
"edit": "تعديل العميل",
|
||||
"submitAdd": "إضافة عميل",
|
||||
|
||||
@@ -648,6 +648,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Basics",
|
||||
"tabCredentials": "Credentials",
|
||||
"tabLinks": "Links",
|
||||
"linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
|
||||
"addExternalLink": "Add External Link",
|
||||
"addExternalSubscription": "Add External Subscription",
|
||||
"noExternalLinks": "No external links yet.",
|
||||
"noExternalSubscriptions": "No external subscriptions yet.",
|
||||
"add": "Add Client",
|
||||
"edit": "Edit Client",
|
||||
"submitAdd": "Add Client",
|
||||
@@ -1765,4 +1771,4 @@
|
||||
"chooseInbound": "Choose an Inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Básico",
|
||||
"tabCredentials": "Credenciales",
|
||||
"tabLinks": "Enlaces",
|
||||
"linksHint": "Añade enlaces de terceros y URLs de suscripción remotas para incluirlos en la suscripción de este cliente.",
|
||||
"addExternalLink": "Añadir enlace externo",
|
||||
"addExternalSubscription": "Añadir suscripción externa",
|
||||
"noExternalLinks": "Aún no hay enlaces externos.",
|
||||
"noExternalSubscriptions": "Aún no hay suscripciones externas.",
|
||||
"add": "Añadir cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Añadir cliente",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "پایه",
|
||||
"tabCredentials": "اطلاعات اتصال",
|
||||
"tabLinks": "لینکها",
|
||||
"linksHint": "لینکهای اشتراک شخصثالث و آدرس سابسکریپشنهای خارجی را اضافه کنید تا در سابسکریپشن این کاربر قرار گیرند.",
|
||||
"addExternalLink": "افزودن لینک خارجی",
|
||||
"addExternalSubscription": "افزودن سابسکریپشن خارجی",
|
||||
"noExternalLinks": "هنوز لینک خارجیای اضافه نشده.",
|
||||
"noExternalSubscriptions": "هنوز سابسکریپشن خارجیای اضافه نشده.",
|
||||
"add": "افزودن کلاینت",
|
||||
"edit": "ویرایش کلاینت",
|
||||
"submitAdd": "افزودن کلاینت",
|
||||
@@ -1764,4 +1770,4 @@
|
||||
"chooseInbound": "یک ورودی انتخاب کنید"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Dasar",
|
||||
"tabCredentials": "Kredensial",
|
||||
"tabLinks": "Tautan",
|
||||
"linksHint": "Tambahkan tautan berbagi pihak ketiga dan URL langganan jarak jauh untuk disertakan dalam langganan klien ini.",
|
||||
"addExternalLink": "Tambah Tautan Eksternal",
|
||||
"addExternalSubscription": "Tambah Langganan Eksternal",
|
||||
"noExternalLinks": "Belum ada tautan eksternal.",
|
||||
"noExternalSubscriptions": "Belum ada langganan eksternal.",
|
||||
"add": "Tambah klien",
|
||||
"edit": "Ubah klien",
|
||||
"submitAdd": "Tambah klien",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "認証情報",
|
||||
"tabLinks": "リンク",
|
||||
"linksHint": "サードパーティの共有リンクやリモートのサブスクリプションURLを追加して、このクライアントのサブスクリプションに含めます。",
|
||||
"addExternalLink": "外部リンクを追加",
|
||||
"addExternalSubscription": "外部サブスクリプションを追加",
|
||||
"noExternalLinks": "外部リンクはまだありません。",
|
||||
"noExternalSubscriptions": "外部サブスクリプションはまだありません。",
|
||||
"add": "クライアントを追加",
|
||||
"edit": "クライアントを編集",
|
||||
"submitAdd": "クライアントを追加",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Básico",
|
||||
"tabCredentials": "Credenciais",
|
||||
"tabLinks": "Links",
|
||||
"linksHint": "Adicione links de terceiros e URLs de assinatura remotas para incluir na assinatura deste cliente.",
|
||||
"addExternalLink": "Adicionar link externo",
|
||||
"addExternalSubscription": "Adicionar assinatura externa",
|
||||
"noExternalLinks": "Ainda não há links externos.",
|
||||
"noExternalSubscriptions": "Ainda não há assinaturas externas.",
|
||||
"add": "Adicionar cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Adicionar cliente",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Основные",
|
||||
"tabCredentials": "Учетные данные",
|
||||
"tabLinks": "Ссылки",
|
||||
"linksHint": "Добавьте сторонние ссылки и URL удалённых подписок, чтобы включить их в подписку этого клиента.",
|
||||
"addExternalLink": "Добавить внешнюю ссылку",
|
||||
"addExternalSubscription": "Добавить внешнюю подписку",
|
||||
"noExternalLinks": "Пока нет внешних ссылок.",
|
||||
"noExternalSubscriptions": "Пока нет внешних подписок.",
|
||||
"add": "Добавить клиента",
|
||||
"edit": "Изменить клиента",
|
||||
"submitAdd": "Добавить клиента",
|
||||
|
||||
@@ -648,6 +648,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Temel",
|
||||
"tabCredentials": "Kimlik Bilgileri",
|
||||
"tabLinks": "Bağlantılar",
|
||||
"linksHint": "Bu istemcinin aboneliğine dahil etmek için üçüncü taraf paylaşım bağlantıları ve uzak abonelik URL'leri ekleyin.",
|
||||
"addExternalLink": "Harici Bağlantı Ekle",
|
||||
"addExternalSubscription": "Harici Abonelik Ekle",
|
||||
"noExternalLinks": "Henüz harici bağlantı yok.",
|
||||
"noExternalSubscriptions": "Henüz harici abonelik yok.",
|
||||
"add": "Kullanıcı Ekle",
|
||||
"edit": "Kullanıcıyı Düzenle",
|
||||
"submitAdd": "Kullanıcı Ekle",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Основні",
|
||||
"tabCredentials": "Облікові дані",
|
||||
"tabLinks": "Посилання",
|
||||
"linksHint": "Додайте сторонні посилання та URL віддалених підписок, щоб включити їх до підписки цього клієнта.",
|
||||
"addExternalLink": "Додати зовнішнє посилання",
|
||||
"addExternalSubscription": "Додати зовнішню підписку",
|
||||
"noExternalLinks": "Зовнішніх посилань ще немає.",
|
||||
"noExternalSubscriptions": "Зовнішніх підписок ще немає.",
|
||||
"add": "Додати клієнта",
|
||||
"edit": "Редагувати клієнта",
|
||||
"submitAdd": "Додати клієнта",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "Cơ bản",
|
||||
"tabCredentials": "Thông tin xác thực",
|
||||
"tabLinks": "Liên kết",
|
||||
"linksHint": "Thêm liên kết chia sẻ của bên thứ ba và URL đăng ký từ xa để đưa vào đăng ký của khách hàng này.",
|
||||
"addExternalLink": "Thêm liên kết ngoài",
|
||||
"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.",
|
||||
"add": "Thêm khách hàng",
|
||||
"edit": "Chỉnh sửa khách hàng",
|
||||
"submitAdd": "Thêm khách hàng",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "凭据",
|
||||
"tabLinks": "链接",
|
||||
"linksHint": "添加第三方分享链接和远程订阅地址,将其包含在该客户端的订阅中。",
|
||||
"addExternalLink": "添加外部链接",
|
||||
"addExternalSubscription": "添加外部订阅",
|
||||
"noExternalLinks": "暂无外部链接。",
|
||||
"noExternalSubscriptions": "暂无外部订阅。",
|
||||
"add": "添加客户端",
|
||||
"edit": "编辑客户端",
|
||||
"submitAdd": "添加客户端",
|
||||
|
||||
@@ -647,6 +647,12 @@
|
||||
"clients": {
|
||||
"tabBasics": "基本",
|
||||
"tabCredentials": "認證資訊",
|
||||
"tabLinks": "連結",
|
||||
"linksHint": "新增第三方分享連結和遠端訂閱網址,將其包含在此用戶端的訂閱中。",
|
||||
"addExternalLink": "新增外部連結",
|
||||
"addExternalSubscription": "新增外部訂閱",
|
||||
"noExternalLinks": "尚無外部連結。",
|
||||
"noExternalSubscriptions": "尚無外部訂閱。",
|
||||
"add": "新增客戶端",
|
||||
"edit": "編輯客戶端",
|
||||
"submitAdd": "新增客戶端",
|
||||
|
||||
Reference in New Issue
Block a user