Files
Cyrene_v2/internal/channel/adapter/httpclient/client.go
T
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00

234 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package httpclient provides a small, reusable HTTP (/JSON) client for IM
// channel adapters that talk to a REST API with token auth and a
// common `{code,message,data}` response envelope (KOOK, and similarly shaped
// platforms). It is used by the KOOK adapter and is intended to be shared by
// future platforms; existing adapters (Telegram, OneBot) that already carry
// their own transport are left untouched.
//
// It depends only on the standard library and internal/logger. The auth token is
// never logged or surfaced in errors. The Authorization scheme is configurable
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
package httpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
// a non-2xx HTTP status. Tests compare with errors.Is.
var ErrAPI = errors.New("httpclient: api error")
// maxBodyBytes caps how large a response body the client will read.
const maxBodyBytes = 1 << 20 // 1 MiB
// maxEnvelopeMessageRunes caps the envelope `message` field once it enters an
// error. It is upstream free text: whatever was rejected may be echoed there, so
// it is cleaned and bounded rather than passed through at the upstream's length.
const maxEnvelopeMessageRunes = 200
// Client is a token-auth JSON HTTP client bound to a base URL.
type Client struct {
http *http.Client
baseURL string
token string
authScheme string
log *slog.Logger
}
// Option customizes a Client at construction time.
type Option func(*Client)
// WithHTTPClient injects the *http.Client used for requests. Tests pass the
// client of an httptest.Server.
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.http = h }
}
// WithBaseURL sets the URL root that paths are joined to. A base ending in "/"
// has its trailing slash trimmed.
func WithBaseURL(base string) Option {
return func(c *Client) { c.baseURL = strings.TrimRight(base, "/") }
}
// WithToken permits an Authorization header on every request. The token is
// stored in memory only and never logged.
func WithToken(token string) Option {
return func(c *Client) { c.token = token }
}
// WithAuthScheme sets the scheme used in the `Authorization: <scheme> <token>`
// header. The default is "Bearer" (OAuth2-style tokens). KOOK robots require
// "Bot". Passing an empty string keeps the default.
func WithAuthScheme(scheme string) Option {
return func(c *Client) { c.authScheme = strings.TrimSpace(scheme) }
}
// WithLogger injects the logger. A nil logger stays silent.
func WithLogger(l *slog.Logger) Option {
return func(c *Client) {
if l == nil {
c.log = logger.New(logger.Options{Out: io.Discard})
} else {
c.log = l
}
}
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via
// the Do/Get helpers' path being an absolute URL).
func New(opts ...Option) *Client {
c := &Client{
http: &http.Client{},
authScheme: "Bearer",
log: logger.New(logger.Options{Out: io.Discard}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// APIEnvelope is the common `{code,message,data}` response envelope used by the
// KOOK REST API. Code==0 means success; a non-zero code is mapped to ErrAPI.
type APIEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// DoJSON performs a request with the given method against path (joined to the
// base URL) and returns the decoded data payload of the envelope. A non-zero
// code or a non-2xx status is wrapped with ErrAPI. A 2xx body that is not an
// envelope (e.g. a raw payload) is returned verbatim as data.
func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
return c.do(ctx, method, c.resolve(path), body)
}
// GetJSON performs a GET against path with the given query parameters and
// returns the decoded data payload. A nil query adds nothing.
func (c *Client) GetJSON(ctx context.Context, path string, query url.Values) (json.RawMessage, error) {
target := c.resolve(path)
if len(query) > 0 {
target += "?" + query.Encode()
}
return c.do(ctx, http.MethodGet, target, nil)
}
// resolve joins path to the base URL, passing through an absolute path as-is.
func (c *Client) resolve(path string) string {
if c.baseURL == "" {
return path
}
return c.baseURL + path
}
// do performs the request, injecting auth and decoding the envelope.
//
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的
// code/message(清洗并截断),其余字节一概不留。
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
host := trace.HostOf(target)
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return nil, fmt.Errorf("httpclient: encode %s request: %w", method, err)
}
}
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
if err != nil {
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, transportCause(err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
scheme := c.authScheme
if scheme == "" {
scheme = "Bearer"
}
req.Header.Set("Authorization", scheme+" "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, transportCause(err))
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, transportCause(err))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, apiFailure(method, host, resp, raw)
}
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return raw, nil
}
if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, envelopeMessage(envelope.Message), ErrAPI)
}
return envelope.Data, nil
}
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
// 外加(当体确实是 `{code,message}` 信封时)清洗并截断过的 code/message。其余字节只
// 经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份报文」这类相关性判断。
func apiFailure(method, host string, resp *http.Response, raw []byte) error {
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err == nil {
message := envelopeMessage(envelope.Message)
switch {
case envelope.Code != 0 && message != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, message, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case message != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, message, upstream, ErrAPI)
}
}
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeMessage 是信封 message 进入错误前的唯一处理:压平空白、清洗凭据形状,再按
// rune 截断。message 是上游自由文本面,被拒的输入常被回显在这里。
func envelopeMessage(s string) string {
flat := strings.Join(strings.Fields(s), " ")
flat = trace.RedactText(flat)
if r := []rune(flat); len(r) > maxEnvelopeMessageRunes {
return string(r[:maxEnvelopeMessageRunes]) + "…"
}
return flat
}
// transportCause 剥掉 net/http 的 *url.Error 外壳:它会把**完整请求地址**拼进错误串
// (query 里的凭据、user:pass@ 都会跟着走)。剥壳保留内层哨兵链
// (context.DeadlineExceeded 等仍可 errors.Is),因此只丢地址、不丢原因。
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径 —— 那份未导出,且服务于入站
// 取回路径,本包不能反向依赖它。
func transportCause(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}