Files
Cyrene_v2/internal/services/gateway/documents.go
T
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

434 lines
20 KiB
Go
Raw 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
// 文档存储端点(design/24 §3 / §6.1,切片 S1):`POST /api/v1/documents` 上传、
// `GET /api/v1/documents/{name}` 读取。它与图片链路(`POST /api/v1/upload` +
// `GET /api/v1/files/{name}`)刻意分离,因为读侧的风险类别不同:
//
// - **类型裁决只有一条**:字节嗅探(`http.DetectContentType`)是唯一裁决者,
// 客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决;扩展名
// 白名单 deny-by-default,二者必须同时通过;
// - **落盘名内容寻址**:`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重,
// 客户端原始文件名永不参与路径;
// - **读侧永不可渲染**:`Content-Disposition: attachment` + `nosniff`,且要求
// Bearer `owner|admin`(文档不需要被 `<img>`/provider 直接消费,摄取在服务端
// 完成,因此不必像图片那样把「名字即凭据」的免鉴权读侧打开)。
//
// 元数据表 `documents` 只写不回收(引用计数与 GC 属切片 S7)。
import (
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
const (
// maxDocumentBytes 是 `POST /api/v1/documents` 的**单文件字节**默认上限:
// 它就是共享存储核心的 `documents.DefaultMaxBytes`(20 MiB)—— 全仓只定义
// 一次,网关与 IM 入站附件因此不可能各写一个数(design/24 §3.3)。
// 它与图片上限(`maxUploadBytes` / `CHAT_IMAGE_MAX_BYTES`)**刻意解耦**:
// 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
// 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
maxDocumentBytes = documents.DefaultMaxBytes
// documentMultipartSlack 是 multipart 信封(边界、部件头)在文件上限之上
// 预留的字节余量;文件本身的校验按确切上限做。
documentMultipartSlack = 1 << 20
// documentFormMemoryBytes 是 multipart 解析时驻留内存的阈值,超过的部件
// 由标准库落到临时文件,因此 20 MiB 文档不会把整个请求体压进堆。
documentFormMemoryBytes = 1 << 20
// documentHashHexLen 是内容寻址落盘名使用的 sha256 十六进制前缀长度
// (32 位十六进制 = 128 位),与 `validUploadName` 的形态校验同宽。
// 它取自共享存储核心,与落盘/读侧的名字形态同源(切片 M1)。
documentHashHexLen = documents.HashHexLen
// documentFormField 是上传 multipart 里的文件字段名(design/24 §6.1)。
documentFormField = "file"
// documentFilePrefix 是文档读取的版本化路径前缀。它位于 `/api/v1/` 之下,
// 因此归网关 mux(而非 SPA 回退),并属于版本化客户端契约。
documentFilePrefix = "/api/v1/documents/"
// documentSubDir 是文档落盘子目录名:`<dataDir>/uploads/documents`。
// 放在图片同根之下,便于 S4 的 `file` 工具沙箱(roots = dataDir)覆盖。
documentSubDir = "documents"
// documentUploadAction 是写侧审计动作名。
documentUploadAction = "document.upload"
// documentsLogSource 是文档子系统的日志来源标签。
documentsLogSource = "gateway/documents"
// documentSniffOKMIME 是**纯文本类**放行的嗅探结果基类型。白名单里的文本类
// 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。
// PDF(切片 B1)是唯一的例外:它有 `application/pdf` 这个自己的签名,放行
// 条件必须与 `.pdf` 扩展名**成对**判定(`documents.ContentAllowed`)。
documentSniffOKMIME = documents.SniffOKMIME
// documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合
// attachment + nosniff,它保证浏览器永远不会按类型渲染文档。
documentFallbackMIME = "application/octet-stream"
)
// 文档端点的机器可读错误码(design/24 §6.2)。前三个与契约表逐字对应。
const (
documentUnsupportedCode = "unsupported_media_type"
documentTooLargeCode = "document_too_large"
documentNotFoundCode = "document_not_found"
documentUploadFailedCode = "document_upload_failed"
)
// documentResponse 是上传成功的载荷。URL 是**相对**同源路径(design/24 §6.1):
// 读侧要求 Bearer,因此该 URL 不是「贴进 `<img>` 就能取」的能力 URL,绝对化
// 没有意义;客户端真正要用的是 `chat.documents` 里的 `name`。
type documentResponse struct {
Name string `json:"name"`
URL string `json:"url"`
MIME string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Deduped bool `json:"deduped"`
}
// documentLimitBytes 解析本次生效的单文件字节上限:配置给了正数就用它,
// 否则(未配置的零值、0、负数)回落到默认 20 MiB —— 语义是「≤0 = 用默认」
// 而不是「禁用上限」,与图片侧 `chatImageLimitBytes` 的口径一致。
// 与图片不同的是这里**不做钳位**:文档没有解码层硬顶,上限只由本项目的
// 解析与上下文预算决定(design/24 §3.3)。
func documentLimitBytes(configured int) int {
if configured <= 0 {
return maxDocumentBytes
}
return configured
}
// documentExtOf 从客户端原始文件名取出小写扩展名(含点)。它只作参考:
// 结果必须同时通过扩展名白名单与字节嗅探,客户端因此无法靠改扩展名换类型。
// 取值逻辑与共享存储核心同源(切片 M1),读侧不再各写一套。
func documentExtOf(filename string) string {
return documents.ExtOf(filename)
}
// documentExtAllowed 报告扩展名是否在放行集合内。deny-by-default:集合之外的
// 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/
// `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便
// 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
// 白名单本体只存在于 `documents.ExtAllowed`(23 项 = design/24 §9.2-2 的 22 项
// 文本类 + `.pdf`;切片 B1 纳入 PDF)。
func documentExtAllowed(ext string) bool {
return documents.ExtAllowed(ext)
}
// documentBaseMIME 把嗅探结果归一为不含参数的基类型(`text/plain; charset=utf-8`
// → `text/plain`),使响应与元数据里的 mime 稳定可比。解析失败时退化为手工切分,
// 绝不因此放行任何东西(放行与否由 documentSniffAllowed 决定)。
func documentBaseMIME(sniffed string) string {
if base, _, err := mime.ParseMediaType(sniffed); err == nil && base != "" {
return base
}
return strings.ToLower(strings.TrimSpace(strings.Split(sniffed, ";")[0]))
}
// documentSniffAllowed 报告 (扩展名, 嗅探结果) 这一对是否被放行。它是「类型裁决
// 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
// 裁决本体只存在于 `documents.ContentAllowed`(写侧与读侧同一份)—— 成对判定,
// 因为 `.pdf` 的放行条件是 `application/pdf` 而不是 `text/plain`(切片 B1)。
func documentSniffAllowed(ext, sniffed string) bool {
return documents.ContentAllowed(ext, sniffed)
}
// validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档
// 扩展名。它是读侧静态路由的路径穿越防护(与 `validUploadName` 同形的双检之一):
// 分隔符、点、双点及任何其他形态都在触碰文件系统之前被拒绝。
func validDocumentName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
return false
}
stem, ext, ok := strings.Cut(name, ".")
if !ok || len(stem) != documentHashHexLen || ext == "" {
return false
}
for _, c := range stem {
if !isLowerHex(c) {
return false
}
}
return documentExtAllowed("." + strings.ToLower(ext))
}
// documentsDir 返回 `<dataDir>/uploads/documents`。它与图片共用 `uploadDir`
// 的数据目录解析(注入值优先、cfg.DataDir 回退),因此两者不会指向不同根。
func (s *Server) documentsDir() (string, error) {
uploads, err := s.uploadDir()
if err != nil {
return "", err
}
return filepath.Join(uploads, documentSubDir), nil
}
// documentsLog 返回标注了 source=gateway/documents 的子日志器。文档日志只记
// name/size/mime/sha256 摘要,**绝不记正文**。
func (s *Server) documentsLog() *slog.Logger {
return logger.WithSource(s.log, documentsLogSource)
}
// failDocumentUpload 审计一次失败的文档写操作并回写错误响应:两件事总是成对
// 发生,抽出来是为了让每个失败分支都是一行、不遗漏审计。
func (s *Server) failDocumentUpload(w http.ResponseWriter, r *http.Request, err error) {
s.auditClaims(r, documentUploadAction, "error")
writeError(w, err)
}
// readDocumentUpload 给上传请求套上请求体上限并解析出 `file` 部件。请求体上限
// 含 multipart 余量,因此「信封合法但文件超限」能落到文件级判定上给出同一
// 错误码。ok=false 表示响应已经写好,调用方直接返回。
func (s *Server) readDocumentUpload(w http.ResponseWriter, r *http.Request, limit int) (multipart.File, *multipart.FileHeader, bool) {
r.Body = http.MaxBytesReader(w, r.Body, int64(limit)+documentMultipartSlack)
if err := r.ParseMultipartForm(documentFormMemoryBytes); err != nil {
s.failDocumentUpload(w, r, documentBodyError(limit, err))
return nil, nil, false
}
file, header, err := r.FormFile(documentFormField)
if err != nil {
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "需要 multipart 字段 file"))
return nil, nil, false
}
return file, header, true
}
// documentStore 按本次生效的目录与上限构造一个文档存储(切片 M1 抽出的共享
// 核心,见 `internal/runtime/documents`):网关只是它的一个调用方 —— IM 入站
// 附件走的是**同一个** Store,两条来源因此不可能漂移。上限来自
// `CHAT_DOCUMENT_MAX_BYTES`(经 documentLimitBytes 归一),并且**每个请求**
// 按当时的配置取值,面板改配置后下一个请求即生效。
//
// 元数据写入面由 `MetadataWriterFor` 给出:未接库(`s.dbPath` 为空)时它返回
// 真正的 nil 接口,Store 因此按「未注入」跳过写表并留痕(S1 的行为)。
func (s *Server) documentStore(dir string, limit int, uploader string) *documents.Store {
return documents.NewStore(documents.StoreConfig{
UploadsDir: dir,
MaxBytes: limit,
Uploader: uploader,
Meta: documents.MetadataWriterFor(s.dbPath, s.log),
Log: s.log,
})
}
// readDocumentBytes 读出本次上传的文件字节,最多 `limit+1` 字节:多读一个字节
// 就足以判定超限,因此既不把超限内容放大进内存,也不把它留在盘上。S1 的流式
// 落盘在抽成共享存储后改为「先读进内存再交给 store」——上限本身(默认 20 MiB)
// 没有放宽,而文档抽取路径本来就整份读进内存(design/24 §9.6 已登记的同一代价)。
func readDocumentBytes(file io.Reader, limit int) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(file, int64(limit)+1))
if err != nil {
return nil, fmt.Errorf("documents: 读上传内容: %w", err)
}
return data, nil
}
// handleDocumentUpload 接受带单个 `file` 部件的 multipart/form-data 请求,
// 用字节嗅探裁决类型,按内容寻址存到 `<dataDir>/uploads/documents`,写一行
// 元数据并审计 `document.upload`。首次上传回 201 + `deduped:false`;命中同一
// 内容时回 200 + `deduped:true` 且 `name` 与首次逐字一致。
//
// 类型/体积的裁决**全部**由 `documents.Store.Save` 给出(切片 M1 起与 IM 入站
// 附件同源);这里只把它的哨兵错误映射为 S1 既有的契约错误码,判定顺序
// (扩展名 → 空 → 上限 → 嗅探)由 store 保持,因此线上答案与抽共享核心之前
// 逐字一致。
func (s *Server) handleDocumentUpload(w http.ResponseWriter, r *http.Request) {
limit := documentLimitBytes(s.cfg.Server.ChatDocumentMaxBytes)
file, header, ok := s.readDocumentUpload(w, r, limit)
if !ok {
return
}
defer func() { _ = file.Close() }()
// 扩展名先于读体判定:明显不该收的扩展名不必先把请求体读进内存(S1 的
// 顺位也是先看扩展名)。判定用的是**共享存储核心的同一份白名单**,
// 与 store 内部的裁决不可能分叉;store 仍会再判一次(防御性)。
if !documents.ExtAllowed(documents.ExtOf(header.Filename)) {
s.failDocumentUpload(w, r, documentUnsupportedError())
return
}
data, err := readDocumentBytes(file, limit)
if err != nil {
s.documentsLog().Error("document read", "err", err)
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "读取上传内容失败"))
return
}
dir, err := s.documentsDir()
if err != nil {
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "文档存储目录未配置"))
return
}
ref, err := s.documentStore(dir, limit, documentUploader(r)).Save(r.Context(), header.Filename, data)
if err != nil {
s.failDocumentSave(w, r, limit, ref, err)
return
}
s.documentsLog().Info("document stored",
"name", ref.Name, "size", ref.Size, "mime", ref.MIME,
"sha256", ref.SHA256, "deduped", ref.Deduped)
s.auditClaims(r, documentUploadAction, "ok")
status := http.StatusCreated
if ref.Deduped {
status = http.StatusOK
}
writeJSON(w, status, documentResponse{
Name: ref.Name,
URL: documentFilePrefix + ref.Name,
MIME: ref.MIME,
Size: ref.Size,
SHA256: ref.SHA256,
Deduped: ref.Deduped,
})
}
// failDocumentSave 把 store 的哨兵错误映射为 S1 既有的线上错误并审计。映射表
// 逐条对应(415 类型拒绝 / 413 超限 / 400 空文件 / 500 存储或元数据失败),
// 与抽共享核心之前的对外行为一字不差:拒绝是**可预期的输入问题**,因此不额外
// 记错误日志(与 S1 相同);存储与元数据失败才留日志。
func (s *Server) failDocumentSave(w http.ResponseWriter, r *http.Request, limit int, ref documents.Ref, err error) {
switch {
case errors.Is(err, documents.ErrTooLarge):
s.failDocumentUpload(w, r, documentTooLargeError(limit))
case errors.Is(err, documents.ErrExtNotAllowed), errors.Is(err, documents.ErrContentNotText):
s.failDocumentUpload(w, r, documentUnsupportedError())
case errors.Is(err, documents.ErrEmpty):
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "文件为空"))
case errors.Is(err, documents.ErrMetadata):
s.documentsLog().Error("document metadata", "err", err, "name", ref.Name)
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "记录文档元数据失败"))
default:
s.documentsLog().Error("document store", "err", err)
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "保存文档失败"))
}
}
// documentUploader 从请求上下文取上传者 id(由 auth.Authorize 填充),取不到时
// 记 anonymous —— 与 `auditClaims` 的身份口径一致,绝不凭请求头自报身份。
func documentUploader(r *http.Request) string {
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Sub != "" {
return claims.Sub
}
return "anonymous"
}
// handleDocumentFile 下发一份已存文档。它要求 Bearer owner|admin(门在路由表),
// 并且**永不可渲染**:attachment + nosniff,Content-Type 由读取时的字节嗅探决定,
// 嗅探不出放行类型时退化为 application/octet-stream。名字形态非法、路径逃出
// 文档目录、文件不存在一律同样的 404 document_not_found。
func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validDocumentName(name) {
writeError(w, documentNotFoundError())
return
}
dir, err := s.documentsDir()
if err != nil {
writeError(w, documentNotFoundError())
return
}
path := filepath.Join(dir, name)
if filepath.Dir(path) != filepath.Clean(dir) {
writeError(w, documentNotFoundError())
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, documentNotFoundError())
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeError(w, documentNotFoundError())
return
}
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。
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
http.ServeContent(w, r, name, info.ModTime(), f)
}
// documentReadMIME 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库:
// 落盘内容就是唯一真源;`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) {
return documentFallbackMIME
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return documentFallbackMIME
}
sniffed := http.DetectContentType(buf[:n])
if !documentSniffAllowed(documents.ExtOf(name), sniffed) {
return documentFallbackMIME
}
return documentBaseMIME(sniffed)
}
// documentBodyError 把 multipart 解析失败映射为其线上错误:超请求体上限是
// 413 document_too_large,其余(非 multipart、部件畸形)是 400 bad_request。
func documentBodyError(limit int, err error) error {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return documentTooLargeError(limit)
}
return apperr.Wrap(apperr.ErrBadRequest, "bad_request", "multipart 请求体非法")
}
// documentTooLargeError 构造超限错误:413 + document_too_large,并带上生效上限。
func documentTooLargeError(limit int) error {
return apperr.Wrap(apperr.ErrPayloadTooLarge, documentTooLargeCode, "文档超过 %d 字节上限", limit)
}
// documentUnsupportedError 构造类型拒绝错误:415 + unsupported_media_type。
// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里;
// PDF 是唯一「扩展名与内容必须互相对应」的放行格式,因此文案里点出它。
func documentUnsupportedError() error {
return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode,
"只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 .pdf),且内容必须与扩展名相符")
}
// documentNotFoundError 构造文档缺失错误:404 + document_not_found。名字形态
// 非法与路径穿越都在此统一收敛,不泄露「是否存在」以外的任何信息。
func documentNotFoundError() error {
return apperr.Wrap(apperr.ErrNotFound, documentNotFoundCode, "文档不存在")
}
// guardDocumentPaths 在 ServeMux 把含 `..` 段的文档路径清理并 301 重定向到一个
// 非文档路径(随后回落 SPA / 裸 404)之前把它拦下,统一回 404
// document_not_found。`{name}` 路由只匹配单个路径段,因此路径里的 `..` 只可能是
// 逃逸尝试;与 `guardUploadPaths` 同一套理由,只是错误码不同。
func guardDocumentPaths(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, documentFilePrefix) && hasDotDotSegment(r.URL.Path) {
writeError(w, documentNotFoundError())
return
}
next.ServeHTTP(w, r)
})
}