Files
Cyrene_v2/plugins/channels/weixin_ilink/media.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

146 lines
5.8 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
import (
"context"
"crypto/aes"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
)
// 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
}
// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a})
}
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单与独立的体积上限。
type ilinkFetcher struct{ a *Adapter }
// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
// `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限,
// 与本切片的文档上限刻意解耦)。
func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
key, err := deriveInboundKey(f.a.cfg.BotToken)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
}
ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{
URL: f.a.cdnURL(m.URL),
Allow: f.a.assetAllowlist(),
Header: f.a.inboundHeaders(),
Client: f.a.httpClient,
Timeout: mediafetch.DefaultTimeout,
})
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
}
// assetAllowlist 给出入站附件取回的来源白名单:**只有已配置的 CDN 根**(默认
// `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用
// 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute),
// 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL)
if err != nil {
return mediafetch.NewAllowlist()
}
return mediafetch.NewAllowlist(origin)
}