package handler import ( "bytes" "encoding/json" "errors" "io" "net/http" "reflect" "strconv" "strings" "unicode/utf8" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" ) // JSON 写入 JSON 响应 func JSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } // Error 写入错误响应 func Error(w http.ResponseWriter, status int, msg string) { JSON(w, status, map[string]string{"error": msg}) } // Decode 从请求体解析 JSON。**拒绝未知字段。** // // # 为什么必须严格 // // 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都 // 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是 // `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`: // // $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}' // HTTP 200 {"mail_id":"2a64fdc8…", …} // $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'" // 0 // // 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 —— // **正因为没人会去核对一个返回 200 的请求**。 // // 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在 // 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」, // 也不要让它以为自己传的东西生效了。 // // 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。 func Decode(r *http.Request, v interface{}) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(v) } // DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。 // // 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个 // 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。 // // 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件 // (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是 // `attachments` vs `attachment_ids` 能拖那么久的原因。 // // 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。 func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) { raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes)) if err != nil { return nil, err } if len(raw) == 0 { return nil, nil } // 取值这一遍必须宽容:未知字段不能让整个心跳体作废。 if uErr := json.Unmarshal(raw, v); uErr != nil { return nil, uErr } // 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回, // 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。 probeType := reflect.TypeOf(v) for probeType != nil && probeType.Kind() == reflect.Ptr { probeType = probeType.Elem() } if probeType == nil { return nil, nil } seen := map[string]bool{} for i := 0; i < maxUnknownFieldsReported; i++ { probe := reflect.New(probeType).Interface() dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() dErr := dec.Decode(probe) if dErr == nil { break } name := unknownFieldName(dErr) // 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错), // 或者同一个名字又出现一次 —— 都说明剥不下去了,停。 if name == "" || seen[name] { break } seen[name] = true unknown = append(unknown, name) stripped, sErr := stripTopLevelKey(raw, name) if sErr != nil { break } raw = stripped } return unknown, nil } const ( // maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条, // 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错, // 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。 maxLenientBodyBytes = 2 << 20 // maxUnknownFieldsReported 是回报的未知字段数上限。 // 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。 maxUnknownFieldsReported = 8 ) // stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。 // // 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字 // 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。 func stripTopLevelKey(raw []byte, key string) ([]byte, error) { var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { return nil, err } if _, ok := m[key]; !ok { return nil, errors.New("key not at top level") } delete(m, key) return json.Marshal(m) } // unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。 // // json 包没有为这种错误定义类型(返回的是 *errors.errorString), // 只能按文本匹配 `json: unknown field "xxx"`。 // 匹配不上时返回空串,调用方回落到笼统文案。 func unknownFieldName(err error) string { const prefix = `json: unknown field "` msg := err.Error() i := strings.Index(msg, prefix) if i < 0 { return "" } rest := msg[i+len(prefix):] j := strings.IndexByte(rest, '"') if j < 0 { return "" } return rest[:j] } // jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。 // // 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」—— // 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。 // 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。 func jsonFieldNames(v interface{}) []string { t := reflect.TypeOf(v) for t != nil && t.Kind() == reflect.Ptr { t = t.Elem() } if t == nil || t.Kind() != reflect.Struct { return nil } out := make([]string, 0, t.NumField()) for i := 0; i < t.NumField(); i++ { f := t.Field(i) if f.PkgPath != "" { continue // 非导出字段不参与 JSON } name := f.Tag.Get("json") if idx := strings.IndexByte(name, ','); idx >= 0 { name = name[:idx] } if name == "-" { continue } if name == "" { name = f.Name } out = append(out, name) } return out } // DecodeBody 解析请求体,失败时直接写 400 并返回 false。 // // 与直接用 Decode 的区别是错误信息**指向具体字段**。原先 22 处调用点 // 一律回一句固定的 "Invalid JSON",客户端只知道「有问题」却不知道哪里有问题 —— // 实测踩过一次:`workspaces` 要的是 `[{name, path}]`,传字符串数组得到的 // 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。 func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool { if err := Decode(r, v); err != nil { Error(w, http.StatusBadRequest, decodeErrMsg(err, v)) return false } return true } // decodeErrMsg 把 json 解码错误翻成一句能照着改的话。 // // 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace), // 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。 func decodeErrMsg(err error, target interface{}) string { if errors.Is(err, io.EOF) { return "请求体为空" } // 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻 // 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错 //(宽容解码时它连报错都没有,见 Decode 的注释)。 if bad := unknownFieldName(err); bad != "" { msg := "不认识的字段 \"" + bad + "\"" if names := jsonFieldNames(target); len(names) > 0 { msg += ";本端点接受:" + strings.Join(names, " / ") } return msg } // 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF —— // 不单独处理的话会落到最后那句笼统的兜底文案里 if errors.Is(err, io.ErrUnexpectedEOF) { return "JSON 语法错误:请求体在解析完成前就结束了(可能被截断)" } var typeErr *json.UnmarshalTypeError if errors.As(err, &typeErr) { if typeErr.Field != "" { return "字段 \"" + typeErr.Field + "\" 类型不对:期望 " + jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value } return "请求体类型不对:期望 " + jsonKindName(typeErr.Type) + ",收到 " + typeErr.Value } var syntaxErr *json.SyntaxError if errors.As(err, &syntaxErr) { return "JSON 语法错误(第 " + strconv.FormatInt(syntaxErr.Offset, 10) + " 字节处)" } return "请求体不是合法 JSON" } // jsonKindName 把 Go 类型说成 JSON 的说法。 // 调用方写的是 JSON,用 []models.Workspace 去解释它要的是什么毫无帮助。 func jsonKindName(t reflect.Type) string { if t == nil { return "未知类型" } switch t.Kind() { case reflect.Slice, reflect.Array: return jsonKindName(t.Elem()) + " 数组" case reflect.Map, reflect.Struct: return "object" case reflect.String: return "string" case reflect.Bool: return "boolean" case reflect.Ptr: return jsonKindName(t.Elem()) default: if k := t.Kind(); k >= reflect.Int && k <= reflect.Float64 { return "number" } return t.Kind().String() } } // httpError 携带 HTTP 状态码的错误 type httpError struct { status int msg string } func (e httpError) Error() string { return e.msg } func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} } func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} } func errConflict(msg string) error { return httpError{http.StatusConflict, msg} } // errRateLimited 用于新建会话速率限制。用 429 而不是 403: // 前者表示「稍后再来」,后者表示「你没这个权限」——语义完全不同, // 客户端据此决定是重试还是放弃。 func errRateLimited(msg string) error { return httpError{http.StatusTooManyRequests, msg} } // writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。 // 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。 func writeKeyErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, repo.ErrKeyUsed): Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)") case errors.Is(err, repo.ErrKeyExpired): Error(w, http.StatusUnauthorized, "密钥已过期") case errors.Is(err, repo.ErrKeyNotFound): Error(w, http.StatusUnauthorized, "密钥无效") case errors.Is(err, repo.ErrKeyTypeInvalid): Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed") case errors.Is(err, repo.ErrKeyNeedsExpiry): Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours") case errors.Is(err, repo.ErrKeyTooShort): Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)") case errors.Is(err, repo.ErrKeyTokenTaken): Error(w, http.StatusConflict, "该密钥已登记过") default: if strings.Contains(err.Error(), "已退役") { Error(w, http.StatusConflict, err.Error()) } else { Error(w, http.StatusInternalServerError, "密钥操作失败") } } } // validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path. 的末段。 // "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。 func validateSessionAlias(alias string) error { if alias == "new" { return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`) } if strings.ContainsAny(alias, ". \t/@") { return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)") } return nil } // normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。 // // 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让 // name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。 // 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。 func normalizeAlias(s string) string { var b strings.Builder lastDash := false for _, r := range s { switch { case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r': if !lastDash && b.Len() > 0 { b.WriteByte('-') lastDash = true } default: b.WriteRune(r) lastDash = false } } out := strings.Trim(b.String(), "-") if out == "new" { return "session-new" } // VARCHAR(128) 上限,按字节截断时不能切坏多字节字符 const maxBytes = 128 if len(out) > maxBytes { cut := out[:maxBytes] for len(cut) > 0 && !utf8.ValidString(cut) { cut = cut[:len(cut)-1] } out = strings.Trim(cut, "-") } return out } // writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案 func writeErr(w http.ResponseWriter, err error, fallback string) { if he, ok := err.(httpError); ok { Error(w, he.status, he.msg) return } Error(w, http.StatusInternalServerError, fallback) } // emptySlice 把 nil slice 转为空 JSON 数组 [] func emptySlice[T any](s []T) []T { if s == nil { return []T{} } return s } // agentLimiterKey 把「这是不是 Agent 发起的」翻译成速率限制的键。 // 人类返回空串 = 不限速(手工操作的频率天然受限)。 func agentLimiterKey(isAgent bool, actor string) string { if isAgent { return actor } return "" } // validPermissionModeInput 校验人显式指定的权限档位。 // // 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**, // 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错 // 更坏的结果(他会以为自己收紧了)。 // // 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」, // 那两种情形下静默回落到默认档才是对的。 func validPermissionModeInput(w http.ResponseWriter, mode string) bool { if mode == "" || models.ValidPermissionMode(mode) { return true } Error(w, http.StatusBadRequest, "permission_mode 非法:"+mode+"(应为 plan / workspace / full)") return false }