- 新增 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 超量用例)
108 lines
4.5 KiB
Go
108 lines
4.5 KiB
Go
package telegram
|
||
|
||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类
|
||
// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个
|
||
// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。
|
||
//
|
||
// 三条边界:
|
||
// - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机;
|
||
// 平台返回的路径还要过 `sanitizeFilePath`(拒绝对协议前缀、`..`、查询串);
|
||
// - 来源白名单 = 已配置的 API 根(默认 `https://api.telegram.org`):白名单之外的
|
||
// 主机**一个字节都不发出去**;
|
||
// - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用),
|
||
// 这里只负责「把平台引用换成一个平台资产地址」。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||
)
|
||
|
||
// getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。
|
||
const getFileMethod = "getFile"
|
||
|
||
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。
|
||
// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
|
||
// 一行诚实提示(design/24 §4.4)。
|
||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||
mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a})
|
||
}
|
||
|
||
// telegramFetcher 实现 mediafetch.Fetcher:Telegram 给的是 `file_id`,因此它必须
|
||
// 先问平台「这个 id 对应哪个路径」,再下载。
|
||
type telegramFetcher struct{ a *Adapter }
|
||
|
||
// Fetch 取回一份 Telegram 附件的字节。体积上限与超时都不在这里写数:前者由
|
||
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
|
||
// `mediafetch.DefaultTimeout`。
|
||
func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||
filePath, err := f.a.filePath(ctx, m.URL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return mediafetch.Fetch(ctx, mediafetch.Request{
|
||
URL: f.a.fileURL(filePath),
|
||
Allow: f.a.assetAllowlist(),
|
||
Client: f.a.client,
|
||
Timeout: mediafetch.DefaultTimeout,
|
||
})
|
||
}
|
||
|
||
// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址
|
||
// 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把
|
||
// 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。
|
||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||
origin, err := mediafetch.OriginOf(a.baseURL)
|
||
if err != nil {
|
||
return mediafetch.NewAllowlist()
|
||
}
|
||
return mediafetch.NewAllowlist(origin)
|
||
}
|
||
|
||
// fileURL 把平台给出的相对路径拼成下载地址。token 在路径里(与 endpoint 同口径),
|
||
// 绝不进查询串;这个地址**只用于请求**,绝不进日志(mediafetch 的错误只带来源)。
|
||
func (a *Adapter) fileURL(filePath string) string {
|
||
return a.baseURL + "/file/bot" + a.cfg.BotToken + "/" + strings.TrimLeft(filePath, "/")
|
||
}
|
||
|
||
// filePath 调 getFile 把平台文件 id 换成平台自己返回的相对路径,并做形态校验。
|
||
func (a *Adapter) filePath(ctx context.Context, fileID string) (string, error) {
|
||
if strings.TrimSpace(fileID) == "" {
|
||
return "", ErrNoFilePath
|
||
}
|
||
out, err := a.doJSON(ctx, getFileMethod, map[string]any{"file_id": fileID})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var res struct {
|
||
FilePath string `json:"file_path"`
|
||
}
|
||
if err := json.Unmarshal(out.Result, &res); err != nil {
|
||
return "", fmt.Errorf("telegram: getFile result: %w", err)
|
||
}
|
||
return sanitizeFilePath(res.FilePath)
|
||
}
|
||
|
||
// sanitizeFilePath 校验平台返回的文件路径:非空、不含协议前缀 / `..` / 查询串 /
|
||
// 控制字符。校验失败时返回 ErrNoFilePath —— 绝不把可疑路径拼进下载地址。
|
||
func sanitizeFilePath(filePath string) (string, error) {
|
||
path := strings.TrimSpace(filePath)
|
||
if path == "" {
|
||
return "", ErrNoFilePath
|
||
}
|
||
for _, r := range path {
|
||
if r < ' ' || r == 0x7f {
|
||
return "", fmt.Errorf("%w: 平台返回的路径含控制字符", ErrNoFilePath)
|
||
}
|
||
}
|
||
if strings.Contains(path, "://") || strings.Contains(path, "..") ||
|
||
strings.ContainsAny(path, "\\?#") {
|
||
return "", fmt.Errorf("%w: 平台返回的路径形态非法", ErrNoFilePath)
|
||
}
|
||
return path, nil
|
||
}
|