Files
AskaEth f82c76df27
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
- 依赖: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 与新的边界。
2026-10-04 19:46:36 +08:00

161 lines
7.3 KiB
Go
Raw Permalink 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 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)
}
}