- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。
- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。
- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。
- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。
- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。
- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。
- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
161 lines
7.3 KiB
Go
161 lines
7.3 KiB
Go
package gateway
|
||
|
||
// 本文件归属 `chat` 请求里的文档引用(design/24 §4.2 路线① / §6.1):请求形态、
|
||
// 服务端校验(条数上限、存储名形态、文件真实存在)与该回合用户消息的装配。
|
||
//
|
||
// 它刻意**不改** S1 已落库的 `documents.go`:解析只复用那边的
|
||
// `validDocumentName` / `documentReadMIME` / `documentFilePrefix` /
|
||
// `documentNotFoundError` / `documentsLog`,读侧行为零变动。
|
||
//
|
||
// 两条硬规则:
|
||
// - 只接受**服务端签发的存储名**(`POST /api/v1/documents` 响应里的 name),
|
||
// 绝不接受任意 http(s) URL —— 不重开 F3 那种「服务端代取 URL」的 SSRF 面
|
||
// (design/24 §5);
|
||
// - 文档部件只挂在 RoleUser 回合上(design/24 §2.3-③、design/11 §10.2)。
|
||
//
|
||
// 失败一律可见(design/24 §4.4):条数超限、名字形态非法、文件不存在都让整个
|
||
// 请求被拒;绝不做「丢掉这条附件但照样起回合」的静默处理。
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// maxChatDocuments 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。
|
||
// 取值取自共享存储核心(切片 M1):Web 上传的单回合上限与 IM 入站附件的单条
|
||
// 上限因此同源,不可能一处改宽、另一处照旧。
|
||
const maxChatDocuments = documents.MaxPerMessage
|
||
|
||
// tooManyDocumentsCode 是附件数超限的机器可读错误码(design/24 §6.2 表格)。
|
||
// 它与 image 侧的 image_too_large 同一风格:客户端只看 code 就能分开处理。
|
||
const tooManyDocumentsCode = "too_many_documents"
|
||
|
||
// documentsUnavailableCode 报告本进程没有接文档解析器(未接线的构建 / 单元测试
|
||
// 构造的裸 hub)。它是**内部接线**问题,绝不是客户端输入问题:此时带了附件的
|
||
// 回合被明确拒绝,而不是把附件静默丢掉。
|
||
const documentsUnavailableCode = "chat_documents_unavailable"
|
||
|
||
// chatDocumentRef 是 `chat` 请求里的一条文档引用。它只有 `name` 一个字段:
|
||
// 客户端能给的就是 `POST /api/v1/documents` 响应里的存储名,MIME / URL / 大小
|
||
// 一律由服务端自己解析(design/24 §3.2:字节嗅探是唯一裁决,客户端声明可被
|
||
// 否决)。REST 与 WS 上行帧**同形**(design/24 §6.1)。
|
||
type chatDocumentRef struct {
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// documentResolver 把一组文档引用解析为服务端文档部件。REST 与 WS 两条入口
|
||
// 共用同一个实现(`Server.resolveChatDocuments`),因此两边不可能分叉。
|
||
type documentResolver func(refs []chatDocumentRef) ([]model.Document, error)
|
||
|
||
// resolveChatDocuments 校验并解析一个回合携带的全部文档引用。条数上限先于任何
|
||
// 文件系统访问被判定(用最小的代价拒最早),随后逐条解析;任一条非法或不存在
|
||
// 都让整个请求被拒 —— 静默丢掉一条附件正是 design/24 §4.4 禁止的
|
||
// 「传上去了但双方都不知道」。
|
||
func (s *Server) resolveChatDocuments(refs []chatDocumentRef) ([]model.Document, error) {
|
||
if len(refs) > maxChatDocuments {
|
||
return nil, tooManyDocumentsError(len(refs))
|
||
}
|
||
out := make([]model.Document, 0, len(refs))
|
||
for _, ref := range refs {
|
||
doc, err := s.resolveChatDocument(ref)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, doc)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// resolveChatDocument 把一条引用解析为文档部件。名字形态必须与 S1 的
|
||
// `validDocumentName` 同规则(32 位小写十六进制主名 + 白名单扩展名),且文件
|
||
// 必须**确实存在**于 `<DataDir>/uploads/documents/`。形态非法、路径穿越、
|
||
// 文件不存在一律收敛到同一个 404 document_not_found —— 与读端点同一口径,
|
||
// 既不泄露「是否存在」以外的信息,也不可能被当成取任意 URL 的入口。
|
||
//
|
||
// MIME 由读侧的字节嗅探给出(`documentReadMIME`)、大小取自文件本身:落盘内容
|
||
// 就是唯一真源,不问数据库、也不信客户端。
|
||
func (s *Server) resolveChatDocument(ref chatDocumentRef) (model.Document, error) {
|
||
if !validDocumentName(ref.Name) {
|
||
return model.Document{}, documentNotFoundError()
|
||
}
|
||
dir, err := s.documentsDir()
|
||
if err != nil {
|
||
return model.Document{}, documentNotFoundError()
|
||
}
|
||
path := filepath.Join(dir, ref.Name)
|
||
if filepath.Dir(path) != filepath.Clean(dir) {
|
||
return model.Document{}, documentNotFoundError()
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return model.Document{}, documentNotFoundError()
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
info, err := f.Stat()
|
||
if err != nil || info.IsDir() {
|
||
return model.Document{}, documentNotFoundError()
|
||
}
|
||
return model.Document{
|
||
Name: ref.Name,
|
||
MIME: documentReadMIME(f, ref.Name),
|
||
URL: documentFilePrefix + ref.Name,
|
||
Size: info.Size(),
|
||
}, nil
|
||
}
|
||
|
||
// tooManyDocumentsError 构造附件数超限错误:400 + too_many_documents,带上限与
|
||
// 本次条数,使客户端不必猜是哪条限额挡下的。
|
||
func tooManyDocumentsError(n int) error {
|
||
return apperr.Wrap(apperr.ErrBadRequest, tooManyDocumentsCode,
|
||
"单回合附件最多 %d 条,本次 %d 条", maxChatDocuments, n)
|
||
}
|
||
|
||
// documentsUnavailableError 构造「未接文档解析器」错误:内部接线问题,因此走
|
||
// 500 + 显式错误码,绝不假装这回合只有文本。
|
||
func documentsUnavailableError() error {
|
||
return apperr.Wrap(apperr.ErrInternal, documentsUnavailableCode, "文档解析器未接线")
|
||
}
|
||
|
||
// chatDocumentErrorCode 取出错误里可对客户端暴露的机器可读码(WS 错误帧用)。
|
||
// 取不到时回退 bad_request —— 内部细节绝不回给客户端。
|
||
func chatDocumentErrorCode(err error) string {
|
||
if appErr, ok := apperr.As(err); ok && appErr.Code != "" {
|
||
return appErr.Code
|
||
}
|
||
return wsBadRequestCode
|
||
}
|
||
|
||
// chatTurnMessages 为一个回合构建用户消息:文本、图片与文档部件挂在**同一条**
|
||
// RoleUser 消息上。文档部件由 model.DocumentMessage 构造(design/24 §2.2),
|
||
// 且只可能落在 RoleUser 上(§2.3-③)。
|
||
//
|
||
// 纯附件回合(无文本、无图片)同样得到一条消息,且文档部件让它不被判成空回合
|
||
// (见 session_tail.go 的 messagesEmpty)。这一分支**不塞空文本部件**:空文本块
|
||
// 会被 provider 拒,而文档部件本身就是这条消息的内容。
|
||
func chatTurnMessages(text, image string, docs []model.Document) []model.Message {
|
||
msg := baseUserMessage(text, image)
|
||
for _, doc := range docs {
|
||
msg.Content = append(msg.Content, model.DocumentMessage(model.RoleUser, "", doc).Content...)
|
||
}
|
||
return []model.Message{msg}
|
||
}
|
||
|
||
// baseUserMessage 返回回合的基底用户消息:有图片时是可带文本的图片消息,否则是
|
||
// 文本消息。它就是引入文档之前 `userChatMessages` 的行为,逐字不变(唯一的
|
||
// 差别是文本为空时给出不带文本部件的空基底,供纯附件回合使用)。
|
||
func baseUserMessage(text, image string) model.Message {
|
||
switch {
|
||
case strings.TrimSpace(image) != "":
|
||
return model.ImageMessage(model.RoleUser, text, image)
|
||
case text == "":
|
||
return model.Message{Role: model.RoleUser}
|
||
default:
|
||
return model.TextMessage(model.RoleUser, text)
|
||
}
|
||
}
|