Files
3x-ui-web-src/internal/web/service/client_external_link.go
T
Sanaei f727d04f65
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
v3.7.0
2026-08-24 15:07:15 +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)
}