- 依赖: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 与新的边界。
434 lines
20 KiB
Go
434 lines
20 KiB
Go
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)
|
||
})
|
||
}
|