Files
Cyrene_v2/plugins/channels/weixin_ilink/adapter_http.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

181 lines
6.5 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 weixin_ilink
// 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go
// 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余
// 代码或其测试依赖其位于某个特定文件」的导出符号。
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
const ilinkAuthType = "ilink_bot_token"
// maxErrorBodyBytes 是 iLink 非 2xx 响应里被读取的字节上限:只用来数体积与取
// sha256 前缀(trace.UpstreamError 的口径),正文不进错误。
const maxErrorBodyBytes = 4096
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
func (a *Adapter) authorizationHeader(req *http.Request) {
for k, vs := range a.inboundHeaders() {
for _, v := range vs {
req.Header.Set(k, v)
}
}
}
// inboundHeaders 构造 iLink 通用请求头(JSON 内容类型、bearer token、认证类型、随机
// 防重放值、App 标识头)。API 调用与**入站附件取回**共用同一份口径(切片 IM-1)——
// CDN 与 API 同属 iLink 平台,凭据与标识没有任何区别。
func (a *Adapter) inboundHeaders() http.Header {
h := http.Header{}
h.Set("Content-Type", "application/json")
h.Set("AuthorizationType", ilinkAuthType)
h.Set("X-WECHAT-UIN", randomUIN())
h.Set("iLink-App-Id", ilinkAppID)
h.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
a.mu.RLock()
token := a.cfg.BotToken
a.mu.RUnlock()
if token != "" {
h.Set("Authorization", "Bearer "+token)
}
return h
}
// randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的
// 防重放值。
func randomUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
n := binary.BigEndian.Uint32(b[:])
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
}
// doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w
// 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。
//
// 地址只以来源形态出镜(mediafetch.HostOf):rawURL 由 base+path 拼成,而 pollBase
// 可能是对端重定向给的主机,完整串没有进日志的必要。非 2xx 的响应体同样不拼 ——
// iLink 会在 errmsg 里回显请求内容,这里只经 trace.UpstreamError 留下
// 状态码/体积/sha256 前缀(同 M40 口径)。
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
host := mediafetch.HostOf(rawURL)
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, host, urlFreeCause(err))
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host, urlFreeCause(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host,
trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), msg))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, host, urlFreeCause(err))
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, host, err)
}
return nil
}
// urlFreeCause 剥掉 net/http 的 *url.Error 外壳:它会把**整条地址**拼进错误串 ——
// doJSON 的地址、uploadCiphertext 的预签名上传地址(`encrypted_query_param` 就是下载
// 凭据)都会跟着进错误与日志。剥壳只丢地址,内层哨兵链(context 取消、*net.DNSError
// 等)原样保留,因此 fetchErrCategory 的归类不受影响。
//
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径:那份是未导出符号,且只服务
// 于入站取回路径(mediafetch 不感知本包的发送侧),因此这里保留一份同形的三行实现。
func urlFreeCause(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// apiURL 把路径与已配置的基础 URL 拼接起来。
func (a *Adapter) apiURL(path string) string {
return joinURL(a.cfg.BaseURL, path)
}
// joinURL 把基础 URL 与以斜杠开头的路径拼接起来,并容忍基础 URL 末尾带斜杠。
func joinURL(base, path string) string {
return strings.TrimRight(base, "/") + path
}
// pollBase 返回 get_qrcode_status 使用的基础 URL:已记录 IDC 重定向主机
// (scaned_but_redirect)时用它,否则用已配置的基础 URL。
func (a *Adapter) pollBase() string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.pollBaseURL != "" {
return a.pollBaseURL
}
return a.cfg.BaseURL
}
// setPollBaseURL 按官方 currentApiBaseUrl 的逻辑,为后续的 get_qrcode_status
// 轮询记录一个 IDC 重定向主机(https://<redirect_host>)。host 为空则清除该覆盖。
func (a *Adapter) setPollBaseURL(host string) {
a.mu.Lock()
defer a.mu.Unlock()
if host == "" {
a.pollBaseURL = ""
return
}
a.pollBaseURL = "https://" + host
}
// buildClientVersion 按官方 api.ts 的做法,把语义化版本编码成 iLink
// App-ClientVersion 的线上值 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 |
// (patch&0xff)。例如 "2.4.8" -> "132104"(十进制字符串,而不是 "1")。
func buildClientVersion(version string) string {
parts := strings.Split(version, ".")
at := func(i int) int {
if i >= len(parts) {
return 0
}
n, err := strconv.Atoi(parts[i])
if err != nil {
return 0
}
return n
}
v := ((at(0) & 0xff) << 16) | ((at(1) & 0xff) << 8) | (at(2) & 0xff)
return strconv.Itoa(v)
}
// cdnURL 把路径与已配置的 CDN 基础 URL 拼接起来,并转义部分 CDN 引用携带的原始
// 查询串。
func (a *Adapter) cdnURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
return raw
}
return strings.TrimRight(a.cfg.CDNBaseURL, "/") + "/" + strings.TrimLeft(raw, "/")
}