Files
Cyrene_v2/internal/channel/mediafetch/inline_image.go
T
AskaEth 6a545badbd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00

242 lines
12 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 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))
}
}