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)) } }