fix(nodes): make node API tokens write-only (#5613)

* fix(nodes): make node API tokens write-only

* fix(nodes): keep token optional on edit for write-only API tokens

NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales.
This commit is contained in:
n0ctal
2026-07-23 18:35:11 +05:00
committed by GitHub
parent 892c06c8bc
commit c77608bc47
29 changed files with 1716 additions and 88 deletions
+42 -39
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -78,7 +77,7 @@ func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetNodeTree()
nodes, err := a.nodeService.GetNodeTreeView()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return
@@ -92,7 +91,7 @@ func (a *NodeController) get(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
n, err := a.nodeService.GetViewById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
@@ -116,27 +115,32 @@ func (a *NodeController) webCert(c *gin.Context) {
jsonObj(c, files, nil)
}
func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
if _, err := a.nodeService.Probe(ctx, n); err != nil {
if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
return errors.New(service.FriendlyProbeError(err.Error()))
}
return nil
}
func (a *NodeController) add(c *gin.Context) {
n, ok := middleware.BindAndValidate[model.Node](c)
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
if !ok {
return
}
if n.OutboundTag == "" {
if err := a.ensureReachable(c, n); err != nil {
if err := a.ensureReachable(c, n, 0); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
}
if err := a.nodeService.Create(n); err != nil {
view, err := a.nodeService.CreateFromRequest(n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
@@ -144,12 +148,12 @@ func (a *NodeController) add(c *gin.Context) {
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warning("apply node outbound bridge failed:", err)
}
if err := a.ensureReachable(c, n); err != nil {
if err := a.ensureReachable(c, n, view.Id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
}
func (a *NodeController) update(c *gin.Context) {
@@ -158,7 +162,7 @@ func (a *NodeController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, ok := middleware.BindAndValidate[model.Node](c)
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
if !ok {
return
}
@@ -167,13 +171,13 @@ func (a *NodeController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
if n.OutboundTag == "" && old.OutboundTag == "" {
if err := a.ensureReachable(c, n); err != nil {
if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
if err := a.ensureReachable(c, n, id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
}
if err := a.nodeService.Update(id, n); err != nil {
if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
@@ -181,7 +185,7 @@ func (a *NodeController) update(c *gin.Context) {
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warning("apply node outbound bridge change failed:", err)
}
if err := a.ensureReachable(c, n); err != nil {
if err := a.ensureReachable(c, n, id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
@@ -233,58 +237,57 @@ func (a *NodeController) setEnable(c *gin.Context) {
}
func (a *NodeController) inbounds(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
if !ok {
return
}
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
jsonObj(c, options, err)
}
func (a *NodeController) test(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
if !ok {
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
var patch service.HeartbeatPatch
var err error
if n.OutboundTag != "" {
patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag)
if runtimeNode.OutboundTag != "" {
patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
} else {
patch, err = a.nodeService.Probe(ctx, n)
patch, err = a.nodeService.Probe(ctx, runtimeNode)
}
jsonObj(c, patch.ToUI(err == nil), nil)
}
func (a *NodeController) certFingerprint(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
if !ok {
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return