P8.1: fix browser inbound re-homing — nodeId probe must parse form-encoded bodies

updateInbound detected an explicit nodeId by probing the raw request
body as JSON only (requestHasJSONKey). The panel frontend, however,
posts application/x-www-form-urlencoded by default (http-init.ts
form-encodes every POST without a declared JSON Content-Type), so the
probe missed on every real browser save and UpdateInbound silently kept
the stored assignment — the edit dialog's 部署到 picker never moved
anything, while JSON API callers (and the P8 E2E, which posted JSON)
worked.

requestHasKey is now content-type aware: JSON bodies are probed as
before; urlencoded bodies are parsed with url.ParseQuery, where the
browser's null encoding (a bare "nodeId=") also counts as provided —
it binds as *int(0) and the existing 0->nil normalisation turns it
into the move-to-local target.

Regression tests post the exact browser wire format through the real
handler stack: form move between nodes, form post without nodeId
(assignment kept), form post with nodeId= (move to local, no binding
error), plus the JSON move at HTTP level. The two form-move tests
fail on the unfixed code and pass with the fix.
This commit is contained in:
Muse
2026-10-21 15:37:45 +00:00
parent 7284fa6cf0
commit 6772c97f8e
2 changed files with 376 additions and 15 deletions
+42 -15
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"encoding/json"
"io"
"mime"
"net"
"net/url"
"strconv"
"strings"
@@ -224,11 +226,17 @@ func (a *InboundController) bulkDelInbounds(c *gin.Context) {
notifyClientsChanged()
}
// requestHasJSONKey reports whether the request body is a JSON object that
// explicitly carries key — present with a null value counts. The body is
// restored so the normal bind can read it again. Non-JSON bodies (form
// posts) report false for every key.
func requestHasJSONKey(c *gin.Context, key string) bool {
// requestHasKey reports whether the request body explicitly carries key —
// present with a JSON null or an empty form value counts. The probe must
// understand both wire formats this endpoint receives: API clients post
// JSON, but the panel frontend posts application/x-www-form-urlencoded by
// default (frontend/src/api/http-init.ts form-encodes every POST that does
// not declare a JSON Content-Type, encoding a null value as a bare "key=").
// A JSON-only probe silently reports false for every real browser post —
// that is exactly how browser-driven nodeId reassignments became a no-op
// while JSON callers kept working. The body is restored so the normal
// bind can read it again.
func requestHasKey(c *gin.Context, key string) bool {
if c.Request == nil || c.Request.Body == nil {
return false
}
@@ -237,11 +245,27 @@ func requestHasJSONKey(c *gin.Context, key string) bool {
return false
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
var probe map[string]json.RawMessage
if json.Unmarshal(body, &probe) != nil {
return false
mediaType, _, _ := mime.ParseMediaType(c.GetHeader("Content-Type"))
if mediaType == "application/x-www-form-urlencoded" {
return formHasKey(body, key)
}
_, ok := probe[key]
var probe map[string]json.RawMessage
if json.Unmarshal(body, &probe) == nil {
_, ok := probe[key]
return ok
}
// Undeclared or unexpected Content-Type with a non-JSON body: fall
// back to form parsing so the key is still understood.
return formHasKey(body, key)
}
// formHasKey reports whether a urlencoded body carries key, even with an
// empty value. ParseQuery keeps every valid pair it finds, so its map is
// usable even when it also returns an error for a later malformed pair.
func formHasKey(body []byte, key string) bool {
values, _ := url.ParseQuery(string(body))
_, ok := values[key]
return ok
}
@@ -255,16 +279,19 @@ func (a *InboundController) updateInbound(c *gin.Context) {
inbound := &model.Inbound{
Id: id,
}
// An explicitly sent nodeId (even null) is a (re)assignment request —
// UpdateInbound migrates the inbound when it differs from the stored
// assignment. An omitted nodeId keeps the stored assignment, so detect
// the key's presence on the raw body before binding consumes it.
nodeIDProvided := requestHasJSONKey(c, "nodeId")
// An explicitly sent nodeId (JSON null, or the form encoding of one —
// a bare "nodeId=") is a (re)assignment request: UpdateInbound migrates
// the inbound when it differs from the stored assignment. An omitted
// nodeId keeps the stored assignment, so detect the key's presence on
// the raw body before binding consumes it.
nodeIDProvided := requestHasKey(c, "nodeId")
if !middleware.BindAndValidateInto(c, inbound) {
return
}
inbound.NodeIDProvided = nodeIDProvided
// Same NodeID=0 → nil normalisation as addInbound.
// Same NodeID=0 → nil normalisation as addInbound. This also covers
// the browser's encoding of "back to local": the form post carries a
// bare "nodeId=", which gin binds as *int(0) rather than nil.
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}