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"` // V2.0.1: traffic calibration (GB, either sign) and the counted // interface pin ("" = automatic). TrafficCalUpGB float64 `json:"trafficCalUpGb"` TrafficCalDownGB float64 `json:"trafficCalDownGb"` StatIface string `json:"statIface"` } 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, TrafficCalUpGB: m.TrafficCalUpGB, TrafficCalDownGB: m.TrafficCalDownGB, StatIface: m.StatIface, } } 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, TrafficCalUpGB: f.TrafficCalUpGB, TrafficCalDownGB: f.TrafficCalDownGB, StatIface: f.StatIface, } } // 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 "价格和流量上限不能是负数" } if math.IsNaN(f.TrafficCalUpGB) || math.IsInf(f.TrafficCalUpGB, 0) || math.IsNaN(f.TrafficCalDownGB) || math.IsInf(f.TrafficCalDownGB, 0) { return "流量校正要填有限的数字(GB,可正可负)" } f.StatIface = strings.TrimSpace(f.StatIface) if len(f.StatIface) > 32 || hasControl(f.StatIface) || strings.ContainsAny(f.StatIface, " \t/") { return "统计网卡填网卡名(如 eth0),留空则自动甄别" } 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 }