feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 依赖: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 与新的边界。
This commit is contained in:
AskaEth committed 2026-10-04 19:46:36 +08:00
1 parent c4e09afd38
commit f82c76df27
14 files changed
+926 -94

No files matched your search

+21 -13
View File
@@ -72,10 +72,12 @@ const (
// documentsLogSource 是文档子系统的日志来源标签。
documentsLogSource = "gateway/documents"
// documentSniffOKMIME 是唯一放行的嗅探结果基类型。白名单里的每一种文档
// (文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// documentSniffOKMIME 是**纯文本类**放行的嗅探结果基类型。白名单里的文本类
// 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。
// PDF(切片 B1)是唯一的例外:它有 `application/pdf` 这个自己的签名,放行
// 条件必须与 `.pdf` 扩展名**成对**判定(`documents.ContentAllowed`)。
documentSniffOKMIME = documents.SniffOKMIME
// documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合
@@ -126,7 +128,8 @@ func documentExtOf(filename string) string {
// 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/
// `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便
// 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
// 白名单本体只存在于 `documents.ExtAllowed`(22 项,design/24 §9.2-2 定稿)。
// 白名单本体只存在于 `documents.ExtAllowed`(23 项 = design/24 §9.2-2 的 22 项
// 文本类 + `.pdf`;切片 B1 纳入 PDF)。
func documentExtAllowed(ext string) bool {
return documents.ExtAllowed(ext)
}
@@ -141,11 +144,12 @@ func documentBaseMIME(sniffed string) string {
return strings.ToLower(strings.TrimSpace(strings.Split(sniffed, ";")[0]))
}
// documentSniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决
// documentSniffAllowed 报告 (扩展名, 嗅探结果) 这一对是否被放行。它是「类型裁决
// 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
// 裁决本体只存在于 `documents.SniffAllowed`(写侧与读侧同一份)。
func documentSniffAllowed(sniffed string) bool {
return documents.SniffAllowed(sniffed)
// 裁决本体只存在于 `documents.ContentAllowed`(写侧与读侧同一份)—— 成对判定,
// 因为 `.pdf` 的放行条件是 `application/pdf` 而不是 `text/plain`(切片 B1)。
func documentSniffAllowed(ext, sniffed string) bool {
return documents.ContentAllowed(ext, sniffed)
}
// validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档
@@ -356,7 +360,7 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) {
writeError(w, documentNotFoundError())
return
}
w.Header().Set("Content-Type", documentReadMIME(f))
w.Header().Set("Content-Type", documentReadMIME(f, name))
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
// 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。
@@ -365,8 +369,11 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) {
}
// documentReadMIME 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库:
// 落盘内容就是唯一真源,嗅探不出放行类型时一律 application/octet-stream。
func documentReadMIME(f *os.File) string {
// 落盘内容就是唯一真源;`name` 只用来取扩展名 —— 类型裁决是**成对**的
// (`documents.ContentAllowed`),因此真 PDF 会如实下发 `application/pdf`
// (配合 `attachment` + `nosniff`,浏览器仍不会内联渲染),判不定者与原响应一致
// 地退化为 `application/octet-stream`。
func documentReadMIME(f *os.File, name string) string {
buf := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(f, buf)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
@@ -376,7 +383,7 @@ func documentReadMIME(f *os.File) string {
return documentFallbackMIME
}
sniffed := http.DetectContentType(buf[:n])
if !documentSniffAllowed(sniffed) {
if !documentSniffAllowed(documents.ExtOf(name), sniffed) {
return documentFallbackMIME
}
return documentBaseMIME(sniffed)
@@ -398,10 +405,11 @@ func documentTooLargeError(limit int) error {
}
// documentUnsupportedError 构造类型拒绝错误:415 + unsupported_media_type。
// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里。
// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里;
// PDF 是唯一「扩展名与内容必须互相对应」的放行格式,因此文案里点出它。
func documentUnsupportedError() error {
return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode,
"只接受白名单内的纯文本类文档(如 .txt/.md/.csv/.json),且内容必须确为文本")
"只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 .pdf),且内容必须与扩展名相符")
}
// documentNotFoundError 构造文档缺失错误:404 + document_not_found。名字形态