- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动) - 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等 - 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误 - 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
93 lines
3.2 KiB
Go
93 lines
3.2 KiB
Go
package weixin_ilink
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
|
|
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
|
|
|
|
// deriveInboundKey 为入站媒体负载推导 AES-128 密钥。按 design/08 §3.3,密钥从
|
|
// bot_token 中提取:先 base64 解码 token,再取末尾 16 字节。参考实现把这一步标记为
|
|
// ⚠️,因此 DownloadMediaWithKey 允许调用方在平台按每条媒体下发密钥时显式传入密钥。
|
|
func deriveInboundKey(botToken string) ([]byte, error) {
|
|
token := strings.TrimSpace(botToken)
|
|
if token == "" {
|
|
return nil, ErrInvalidConfig
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
|
|
}
|
|
if len(b) < aes.BlockSize {
|
|
return nil, ErrKeyTooShort
|
|
}
|
|
return b[len(b)-aes.BlockSize:], nil
|
|
}
|
|
|
|
// DownloadMedia 下载一条入站 CDN 引用,并用从已配置 bot_token 推导出的密钥返回
|
|
// 明文。密文超过 MaxMediaSize 时以 ErrMediaTooLarge 失败。
|
|
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
|
|
key, err := deriveInboundKey(a.cfg.BotToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
|
|
}
|
|
return a.downloadWithKey(ctx, cdnURL, key)
|
|
}
|
|
|
|
// DownloadMediaWithKey 是 DownloadMedia 的显式按条 AES 密钥版本,用于协议把密钥
|
|
// 与媒体引用一并下发的场景。
|
|
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
|
|
if len(key) != aes.BlockSize {
|
|
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
|
|
}
|
|
return a.downloadWithKey(ctx, cdnURL, key)
|
|
}
|
|
|
|
// downloadWithKey 拉取 CDN 字节并剥掉 AES-ECB 层。
|
|
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
|
|
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
|
|
}
|
|
plaintext, err := aesECBDecrypt(key, ciphertext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
// fetchBody 对 rawURL 发 GET 并返回响应体,超过 MaxMediaSize 的一律拒绝,避免单个
|
|
// 超大文件耗尽内存。
|
|
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: build media request: %w", err)
|
|
}
|
|
a.authorizationHeader(req)
|
|
resp, err := a.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: download media: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode)
|
|
}
|
|
limit := a.cfg.MaxMediaSize + 1
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: read media: %w", err)
|
|
}
|
|
if int64(len(data)) > a.cfg.MaxMediaSize {
|
|
return nil, ErrMediaTooLarge
|
|
}
|
|
return data, nil
|
|
}
|