feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
This commit is contained in:
1 parent
5de708a560
commit
6a545badbd
8 files changed
+1004
-37
No files matched your search
@@ -7,6 +7,8 @@ package mediafetch
|
||||
//
|
||||
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
|
||||
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
|
||||
// 图片附件同理由 `inline_image.go` 承载(切片 IM-IMG):独立的图片白名单 + 图片口径的
|
||||
// 上限(5 MiB)+ 字节嗅探裁决,与上面两份名单互不污染。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -33,16 +35,18 @@ type Fetcher interface {
|
||||
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
|
||||
}
|
||||
|
||||
// Inline 把消息里**文件类**与**语音类**媒体在受支持格式与限额内的字节内联进
|
||||
// `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一)拿到字节。它是四个适配器
|
||||
// 共享的唯一实现:
|
||||
// Inline 把消息里**文件类**、**图片类**与**语音类**媒体在受支持格式与限额内的字节
|
||||
// 内联进 `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一 / 图片内联成
|
||||
// data: URI)拿到字节。它是四个适配器共享的唯一实现:
|
||||
//
|
||||
// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末);
|
||||
// - 文件类走 `inlineFiles`(`documents.ExtAllowed` 白名单,20 MiB);
|
||||
// - 图片类走 `inlineImages`(切片 IM-IMG,`inline_image.go`):它用一份**独立的
|
||||
// 图片白名单**与**图片口径的上限**(5 MiB),并以字节嗅探为最终裁决;
|
||||
// - 语音类走 `inlineVoices`(切片 IM-ASR,design/06 §1.1、design/08 §4.1):它用一份
|
||||
// **独立的音频白名单**,绝不与 `documents.ExtAllowed` 混淆——后者是 Web 文档上传的
|
||||
// 口径,往里加音频格式会污染文档白名单;
|
||||
// - 两条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
|
||||
// 来源不记完整 URL),因此四个适配器零改动即可拿到语音字节。
|
||||
// - 三条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
|
||||
// 来源不记完整 URL),因此四个适配器零改动即可拿到图片与语音字节。
|
||||
//
|
||||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||||
@@ -51,9 +55,32 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage,
|
||||
return
|
||||
}
|
||||
inlineFiles(ctx, log, msg, f)
|
||||
inlineImages(ctx, log, msg, f)
|
||||
inlineVoices(ctx, log, msg, f)
|
||||
}
|
||||
|
||||
// MaxBytesFor 返回一条媒体取回时该用的**字节上限**,按类型分档:图片 5 MiB
|
||||
// (`MaxImageBytes`)、语音 5 MiB(`MaxVoiceBytes`)、其余(文档类附件)20 MiB
|
||||
// (`documents.DefaultMaxBytes`)。
|
||||
//
|
||||
// 它给各适配器的 Fetcher 用(`mediafetch.Request.MaxBytes`):取回发生在 Fetcher 里,
|
||||
// 而「同一份取回口径服务三条链路」意味着上限不能由取回层替调用方猜 —— 这一份映射是
|
||||
// 唯一出处,渠道侧只需 `MaxBytes: mediafetch.MaxBytesFor(m)`。
|
||||
//
|
||||
// `Kind` 为空的媒体(只给了消息级内容类型的形状)无法在这一层判出类型,回落到文档
|
||||
// 上限;此时图片仍由 `inlineImages` 的**取回后**复核(> MaxImageBytes 一律不内联)
|
||||
// 兜住,因此不会出现「巨图进了上下文」。
|
||||
func MaxBytesFor(m adapter.Media) int64 {
|
||||
switch m.Kind {
|
||||
case adapter.MediaImage:
|
||||
return MaxImageBytes
|
||||
case adapter.MediaVoice:
|
||||
return MaxVoiceBytes
|
||||
default:
|
||||
return documents.DefaultMaxBytes
|
||||
}
|
||||
}
|
||||
|
||||
// inlineFiles 内联**文件类**媒体:
|
||||
//
|
||||
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件承载**入站图片附件的字节内联**(切片 IM-IMG):四个适配器共用同一份
|
||||
// `Inline`,因此图片只需在这里补一条分支,渠道侧**零改动**即可把图片字节送进
|
||||
// `Media.Data` —— 桥接器再把它内联成 data: URI 挂上既有视觉链路(design/24 §4.2 末)。
|
||||
//
|
||||
// 五条口径(每条都是本切片刻意钉死的):
|
||||
//
|
||||
// 1. **图片放行集合独立于文档白名单与音频白名单**:`documents.ExtAllowed` 是 Web
|
||||
// 文档上传的口径(纯文本类 + PDF),`audioExtAllowed` 是语音转写的口径。把图片
|
||||
// 扩展名塞进任何一份都会悄悄扩大另一个面(Web 上传能收 .png、或语音白名单里混进
|
||||
// 图片),因此这里是**第三份、互不引用**的集合。
|
||||
//
|
||||
// 2. **声明只用于预筛,字节嗅探是唯一裁决权**:平台的扩展名/MIME 命中图片集合就值得
|
||||
// 取字节(省掉明显不该取的那些),但**取回后必须重新按真实字节判**——改名成 `.png`
|
||||
// 的 HTML 错误页、或顶着 `image/png` 下发的文本,一律**不内联**(项目既有口径:
|
||||
// 嗅探是唯一裁决权,与 vision 的 `imageMIMEFor`、documents 的 `ContentAllowed`
|
||||
// 同一纪律)。绝不静默当图片塞进上下文。
|
||||
//
|
||||
// ⚠️ 与 vision 的 `imageMIMEFor` 有一处**刻意不同**:那边在「字节判不定」时会采信
|
||||
// 来源声明的 `image/*`(为了不无谓拒绝 avif 一类嗅探不出的格式);这里**不采信**
|
||||
// 声明兜底 —— 入站图片的字节已经在我们手上,判不定就诚实说「这不是白名单光栅图」,
|
||||
// 绝不让一份判不定的载荷顶着 `image/png` 进上下文。
|
||||
//
|
||||
// 3. **平台没给名字也没给类型时以「平台类型」为声明**:微信 iLink 的 image_item 只有
|
||||
// `media`(引用与密钥),**既没有 file_name 也没有 mime**,只认扩展名会把它整条
|
||||
// 挡掉(与 telegram 语音只有 `file_id` + `audio/ogg` 是同一类形状)。此时
|
||||
// `IsImageMedia` 判定的那个类型标记**同样来自平台载荷**,与资产地址处于同一信任域,
|
||||
// 因此它是声明;字节嗅探仍是最终裁决(判不定就不内联)。
|
||||
//
|
||||
// 4. **上限按图片口径**:图片走 `MaxImageBytes`(5 MiB,与 `CHAT_IMAGE_MAX_BYTES`
|
||||
// 默认值、vision 解码层硬顶同值),**绝不用文档的 20 MiB** —— 一张图能被内联多大
|
||||
// 只能有一个答案。
|
||||
//
|
||||
// 5. **失败不炸、也不静默**:取不到字节/超限/字节不是图片时,该媒体保持无字节,记一条
|
||||
// 带来源的 Warn(`host` 只记来源,**绝不记 URL 原串**:平台资产地址可能带签名凭据),
|
||||
// 由下游照实说「读不到」。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
)
|
||||
|
||||
// MaxImageBytes 是**单张入站图片**内联的字节上限。取值直接复用 vision 解码层的
|
||||
// 硬顶(`vision.MaxInlineImageBytes`,与 `CHAT_IMAGE_MAX_BYTES` 的默认值 5242880
|
||||
// 逐字节相同)——上限只在这里有一个出处:入站取回放行的字节,解码层一定接得住;
|
||||
// 若各自写一个数,就会出现「页面上传 5 MiB 能过、IM 收进来的 5.1 MiB 被解码层拒」
|
||||
// 这种自相矛盾的行为。
|
||||
const MaxImageBytes int64 = vision.MaxInlineImageBytes
|
||||
|
||||
// imageSniffPrefix 是字节嗅探读取的前缀长度:`http.DetectContentType` 只看前 512 字节。
|
||||
const imageSniffPrefix = 512
|
||||
|
||||
// IsImageMedia 报告一条媒体是不是**图片类**。判定与文件类/语音类同形
|
||||
// (`MediaImage`,或「只给了内容类型、附件就在 Media 里」的 `ContentImage`)。
|
||||
func IsImageMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaImage {
|
||||
return true
|
||||
}
|
||||
return m.Kind == "" && msg.ContentType == adapter.ContentImage
|
||||
}
|
||||
|
||||
// ImageMIMEOf 是**图片字节的唯一裁决**:按真实字节判定它是不是白名单内的光栅图,
|
||||
// 是则返回归一后的 MIME。它同时给两处用——本包的字节内联(不信任声明),以及桥接器
|
||||
// 构造 data: URI 时(`vision.DataURI(mime, data)`)。两处共用它,就不可能出现
|
||||
// 「取回层说这是图片、构造 URI 的那一段却按别的类型拼前缀」这种漂移。
|
||||
//
|
||||
// 白名单:png / jpeg / gif / webp / bmp / tiff。**SVG 不在内**:它是可执行内容
|
||||
// (与 vision / 网关上传同一口径),且 `DetectContentType` 只会把它判成文本/XML。
|
||||
func ImageMIMEOf(data []byte) (string, bool) {
|
||||
if len(data) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if tiffHeader(data) {
|
||||
return "image/tiff", true
|
||||
}
|
||||
return knownImageMIME(normalizedMIME(http.DetectContentType(prefixBytes(data, imageSniffPrefix))))
|
||||
}
|
||||
|
||||
// tiffHeader 按 TIFF 的两种字节序头(`II*\0` 小端 / `MM\0*` 大端)判定是不是 TIFF。
|
||||
//
|
||||
// ⚠️ **为什么这里要自己认签名**:Go 的 `http.DetectContentType` 的表里**没有** TIFF
|
||||
// (png/jpeg/gif/webp/bmp 都有,TIFF 没有),只看它会把一份 TIFF 判成
|
||||
// `application/octet-stream` ⇒ 白名单里的 `.tiff` 会被字节永久否决(声明永远过不了
|
||||
// 字节这一关,等于白写在白名单里)。补齐这 4 个字节的签名让「嗅探是唯一裁决权」对
|
||||
// TIFF 也成立;判成 TIFF 的载荷仍受 `MaxImageBytes` 与来源白名单约束。
|
||||
func tiffHeader(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
le := data[0] == 'I' && data[1] == 'I' && data[2] == 0x2a && data[3] == 0x00
|
||||
be := data[0] == 'M' && data[1] == 'M' && data[2] == 0x00 && data[3] == 0x2a
|
||||
return le || be
|
||||
}
|
||||
|
||||
// imageExtAllowed 报告扩展名(含点、小写)是否在**图片**放行集合内。
|
||||
//
|
||||
// ⚠️ 这一份**独立**于 `documents.ExtAllowed` 与 `audioExtAllowed`:往任一份里加图片
|
||||
// 格式都会污染另一条链路(Web 文档上传 / 语音转写)。
|
||||
func imageExtAllowed(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// knownImageMIME 把 MIME 归一到图片白名单内的类型。白名单只有一处(这里),
|
||||
// 扩展名预筛、声明 MIME 预筛与字节嗅探三条路径都经它判定,因此三者不可能给出
|
||||
// 互相矛盾的答案。SVG(`image/svg+xml`)**不在内**:可执行内容绝不进上下文。
|
||||
func knownImageMIME(mime string) (string, bool) {
|
||||
switch mime {
|
||||
case "image/png":
|
||||
return "image/png", true
|
||||
case "image/jpeg", "image/jpg":
|
||||
// image/jpg 不是标准写法,但确有其事(vision 的 knownRasterMIME 同口径)。
|
||||
return "image/jpeg", true
|
||||
case "image/gif":
|
||||
return "image/gif", true
|
||||
case "image/webp":
|
||||
return "image/webp", true
|
||||
case "image/bmp":
|
||||
return "image/bmp", true
|
||||
case "image/tiff":
|
||||
return "image/tiff", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// imageFetchAllowed 是**预筛**:报告一条图片媒体值不值得取字节。它只是省一次白跑的
|
||||
// 下载(字节嗅探才是最终裁决),但方向必须是 deny-by-default 的:
|
||||
//
|
||||
// - 平台给了扩展名(文件名或资产地址末段)⇒ 以它为准:不在图片集合里就**不取**。
|
||||
// 伪造 `payload.html` / `x.exe` 的图片 item 因此连请求都不会发出去;
|
||||
// - 没有扩展名但声明了 MIME ⇒ 以声明为准:不是 `image/*`(例如 `text/html`)就不取;
|
||||
// - 两者都没有 ⇒ 以**平台类型**为声明(口径 3):值得一取,取回后由字节裁决。
|
||||
func imageFetchAllowed(m adapter.Media) bool {
|
||||
if ext := documents.ExtOf(displayName(m)); ext != "" {
|
||||
return imageExtAllowed(ext)
|
||||
}
|
||||
if declared := normalizedMIME(m.MIMEType); declared != "" {
|
||||
_, ok := knownImageMIME(declared)
|
||||
return ok
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizedMIME 归一一个 MIME 串:截掉 `;charset=…` 参数、去空白、转小写。
|
||||
func normalizedMIME(mime string) string {
|
||||
base, _, _ := strings.Cut(mime, ";")
|
||||
return strings.ToLower(strings.TrimSpace(base))
|
||||
}
|
||||
|
||||
// prefixBytes 取字节前缀(不足则原样)。嗅探只看前缀,因此绝不为了判定复制整份字节。
|
||||
func prefixBytes(data []byte, n int) []byte {
|
||||
if len(data) > n {
|
||||
return data[:n]
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// inlineImages 内联**图片类**媒体:预筛声明 → 取回(来源白名单 + 逐跳校验 + 边读边限
|
||||
// + 超时)→ **字节嗅探裁决** → 写回 `Media.Data`。
|
||||
//
|
||||
// - 只内联前 `documents.MaxPerMessage` 张(与文件/语音各自计一条额度:同一条消息里的
|
||||
// 文件、图片、语音互不吃掉对方的额度),超出者不下载并记一条 Warn;
|
||||
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载;
|
||||
// - 与语音不同,**图片不因「消息已有正文」而跳过**:配文的图仍然是一份内容
|
||||
// (语音有平台转写时取字节是白花钱,图片没有替身);
|
||||
// - 任何一步失败都只留空字节 + 一条带来源的日志,**消息照收、回合继续**。
|
||||
func inlineImages(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
seen := 0
|
||||
for i := range msg.Media {
|
||||
m := &msg.Media[i]
|
||||
if !IsImageMedia(msg, *m) {
|
||||
continue
|
||||
}
|
||||
if seen >= documents.MaxPerMessage {
|
||||
log.Warn("inbound image attachments beyond per-message limit not inlined",
|
||||
"platform", msg.Platform, "media", len(msg.Media), "limit", documents.MaxPerMessage)
|
||||
return
|
||||
}
|
||||
seen++
|
||||
if len(m.Data) > 0 {
|
||||
continue
|
||||
}
|
||||
if !imageFetchAllowed(*m) {
|
||||
// 预筛未过:**一个字节都不发出去**。这不是失败,是「这个类型本来就不读」。
|
||||
log.Debug("inbound image not inlined: declared type is not an allowed image",
|
||||
"platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, "size", m.Size)
|
||||
continue
|
||||
}
|
||||
if m.Size > MaxImageBytes {
|
||||
log.Warn("inbound image not inlined: declared size over limit",
|
||||
"platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType,
|
||||
"size", m.Size, "limit", MaxImageBytes)
|
||||
continue
|
||||
}
|
||||
data, err := f.Fetch(ctx, *m)
|
||||
if err != nil {
|
||||
// 只记来源(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。
|
||||
log.Warn("inbound image inline failed",
|
||||
"platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType,
|
||||
"size", m.Size, "host", HostOf(m.URL), "err", err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
log.Warn("inbound image inline returned no bytes",
|
||||
"platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType,
|
||||
"host", HostOf(m.URL))
|
||||
continue
|
||||
}
|
||||
// 纵深:取回器可能没按图片口径设上限(各渠道 Fetcher 不都读过 MaxBytesFor),
|
||||
// 因此这里再判一次——超大的一律不内联,绝不把一份巨图塞进上下文。
|
||||
if int64(len(data)) > MaxImageBytes {
|
||||
log.Warn("inbound image not inlined: fetched size over limit",
|
||||
"platform", msg.Platform, "name", displayName(*m), "size", len(data), "limit", MaxImageBytes)
|
||||
continue
|
||||
}
|
||||
mime, ok := ImageMIMEOf(data)
|
||||
if !ok {
|
||||
// 声明说它是图片、字节说不是(HTML 错误页 / 二进制垃圾 / 判不定):
|
||||
// **不内联**并如实记一条。绝不静默当图片送进上下文。
|
||||
log.Warn("inbound image not inlined: bytes are not a whitelisted raster image",
|
||||
"platform", msg.Platform, "name", displayName(*m), "declared_mime", m.MIMEType,
|
||||
"size", len(data), "host", HostOf(m.URL))
|
||||
continue
|
||||
}
|
||||
m.Data = data
|
||||
log.Debug("inbound image inlined",
|
||||
"platform", msg.Platform, "mime", mime, "size", len(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件锁定**入站图片字节内联**的口径(切片 IM-IMG):
|
||||
// - 正向:白名单声明的图片、以及「平台类型即声明」的无名无 MIME 图片(微信 iLink 的形状)
|
||||
// 都会被取回并写进 `Media.Data`(①);
|
||||
// - 反证:非白名单声明/非图片字节 ⇒ **0 次取回**;顶着图片声明的非图片字节取回后
|
||||
// **绝不内联**(②);
|
||||
// - 超限:声明超限不取回、取回的字节超限不内联,两条都留一条诚实日志(③);
|
||||
// - 失败/超时:不炸、不静默(日志带来源,**绝不记 URL 原串**)(④);
|
||||
// - **图片白名单绝不污染文档白名单与音频白名单**(⑤)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// 各格式的**最小字节样本**:`http.DetectContentType` 只认前缀签名,因此这些就是
|
||||
// 「真图片」在该函数眼中的全部依据(TIFF 由本包自己的 tiffHeader 补签名)。
|
||||
var (
|
||||
pngSample = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
jpegSample = []byte("\xff\xd8\xff\xe0" + "fake-jpeg-body")
|
||||
gifSample = []byte("GIF89a" + "fake-gif-body")
|
||||
webpSample = []byte("RIFF\x00\x00\x00\x00WEBPVP8 " + "fake-webp-body")
|
||||
bmpSample = []byte("BM\x00\x00\x00\x00" + "fake-bmp-body")
|
||||
tiffSample = []byte("II\x2a\x00\x08\x00\x00\x00" + "fake-tiff-body")
|
||||
tiffBE = []byte("MM\x00\x2a\x00\x00\x00\x08" + "fake-tiff-body")
|
||||
htmlSample = []byte("<!DOCTYPE html><html><body>PROXY error page</body></html>")
|
||||
textSample = []byte("这不是图片,只是一段文本。")
|
||||
)
|
||||
|
||||
// imageMessage 构造一条「只有一张图片」的入站消息(Kind 明确,与 QQ/kook 的形状一致)。
|
||||
func imageMessage(m adapter.Media) *adapter.UnifiedMessage {
|
||||
return &adapter.UnifiedMessage{
|
||||
Platform: "test",
|
||||
ContentType: adapter.ContentImage,
|
||||
Media: []adapter.Media{m},
|
||||
}
|
||||
}
|
||||
|
||||
// logBuffer 返回一个把日志写进内存的带来源日志器:用例据此断言「失败不静默」。
|
||||
func logBuffer(t *testing.T) (*slog.Logger, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
return logger.WithSource(logger.New(logger.Options{Level: "debug", Out: &buf}),
|
||||
"internal/channel/mediafetch"), &buf
|
||||
}
|
||||
|
||||
// TestInlineImageFetchesWhitelistedImage 是正向用例:扩展名与字节都是白名单图片 ⇒ 取回一次
|
||||
// 并写进 Data(并且把**嗅探所得**的 MIME 记进日志,不是平台声明的那一个)。
|
||||
func TestInlineImageFetchesWhitelistedImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
||||
}
|
||||
if !bytes.Equal(msg.Media[0].Data, pngSample) {
|
||||
t.Fatalf("Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME 是**微信 iLink 的形状**:
|
||||
// image_item 既没有 file_name 也没有 mime,只有平台类型与 CDN 引用 —— 这正是
|
||||
// 「只认扩展名就会把微信图片整条挡掉」的那个缺口。
|
||||
func TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
mime string
|
||||
}{
|
||||
{"gif", gifSample, "image/gif"},
|
||||
{"jpeg", jpegSample, "image/jpeg"},
|
||||
{"webp", webpSample, "image/webp"},
|
||||
{"bmp", bmpSample, "image/bmp"},
|
||||
{"tiff 小端", tiffSample, "image/tiff"},
|
||||
{"tiff 大端", tiffBE, "image/tiff"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: tc.data}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage,
|
||||
// 无 Name、无 MIMEType:引用形如 CDN 的相对下载引用(归一后是绝对地址)。
|
||||
URL: "https://cdn.example.com/c2c/download?encrypted_query_param=SECRET-VALUE",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1(平台类型即声明)", f.calls())
|
||||
}
|
||||
if !bytes.Equal(msg.Media[0].Data, tc.data) {
|
||||
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
||||
}
|
||||
if got, ok := ImageMIMEOf(msg.Media[0].Data); !ok || got != tc.mime {
|
||||
t.Fatalf("ImageMIMEOf = %q/%v, want %s/true", got, ok, tc.mime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageWithCaptionIsStillInlined 钉住与语音**刻意不同**的一条:消息带正文时图片
|
||||
// 仍要取(配文的图同样是一份内容),绝不能照语音那条「已有文字就跳过」的口径走。
|
||||
func TestInlineImageWithCaptionIsStillInlined(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
|
||||
})
|
||||
msg.Content = "看看这张图 [图片]"
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 || len(msg.Media[0].Data) == 0 {
|
||||
t.Fatalf("带正文的图片没有被内联:calls=%d data=%d 字节", f.calls(), len(msg.Media[0].Data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageSkipsNonImageDeclarationWithoutFetch 是**反证**(②):声明不指向白名单
|
||||
// 图片时**一个字节都不发出去**(取回次数 0,Data 为空)。
|
||||
func TestInlineImageSkipsNonImageDeclarationWithoutFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
m adapter.Media
|
||||
}{
|
||||
{"扩展名是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "page.html"}},
|
||||
{"扩展名是 exe", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "payload.exe"}},
|
||||
{"扩展名是 zip", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "bundle.zip"}},
|
||||
{"地址末段是 zip", "https://cdn.example.com/bundle.zip", adapter.Media{Kind: adapter.MediaImage}},
|
||||
{"声明 MIME 是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "text/html"}},
|
||||
{"声明 MIME 是 svg", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "image/svg+xml"}},
|
||||
{"声明 MIME 是 pdf", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "application/pdf"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
m := tc.m
|
||||
m.URL = tc.url
|
||||
msg := imageMessage(m)
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("非图片声明发起了 %d 次取回,want 0(反证)", f.calls())
|
||||
}
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("非图片声明被内联了")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageSniffVetoesMislabeledBytes 钉住「字节嗅探是唯一裁决权」(②的后半):
|
||||
// 声明是 `.png`/`image/png`,字节却是 HTML ⇒ 取回发生过(必须看字节才知道),但**绝不内联**,
|
||||
// 并且留一条诚实日志。去掉嗅探(只信声明)这条用例就会红。
|
||||
func TestInlineImageSniffVetoesMislabeledBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{"HTML 错误页", htmlSample, "not a whitelisted raster image"},
|
||||
{"纯文本", textSample, "not a whitelisted raster image"},
|
||||
{"空响应体", nil, "returned no bytes"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: tc.data}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
|
||||
})
|
||||
log, buf := logBuffer(t)
|
||||
|
||||
Inline(context.Background(), log, msg, f)
|
||||
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("非图片字节(len=%d)被内联进了上下文", len(msg.Media[0].Data))
|
||||
}
|
||||
if !strings.Contains(buf.String(), tc.want) {
|
||||
t.Fatalf("字节被否决时没有留下诚实日志(want %q):%q", tc.want, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageDeclaredOversizeNotFetched 钉住声明超限不取回(③前半)。
|
||||
func TestInlineImageDeclaredOversizeNotFetched(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/big.png", Name: "big.png",
|
||||
Size: MaxImageBytes + 1,
|
||||
})
|
||||
log, buf := logBuffer(t)
|
||||
|
||||
Inline(context.Background(), log, msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("声明超限的图片仍被取回 %d 次,want 0", f.calls())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "declared size over limit") {
|
||||
t.Fatalf("声明超限没有留下日志:%q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageFetchedOversizeNotInlined 钉住「取回的字节超限一律不内联」(③后半,纵深):
|
||||
// 渠道 Fetcher 没按图片口径设上限时(各渠道尚未全部改用 MaxBytesFor),取回层这一道
|
||||
// 仍然拦得住,绝不会把一份巨图塞进上下文。
|
||||
func TestInlineImageFetchedOversizeNotInlined(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: append(append([]byte(nil), pngSample...), make([]byte, MaxImageBytes)...)}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/huge.png", Name: "huge.png",
|
||||
})
|
||||
log, buf := logBuffer(t)
|
||||
|
||||
Inline(context.Background(), log, msg, f)
|
||||
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("超限的图片字节被内联了")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "fetched size over limit") {
|
||||
t.Fatalf("超限没有留下日志:%q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageSkipsExistingBytesAndCapsPerMessage 钉住两条省流与限额:已有字节不重复
|
||||
// 下载;同一条消息里最多内联 `documents.MaxPerMessage` 张。
|
||||
func TestInlineImageSkipsExistingBytesAndCapsPerMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("已有字节", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", Data: []byte("local"),
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 || string(msg.Media[0].Data) != "local" {
|
||||
t.Fatalf("已有字节的图片被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data)
|
||||
}
|
||||
})
|
||||
t.Run("条数上限", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/0.png", Name: "0.png",
|
||||
})
|
||||
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
||||
msg.Media = append(msg.Media, adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/x.png", Name: "x.png",
|
||||
})
|
||||
}
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != documents.MaxPerMessage {
|
||||
t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInlineImageQuotaIsSeparateFromFilesAndVoices 钉住「三条链路各自计额度」:同一条消息里
|
||||
// 的文件、图片、语音互不吃掉对方的条数额度。
|
||||
func TestInlineImageQuotaIsSeparateFromFilesAndVoices(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := &adapter.UnifiedMessage{Platform: "test"}
|
||||
for i := 0; i < documents.MaxPerMessage; i++ {
|
||||
msg.Media = append(msg.Media,
|
||||
adapter.Media{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"})
|
||||
}
|
||||
msg.Media = append(msg.Media,
|
||||
adapter.Media{Kind: adapter.MediaImage, URL: "https://cdn.example.com/p.png", Name: "p.png"})
|
||||
msg.Media = append(msg.Media,
|
||||
adapter.Media{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.ogg", Name: "v.ogg"})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
// 5 个文件 + 1 张图 + 1 条语音:三条链路各自在额度内,全部取回。
|
||||
if f.calls() != documents.MaxPerMessage+2 {
|
||||
t.Fatalf("取回次数 = %d, want %d(三条链路各自的额度不许互相吃掉)",
|
||||
f.calls(), documents.MaxPerMessage+2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageFailureIsHonestNotSilent 钉住失败/超时路径(④):不炸、消息其余字段不动、
|
||||
// 留一条**带来源**的 Warn;并且日志里**只有来源主机**,绝不出现完整 URL(可能带签名凭据)。
|
||||
func TestInlineImageFailureIsHonestNotSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "encrypted_query_param=SUPER_SECRET_VALUE"
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"平台返回非成功状态", ErrBadStatus},
|
||||
{"超时", context.DeadlineExceeded},
|
||||
{"来源不在白名单", ErrOriginNotAllowed},
|
||||
{"超过体积上限", ErrTooLarge},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{err: tc.err}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/c2c/download?" + secret, Name: "a.png",
|
||||
})
|
||||
msg.Content = "[图片]"
|
||||
log, buf := logBuffer(t)
|
||||
|
||||
Inline(context.Background(), log, msg, f)
|
||||
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("失败时仍写入了字节")
|
||||
}
|
||||
if msg.Content != "[图片]" {
|
||||
t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责)", msg.Content)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "inbound image inline failed") {
|
||||
t.Fatalf("失败路径静默了:%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "source=internal/channel/mediafetch") {
|
||||
t.Fatalf("日志没有带来源(AGENTS §10):%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "host=https://cdn.example.com") {
|
||||
t.Fatalf("日志里没有来源主机,定位不到是哪台平台主机:%q", out)
|
||||
}
|
||||
if strings.Contains(out, secret) || strings.Contains(out, "/c2c/download") {
|
||||
t.Fatalf("日志泄露了完整 URL 或签名参数:%q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageEmptyBytesIsHonest 钉住「取到 0 字节」这条分支同样不静默。
|
||||
func TestInlineImageEmptyBytesIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: nil}
|
||||
msg := imageMessage(adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
|
||||
})
|
||||
log, buf := logBuffer(t)
|
||||
|
||||
Inline(context.Background(), log, msg, f)
|
||||
|
||||
if !strings.Contains(buf.String(), "returned no bytes") {
|
||||
t.Fatalf("0 字节路径静默了:%q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageWhitelistDoesNotPolluteOtherWhitelists 是本切片的硬红线(⑤):图片放行集合是
|
||||
// **独立**的一份 —— 既不许污染 `documents.ExtAllowed`(Web 文档上传口径),也不许与音频
|
||||
// 白名单互相渗透。
|
||||
func TestImageWhitelistDoesNotPolluteOtherWhitelists(t *testing.T) {
|
||||
t.Parallel()
|
||||
imageExts := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"}
|
||||
for _, ext := range imageExts {
|
||||
if !imageExtAllowed(ext) {
|
||||
t.Fatalf("imageExtAllowed(%q) = false, want true", ext)
|
||||
}
|
||||
if documents.ExtAllowed(ext) {
|
||||
t.Fatalf("documents.ExtAllowed(%q) = true —— 图片格式污染了文档白名单", ext)
|
||||
}
|
||||
if audioExtAllowed(ext) {
|
||||
t.Fatalf("audioExtAllowed(%q) = true —— 图片格式污染了音频白名单", ext)
|
||||
}
|
||||
}
|
||||
// 反方向:文档/音频/可执行格式都不是图片。
|
||||
for _, ext := range []string{".txt", ".pdf", ".mp3", ".ogg", ".wav", ".exe", ".svg", ".html", ""} {
|
||||
if imageExtAllowed(ext) {
|
||||
t.Fatalf("imageExtAllowed(%q) = true, want false", ext)
|
||||
}
|
||||
}
|
||||
// 三份集合的规模与成员都不互相包含(防止「一条路复用两份名单」)。
|
||||
if documents.ExtAllowed(".png") || documents.ExtAllowed(".mp3") || documents.ExtAllowed(".amr") {
|
||||
t.Fatal("文档白名单里出现了图片或音频格式")
|
||||
}
|
||||
if audioExtAllowed(".png") || audioExtAllowed(".pdf") {
|
||||
t.Fatal("音频白名单里出现了图片或文档格式")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageMIMEOfIsTheByteArbiter 钉住字节裁决本身:白名单光栅图认得出来、SVG/HTML/文本/
|
||||
// 空字节一律判否,且 `image/jpg` 这种非标准写法被归一到 `image/jpeg`。
|
||||
func TestImageMIMEOfIsTheByteArbiter(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"png", pngSample, "image/png", true},
|
||||
{"jpeg", jpegSample, "image/jpeg", true},
|
||||
{"gif", gifSample, "image/gif", true},
|
||||
{"webp", webpSample, "image/webp", true},
|
||||
{"bmp", bmpSample, "image/bmp", true},
|
||||
{"tiff 小端", tiffSample, "image/tiff", true},
|
||||
{"tiff 大端", tiffBE, "image/tiff", true},
|
||||
{"html 错误页", htmlSample, "", false},
|
||||
{"纯文本", textSample, "", false},
|
||||
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", false},
|
||||
{"空字节", nil, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := ImageMIMEOf(tc.data)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("ImageMIMEOf = %q/%v, want %q/%v", got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, ok := knownImageMIME("image/jpg"); !ok || got != "image/jpeg" {
|
||||
t.Fatalf("knownImageMIME(image/jpg) = %q/%v, want image/jpeg/true", got, ok)
|
||||
}
|
||||
if _, ok := knownImageMIME("image/svg+xml"); ok {
|
||||
t.Fatal("SVG 被图片白名单放行了(它是可执行内容)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxBytesForPerKind 钉住「上限按类型分档」的唯一出处:图片与语音 5 MiB、其余文档类
|
||||
// 20 MiB —— 各渠道 Fetcher 只该引用它,不许自己写数。
|
||||
func TestMaxBytesForPerKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
m adapter.Media
|
||||
want int64
|
||||
}{
|
||||
{"图片", adapter.Media{Kind: adapter.MediaImage}, MaxImageBytes},
|
||||
{"语音", adapter.Media{Kind: adapter.MediaVoice}, MaxVoiceBytes},
|
||||
{"文件", adapter.Media{Kind: adapter.MediaFile}, documents.DefaultMaxBytes},
|
||||
{"无类型", adapter.Media{}, documents.DefaultMaxBytes},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MaxBytesFor(tc.m); got != tc.want {
|
||||
t.Fatalf("%s: MaxBytesFor = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if MaxImageBytes == documents.DefaultMaxBytes {
|
||||
t.Fatal("图片上限与文档上限撞在同一个数上:本切片要求图片用 5 MiB、文档用 20 MiB")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineImageIgnoresOtherMediaKinds 钉住分支边界:图片分支只碰图片媒体,文件/语音媒体
|
||||
// 不会被它写入 Data(各自的分支负责),且非图片媒体的失败不会影响图片。
|
||||
func TestInlineImageIgnoresOtherMediaKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: pngSample}
|
||||
msg := &adapter.UnifiedMessage{
|
||||
Platform: "test",
|
||||
Media: []adapter.Media{
|
||||
{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"},
|
||||
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.silk", Name: "v.silk"},
|
||||
},
|
||||
}
|
||||
log, _ := logBuffer(t)
|
||||
|
||||
inlineImages(context.Background(), log, msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("图片分支对非图片媒体发起了 %d 次取回,want 0", f.calls())
|
||||
}
|
||||
}
|
||||
@@ -197,10 +197,11 @@ func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineIgnoresImageAndNonAudioVoice 钉住边界:图片媒体不走本循环(图片走视觉
|
||||
// 链路),**格式不可转写**的语音也不取字节(切片 IM-ASR 起,白名单格式的语音**会**
|
||||
// 被取回,见 inline_voice_test.go)。
|
||||
func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) {
|
||||
// TestInlineIgnoresNonImageAndNonAudioVoice 钉住分支边界:**声明不是图片格式**的图片媒体
|
||||
// (这里是 `.txt`)与格式不可转写的语音一样**不取字节**(切片 IM-IMG 起,声明命中图片白名单
|
||||
// 的图片**会**被 `inlineImages` 取回,见 inline_image_test.go;音频白名单里的语音同理见
|
||||
// inline_voice_test.go)。
|
||||
func TestInlineIgnoresNonImageAndNonAudioVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := &adapter.UnifiedMessage{
|
||||
@@ -215,7 +216,7 @@ func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) {
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("非文件类媒体被取回了 %d 次,want 0", f.calls())
|
||||
t.Fatalf("声明不是图片格式的媒体被取回了 %d 次,want 0", f.calls())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,20 +230,43 @@ func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体不走文档内联。
|
||||
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
|
||||
// kookPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名)。
|
||||
var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界:
|
||||
// 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但
|
||||
// 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。
|
||||
// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。
|
||||
func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
t.Run("字节不是图片: 取回但不内联", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t) // 默认正文是文本(Content-Type: text/plain)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("图片媒体被文档内联取回了 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("图片媒体被写入了字节(应留给视觉链路)")
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("图片声明的媒体取回次数 = %d, want 1(图片现在也进内联链路)", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("非图片字节被写入了字节(嗅探是唯一裁决权)")
|
||||
}
|
||||
})
|
||||
t.Run("字节是真图: 内联", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
s.body = kookPNG
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(c.message(t).Media[0].Data, kookPNG) {
|
||||
t.Fatalf("Media.Data = %q, want 取回的图片字节", c.message(t).Media[0].Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -110,16 +110,20 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。
|
||||
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
|
||||
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
|
||||
// inlineInboundMedia 把受支持格式的附件(**文件类、图片类、语音类**)取回、解密并内联到
|
||||
// 消息上(切片 IM-1 / IM-ASR / IM-IMG)。失败不抛出:该附件保持无字节,由 channelbridge 的
|
||||
// 入站摄取照既有口径给模型一行诚实提示(design/24 §4.4)——本切片绝不为了「让附件能读」
|
||||
// 而把失败吞掉。
|
||||
//
|
||||
// 图片走的是**同一条路**:iLink 的 image_item 不带文件名也不带 MIME,因此它的可取性由
|
||||
// 平台类型 + 字节嗅探判定(`mediafetch.inlineImages`),来源仍受下面这份白名单约束。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData})
|
||||
}
|
||||
|
||||
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
|
||||
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
|
||||
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、独立的体积上限,以及
|
||||
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、按类型的体积上限,以及
|
||||
// **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。
|
||||
type ilinkFetcher struct {
|
||||
a *Adapter
|
||||
@@ -128,21 +132,23 @@ type ilinkFetcher struct {
|
||||
raw []byte
|
||||
}
|
||||
|
||||
// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由
|
||||
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
|
||||
// Fetch 取回并解密一份微信 iLink 附件(**文件、图片、语音、视频同一条路**)。体积上限与
|
||||
// 超时都不在这里写数:上限按媒体类型由 `mediafetch.MaxBytesFor` 给出(图片 5 MiB、语音
|
||||
// 5 MiB、文档类附件 20 MiB —— 一份巨图绝不会按文档的 20 MiB 收下),超时是具名常量
|
||||
// `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限,
|
||||
// 与本切片的文档上限刻意解耦)。
|
||||
// 与本切片的内联上限刻意解耦)。
|
||||
func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
key, err := f.inboundKey(m)
|
||||
if err != nil {
|
||||
return nil, 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,
|
||||
URL: f.a.cdnURL(m.URL),
|
||||
Allow: f.a.assetAllowlist(),
|
||||
Header: f.a.inboundHeaders(),
|
||||
Client: f.a.httpClient,
|
||||
MaxBytes: mediafetch.MaxBytesFor(m),
|
||||
Timeout: mediafetch.DefaultTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
|
||||
@@ -172,6 +178,10 @@ func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) {
|
||||
// `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用
|
||||
// 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute),
|
||||
// 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。
|
||||
//
|
||||
// 它对**所有**媒体类型一视同仁:文件、图片、语音、视频都经 `ilinkFetcher.Fetch` 取回,
|
||||
// 因此图片 URL(绝对 `full_url` 或 CDN 根下的相对引用)同样受这一份白名单约束 ——
|
||||
// 图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限,不是更宽的网络可达面。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package weixin_ilink
|
||||
|
||||
// 本文件锁定微信 iLink 入站**图片**的采集口径(切片 IM-IMG):CDN 引用 → 来源白名单取回
|
||||
// → 剥 AES-ECB 层 → 写进 `Media.Data`(交回给桥接器内联成 data: URI 进视觉链路)。
|
||||
//
|
||||
// 覆盖:正向(含真机形状——`image_item` 既无 file_name 也无 mime)、按条 `aeskey`、
|
||||
// 来源白名单反证(白名单外的绝对引用 **0 次命中**)、图片口径的体积上限(5 MiB,不是文档的
|
||||
// 20 MiB)、以及「声明说图片、字节说不是」时**不内联**。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// pngBytes 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名,
|
||||
// 因此样本必须是完整签名 + 载荷,短于签名就会被判成非图片)。
|
||||
var pngBytes = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// imageItemRaw 构造一条带图片 item 的原始 iLink 消息。imageItem 就是真机协议里的
|
||||
// `image_item` 对象(`{"media": {...}}`,可能另有 `aeskey`);**名字与 MIME 本就不存在**,
|
||||
// 这正是本切片要覆盖的形状。
|
||||
func imageItemRaw(t *testing.T, imageItem map[string]any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"client_id": "c-img", "from_user_id": "u-1", "message_type": msgTypeUser,
|
||||
"item_list": []any{map[string]any{"type": itemImage, "image_item": imageItem}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal image item: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestInboundImageIsInlinedAndDecrypted 是正向用例:真机形状的图片 item(相对下载引用、
|
||||
// 无名字无 MIME)被取回、解密并内联,且字节确实是白名单光栅图。
|
||||
func TestInboundImageIsInlinedAndDecrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x41)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
|
||||
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
|
||||
}))
|
||||
if m == nil {
|
||||
t.Fatal("convertMessage 没有解析出消息")
|
||||
}
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage {
|
||||
t.Fatalf("媒体不是图片:%+v", msg.Media)
|
||||
}
|
||||
if !bytes.Equal(msg.Media[0].Data, pngBytes) {
|
||||
t.Fatalf("Media.Data = %q, want 解密后的图片字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
// 桥接器构造 data: URI 时用的就是这个裁决入口,这里顺手钉住它对真机字节给得出类型。
|
||||
if mime, ok := mediafetch.ImageMIMEOf(msg.Media[0].Data); !ok || mime != "image/png" {
|
||||
t.Fatalf("ImageMIMEOf = %q/%v, want image/png/true", mime, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded 钉住来源白名单对**图片**同样生效:
|
||||
// `full_url` 是绝对地址时 `cdnURL` 原样放行,唯一能挡住「对端伪造绝对引用」的就是白名单。
|
||||
func TestInboundImageAbsoluteRefOffAllowlistIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x42)
|
||||
inside := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
|
||||
outside := newIlinkAssetServer(t, encrypted(t, key, pngBytes))
|
||||
a, c := newInlineIlinkAdapter(t, inside, key)
|
||||
|
||||
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
|
||||
"media": map[string]any{"full_url": outside.srv.URL + "/c2c/img.png", "encrypt_query_param": "ENC"},
|
||||
}))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if outside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单外的图片主机被请求了 %d 次,want 0", outside.hits.Load())
|
||||
}
|
||||
if inside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单内的主机不该收到这次请求(引用指向别处):%d 次", inside.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("白名单外的图片被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImagePerItemAesKeyIsUsed 钉住图片的**按条密钥**(`image_item.aeskey`,十六进制)
|
||||
// 优先于 bot_token 兜底:这里刻意让两条路的密钥不同,并断言解出的字节**逐字节等于**明文 ——
|
||||
// 用错密钥时 AES-ECB 仍会「成功」返回乱码,只断言「非空」会假绿。
|
||||
func TestInboundImagePerItemAesKeyIsUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
perItem := keyOf(0x43)
|
||||
fallback := keyOf(0x44)
|
||||
plain := append([]byte(nil), pngBytes...)
|
||||
s := newIlinkAssetServer(t, encrypted(t, perItem, plain))
|
||||
a, c := newInlineIlinkAdapter(t, s, fallback)
|
||||
|
||||
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
|
||||
"aeskey": hex.EncodeToString(perItem),
|
||||
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
|
||||
}))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
got := c.message(t).Media[0].Data
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Fatalf("Media.Data = %q, want %q(按条 aeskey 没有被用上)", got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageUsesImageByteCap 钉住图片走**图片口径**的上限(5 MiB),而不是文档的
|
||||
// 20 MiB:假 CDN 下发的是一份**合法 AES 密文**,解密后是「PNG 签名 + 填充」的 5 MiB+64
|
||||
// 字节 —— 它介于两个上限之间,且字节本身是**能通过嗅探的合法 PNG 前缀**,因此「没有被内联」
|
||||
// 只可能来自图片上限(用文档上限就会把它收下)。
|
||||
func TestInboundImageUsesImageByteCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x45)
|
||||
big := append(append([]byte(nil), pngBytes...), bytes.Repeat([]byte("x"), int(mediafetch.MaxImageBytes)+64-len(pngBytes))...)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, big))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
|
||||
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
|
||||
}))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if got := len(c.message(t).Media[0].Data); got != 0 {
|
||||
t.Fatalf("超限的图片被内联了(%d 字节);图片上限应为 %d", got, mediafetch.MaxImageBytes)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("CDN 命中 = %d, want 1(取回发生在上限判定之前)", s.hits.Load())
|
||||
}
|
||||
// 反证夹具本身是「合法图片」:去掉上限后会内联的字节,这里必须仍然只因为体积被拒。
|
||||
if _, ok := mediafetch.ImageMIMEOf(big); !ok {
|
||||
t.Fatal("夹具要不是合法 PNG,本用例就变成了嗅探拒绝的假绿")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageBytesAreSniffedNotTrusted 钉住「字节嗅探是唯一裁决权」在微信这条路上
|
||||
// 也成立:平台把它标成图片、下发的却是 HTML(AES 密文合法、解密也成功)⇒ **不内联**。
|
||||
func TestInboundImageBytesAreSniffedNotTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x46)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, []byte("<!DOCTYPE html><html>PROXY</html>")))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(imageItemRaw(t, map[string]any{
|
||||
"media": map[string]any{"encrypt_query_param": "ENC-PARAM"},
|
||||
}))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("非图片字节被当成图片内联了")
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("CDN 命中 = %d, want 1(必须看字节才知道它不是图片)", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxBytesForImageIsNotDocumentCap 钉住渠道侧引用的那一份「按类型分档」映射:图片 5 MiB、
|
||||
// 文档类 20 MiB,两者不允许撞在一起(一个数只能有一个答案)。
|
||||
func TestMaxBytesForImageIsNotDocumentCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := mediafetch.MaxBytesFor(adapter.Media{Kind: adapter.MediaImage}); got != mediafetch.MaxImageBytes {
|
||||
t.Fatalf("MaxBytesFor(image) = %d, want %d", got, mediafetch.MaxImageBytes)
|
||||
}
|
||||
if mediafetch.MaxImageBytes >= 20<<20 {
|
||||
t.Fatalf("图片上限 %d 已经不小于文档上限,两者解耦失效", mediafetch.MaxImageBytes)
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,8 @@ func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) {
|
||||
|
||||
// TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey`
|
||||
// (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有
|
||||
// 走对分支才解得出正文。图片不进内联链路(mediafetch 只管文件类),所以直接驱动 Fetcher。
|
||||
// 走对分支才解得出正文。本用例钉的是**取回层**的密钥选择(图片现在也走内联链路,但这里的
|
||||
// 载荷不是可嗅探的真图字节,走内联会被字节裁决挡下),所以直接驱动 Fetcher。
|
||||
func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) {
|
||||
t.Parallel()
|
||||
right := keyOf(0x42)
|
||||
|
||||
Reference in new issue
Block a user