262 lines
7.2 KiB
Go
262 lines
7.2 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Item 7 B3: probe metadata editing (minimal set). The fields are the
|
|
// operator-owned display/plan data of probe_meta (ITEM7-SCOPE §5.2),
|
|
// edited from the node page's agent edit dialog and saved through the
|
|
// panel-authenticated agent admin group — effective immediately (the
|
|
// snapshot cache is invalidated; no restart anywhere). Validation
|
|
// mirrors the hub's patch_error chain field for field.
|
|
|
|
type ProbeMetaController struct{}
|
|
|
|
func NewProbeMetaController(g *gin.RouterGroup) *ProbeMetaController {
|
|
pc := &ProbeMetaController{}
|
|
g.GET("/probe-meta/:nodeId", pc.getMeta)
|
|
g.PUT("/probe-meta/:nodeId", pc.putMeta)
|
|
return pc
|
|
}
|
|
|
|
// probeMetaForm is the wire shape of the edit form: the full meta row,
|
|
// replaced wholesale on save.
|
|
type probeMetaForm struct {
|
|
Price float64 `json:"price"`
|
|
Currency string `json:"currency"`
|
|
BillingCycle string `json:"billingCycle"`
|
|
ExpiresAt *string `json:"expiresAt"`
|
|
TrafficLimit int64 `json:"trafficLimit"`
|
|
TrafficMode string `json:"trafficMode"`
|
|
TrafficResetDay int64 `json:"trafficResetDay"`
|
|
Public bool `json:"public"`
|
|
Sort int64 `json:"sort"`
|
|
Group string `json:"group"`
|
|
PublicRemark string `json:"publicRemark"`
|
|
Notify bool `json:"notify"`
|
|
CountryPin string `json:"countryPin"`
|
|
IPv4Pin string `json:"ipv4Pin"`
|
|
IPv6Pin string `json:"ipv6Pin"`
|
|
}
|
|
|
|
func metaToForm(m probe.Meta) probeMetaForm {
|
|
return probeMetaForm{
|
|
Price: m.Price,
|
|
Currency: m.Currency,
|
|
BillingCycle: m.BillingCycle,
|
|
ExpiresAt: m.ExpiresAt,
|
|
TrafficLimit: m.TrafficLimit,
|
|
TrafficMode: m.TrafficMode,
|
|
TrafficResetDay: m.TrafficResetDay,
|
|
Public: m.Public,
|
|
Sort: m.Sort,
|
|
Group: m.GroupName,
|
|
PublicRemark: m.PublicRemark,
|
|
Notify: m.Notify,
|
|
CountryPin: m.CountryPin,
|
|
IPv4Pin: m.IPv4Pin,
|
|
IPv6Pin: m.IPv6Pin,
|
|
}
|
|
}
|
|
|
|
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
|
|
return probe.Meta{
|
|
AgentID: agentID,
|
|
Price: f.Price,
|
|
Currency: f.Currency,
|
|
BillingCycle: f.BillingCycle,
|
|
ExpiresAt: f.ExpiresAt,
|
|
TrafficLimit: f.TrafficLimit,
|
|
TrafficMode: f.TrafficMode,
|
|
TrafficResetDay: f.TrafficResetDay,
|
|
Public: f.Public,
|
|
Sort: f.Sort,
|
|
GroupName: f.Group,
|
|
PublicRemark: f.PublicRemark,
|
|
Notify: f.Notify,
|
|
CountryPin: f.CountryPin,
|
|
IPv4Pin: f.IPv4Pin,
|
|
IPv6Pin: f.IPv6Pin,
|
|
}
|
|
}
|
|
|
|
// agentForNode resolves the agent bound to a panel node row.
|
|
func agentForNode(c *gin.Context) (model.AgentNode, bool) {
|
|
nodeID, err := strconv.Atoi(c.Param("nodeId"))
|
|
if err != nil {
|
|
return model.AgentNode{}, false
|
|
}
|
|
var agent model.AgentNode
|
|
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
|
|
return model.AgentNode{}, false
|
|
}
|
|
return agent, true
|
|
}
|
|
|
|
func (pc *ProbeMetaController) getMeta(c *gin.Context) {
|
|
agent, ok := agentForNode(c)
|
|
if !ok {
|
|
c.JSON(404, gin.H{"message": "agent not found"})
|
|
return
|
|
}
|
|
meta, found, err := probe.MetaFor(int64(agent.Id))
|
|
if err != nil {
|
|
jsonMsg(c, "load probe meta failed", err)
|
|
return
|
|
}
|
|
if !found {
|
|
meta = probe.DefaultMeta(int64(agent.Id))
|
|
}
|
|
jsonObj(c, metaToForm(meta), nil)
|
|
}
|
|
|
|
func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
|
agent, ok := agentForNode(c)
|
|
if !ok {
|
|
c.JSON(404, gin.H{"message": "agent not found"})
|
|
return
|
|
}
|
|
var form probeMetaForm
|
|
if err := c.ShouldBindJSON(&form); err != nil {
|
|
jsonMsg(c, "Invalid request", err)
|
|
return
|
|
}
|
|
if msg := validateProbeMetaForm(&form); msg != "" {
|
|
jsonMsg(c, msg, errRequest)
|
|
return
|
|
}
|
|
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
|
|
jsonMsg(c, "save probe meta failed", err)
|
|
return
|
|
}
|
|
service.InvalidateProbeSnapshot()
|
|
jsonObj(c, gin.H{"message": "ok"}, nil)
|
|
}
|
|
|
|
// ---- hub patch_error ports ----
|
|
|
|
func hasControl(s string) bool {
|
|
for _, r := range s {
|
|
if unicode.IsControl(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var namedCycles = map[string]int64{
|
|
"monthly": 1, "quarterly": 3, "semiannual": 6,
|
|
"yearly": 12, "biennial": 24, "triennial": 36,
|
|
}
|
|
|
|
func cycleMonths(cycle string) (int64, bool) {
|
|
if m, ok := namedCycles[cycle]; ok {
|
|
return m, true
|
|
}
|
|
if strings.HasSuffix(cycle, "m") {
|
|
if n, err := strconv.ParseInt(strings.TrimSuffix(cycle, "m"), 10, 64); err == nil && n >= 1 && n <= 1200 {
|
|
return n, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func cycleName(months int64) string {
|
|
for name, m := range namedCycles {
|
|
if m == months {
|
|
return name
|
|
}
|
|
}
|
|
return fmt.Sprintf("%dm", months)
|
|
}
|
|
|
|
// validateProbeMetaForm normalizes the form in place (hub patch_error
|
|
// order: group, public remark, limits, billing, pins) and returns the
|
|
// first refusal's message, "" when the form may be stored.
|
|
func validateProbeMetaForm(f *probeMetaForm) string {
|
|
f.Group = strings.TrimSpace(f.Group)
|
|
if len([]rune(f.Group)) > 13 || hasControl(f.Group) {
|
|
return "分组名最多 13 个字,不能含控制字符"
|
|
}
|
|
f.PublicRemark = strings.TrimSpace(f.PublicRemark)
|
|
if len([]rune(f.PublicRemark)) > 100 || hasControl(f.PublicRemark) {
|
|
return "公开备注最多 100 个字,不能含控制字符"
|
|
}
|
|
if f.TrafficResetDay < 1 || f.TrafficResetDay > 31 {
|
|
return "流量重置日要在 1 到 31 之间"
|
|
}
|
|
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
|
|
return "价格和流量上限不能是负数"
|
|
}
|
|
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
|
|
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
|
|
return "货币要填三个字母的代码,如 USD、CNY、HKD"
|
|
}
|
|
if f.BillingCycle != "once" {
|
|
months, ok := cycleMonths(f.BillingCycle)
|
|
if !ok {
|
|
return "付款周期要是整月,在 1 个月到 100 年之间"
|
|
}
|
|
f.BillingCycle = cycleName(months)
|
|
}
|
|
f.CountryPin = strings.ToUpper(strings.TrimSpace(f.CountryPin))
|
|
if f.CountryPin != "" && (len(f.CountryPin) != 2 || !isUpperASCII(f.CountryPin)) {
|
|
return "国家要填两个字母的代码,留空则自动识别"
|
|
}
|
|
for _, pin := range []struct {
|
|
val *string
|
|
v6 bool
|
|
}{
|
|
{&f.IPv4Pin, false},
|
|
{&f.IPv6Pin, true},
|
|
} {
|
|
typed := strings.TrimSpace(*pin.val)
|
|
if typed == "" {
|
|
*pin.val = ""
|
|
continue
|
|
}
|
|
ip := net.ParseIP(typed)
|
|
if ip == nil || (ip.To4() == nil) != pin.v6 {
|
|
if pin.v6 {
|
|
return "IPv6 要填一个 IPv6 地址,或者留空"
|
|
}
|
|
return "IPv4 要填一个 IPv4 地址,或者留空"
|
|
}
|
|
*pin.val = ip.String()
|
|
}
|
|
if f.ExpiresAt != nil {
|
|
v := strings.TrimSpace(*f.ExpiresAt)
|
|
if v == "" {
|
|
f.ExpiresAt = nil
|
|
} else if _, err := time.ParseInLocation("2006-01-02", v, time.Local); err != nil {
|
|
return "到期日期格式应为 YYYY-MM-DD"
|
|
} else {
|
|
f.ExpiresAt = &v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isUpperASCII(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < 'A' || s[i] > 'Z' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|