package handler import ( "encoding/json" "errors" "net/http" "strings" "unicode/utf8" "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 func Decode(r *http.Request, v interface{}) error { return json.NewDecoder(r.Body).Decode(v) } // 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} } // 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: 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 }