- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
242 lines
12 KiB
Go
242 lines
12 KiB
Go
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))
|
||
}
|
||
}
|