- 字节嗅探唯一裁决 + 扩展名白名单 deny-by-default(23 个文本/代码扩展名);
客户端 Content-Type 与文件名只作参考、可被否决。
- 内容寻址落盘名 sha256[:32]+ext ⇒ 同字节同扩展名天然去重;首次 201/deduped:false,
命中 200/同一 name/deduped:true。
- 独立限额 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,与图片 5 MiB 刻意解耦)+ MaxBytesReader。
- 读侧 Bearer owner|admin + Content-Disposition: attachment + nosniff(与图片读侧刻意不同);
写侧审计 document.upload;错误码 unsupported_media_type/document_too_large/document_not_found。
- 元数据表 documents(0014,含 idx_documents_name 唯一、idx_documents_sha256);
guardDocumentPaths 防路径穿越(否则 ServeMux 会 307 泄漏路径)。
- 既有 /api/v1/upload 与 /api/v1/files/{name} 零改动(upload.go 零 diff)。
验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、9 条新单测 PASS。
变异独立复现 3 条:白名单加 ".js" ⇒ 415 用例红;嗅探裁决一律放行 ⇒ 谎报类型用例红;
摘掉 guardDocumentPaths ⇒ 穿越用例红(307 重定向,正是那道防护存在的理由)。
495 lines
20 KiB
Go
495 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 (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
const (
|
||
// maxDocumentBytes 是 `POST /api/v1/documents` 的**单文件字节**默认上限:20 MiB。
|
||
// 它与图片上限(`maxUploadBytes` / `CHAT_IMAGE_MAX_BYTES`)**刻意解耦**:
|
||
// 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
|
||
// 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
|
||
maxDocumentBytes = 20 << 20
|
||
|
||
// documentMultipartSlack 是 multipart 信封(边界、部件头)在文件上限之上
|
||
// 预留的字节余量;文件本身的校验按确切上限做。
|
||
documentMultipartSlack = 1 << 20
|
||
|
||
// documentFormMemoryBytes 是 multipart 解析时驻留内存的阈值,超过的部件
|
||
// 由标准库落到临时文件,因此 20 MiB 文档不会把整个请求体压进堆。
|
||
documentFormMemoryBytes = 1 << 20
|
||
|
||
// documentHashHexLen 是内容寻址落盘名使用的 sha256 十六进制前缀长度
|
||
// (32 位十六进制 = 128 位),与 `validUploadName` 的形态校验同宽。
|
||
documentHashHexLen = 32
|
||
|
||
// 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」即「拒绝」。
|
||
documentSniffOKMIME = "text/plain"
|
||
|
||
// 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"
|
||
)
|
||
|
||
// ErrDocumentTooLarge 报告文档体量超过生效上限。`storeDocument` 用它把
|
||
// 「超限(413)」与「存储失败(500)」分开,调用方用 `errors.Is` 判定。
|
||
var ErrDocumentTooLarge = errors.New("gateway: document too large")
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// documentRecord 是写入 `documents` 表的一行元数据(S1 只写)。
|
||
type documentRecord struct {
|
||
Name string
|
||
SHA256 string
|
||
MIME string
|
||
Size int64
|
||
Uploader string
|
||
}
|
||
|
||
// documentStored 是一次成功落盘的结果。
|
||
type documentStored struct {
|
||
name string
|
||
sha256 string
|
||
size int64
|
||
deduped bool
|
||
}
|
||
|
||
// documentLimitBytes 解析本次生效的单文件字节上限:配置给了正数就用它,
|
||
// 否则(未配置的零值、0、负数)回落到默认 20 MiB —— 语义是「≤0 = 用默认」
|
||
// 而不是「禁用上限」,与图片侧 `chatImageLimitBytes` 的口径一致。
|
||
// 与图片不同的是这里**不做钳位**:文档没有解码层硬顶,上限只由本项目的
|
||
// 解析与上下文预算决定(design/24 §3.3)。
|
||
func documentLimitBytes(configured int) int {
|
||
if configured <= 0 {
|
||
return maxDocumentBytes
|
||
}
|
||
return configured
|
||
}
|
||
|
||
// documentExtOf 从客户端原始文件名取出小写扩展名(含点)。它只作参考:
|
||
// 结果必须同时通过扩展名白名单与字节嗅探,客户端因此无法靠改扩展名换类型。
|
||
func documentExtOf(filename string) string {
|
||
return strings.ToLower(filepath.Ext(filename))
|
||
}
|
||
|
||
// documentExtAllowed 报告扩展名是否在放行集合内。deny-by-default:集合之外的
|
||
// 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/
|
||
// `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便
|
||
// 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
|
||
func documentExtAllowed(ext string) bool {
|
||
switch ext {
|
||
case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
|
||
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
|
||
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 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` 从不参与决定。
|
||
func documentSniffAllowed(sniffed string) bool {
|
||
return documentBaseMIME(sniffed) == documentSniffOKMIME
|
||
}
|
||
|
||
// documentHashName 返回内容寻址的落盘名:sha256 十六进制的前 32 位 + ext。
|
||
// 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。
|
||
func documentHashName(sum []byte, ext string) string {
|
||
return hex.EncodeToString(sum)[:documentHashHexLen] + ext
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// handleDocumentUpload 接受带单个 `file` 部件的 multipart/form-data 请求,
|
||
// 用字节嗅探裁决类型,按内容寻址存到 `<dataDir>/uploads/documents`,写一行
|
||
// 元数据并审计 `document.upload`。首次上传回 201 + `deduped:false`;命中同一
|
||
// 内容时回 200 + `deduped:true` 且 `name` 与首次逐字一致。
|
||
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() }()
|
||
|
||
ext := documentExtOf(header.Filename)
|
||
if !documentExtAllowed(ext) {
|
||
s.failDocumentUpload(w, r, documentUnsupportedError())
|
||
return
|
||
}
|
||
if header.Size <= 0 {
|
||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "文件为空"))
|
||
return
|
||
}
|
||
if header.Size > int64(limit) {
|
||
s.failDocumentUpload(w, r, documentTooLargeError(limit))
|
||
return
|
||
}
|
||
sniffed, err := sniffImageMIME(file)
|
||
if err != nil {
|
||
s.documentsLog().Error("document sniff", "err", err)
|
||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "读取上传内容失败"))
|
||
return
|
||
}
|
||
if !documentSniffAllowed(sniffed) {
|
||
s.failDocumentUpload(w, r, documentUnsupportedError())
|
||
return
|
||
}
|
||
dir, err := s.documentsDir()
|
||
if err != nil {
|
||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "文档存储目录未配置"))
|
||
return
|
||
}
|
||
stored, err := s.storeDocument(dir, ext, file, int64(limit))
|
||
if errors.Is(err, ErrDocumentTooLarge) {
|
||
s.failDocumentUpload(w, r, documentTooLargeError(limit))
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.documentsLog().Error("document store", "err", err)
|
||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "保存文档失败"))
|
||
return
|
||
}
|
||
rec := documentRecord{
|
||
Name: stored.name,
|
||
SHA256: stored.sha256,
|
||
MIME: documentBaseMIME(sniffed),
|
||
Size: stored.size,
|
||
Uploader: documentUploader(r),
|
||
}
|
||
if err := s.insertDocumentMetadata(r.Context(), rec); err != nil {
|
||
s.documentsLog().Error("document metadata", "err", err, "name", rec.Name)
|
||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "记录文档元数据失败"))
|
||
return
|
||
}
|
||
s.documentsLog().Info("document stored",
|
||
"name", rec.Name, "size", rec.Size, "mime", rec.MIME,
|
||
"sha256", rec.SHA256, "deduped", stored.deduped)
|
||
s.auditClaims(r, documentUploadAction, "ok")
|
||
status := http.StatusCreated
|
||
if stored.deduped {
|
||
status = http.StatusOK
|
||
}
|
||
writeJSON(w, status, documentResponse{
|
||
Name: rec.Name,
|
||
URL: documentFilePrefix + rec.Name,
|
||
MIME: rec.MIME,
|
||
Size: rec.Size,
|
||
SHA256: rec.SHA256,
|
||
Deduped: stored.deduped,
|
||
})
|
||
}
|
||
|
||
// storeDocument 把 f 按内容寻址写入 dir:先流式写点前缀临时文件、同时计算
|
||
// sha256,再把临时文件重命名到 `<sha256[:32]><ext>`(部分写入因此绝不会以可读的
|
||
// 名字露面)。目标名已存在(同字节同扩展名)时丢弃临时文件并报告 deduped,
|
||
// 盘上只留一份。目录 0700、文件继承临时的 0600。
|
||
func (s *Server) storeDocument(dir, ext string, f io.Reader, limit int64) (documentStored, error) {
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return documentStored{}, fmt.Errorf("create document dir: %w", err)
|
||
}
|
||
tmp, err := os.CreateTemp(dir, ".document-*")
|
||
if err != nil {
|
||
return documentStored{}, fmt.Errorf("create document temp: %w", err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
hasher := sha256.New()
|
||
written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(f, limit+1))
|
||
if err != nil {
|
||
_ = tmp.Close()
|
||
_ = os.Remove(tmpName)
|
||
return documentStored{}, fmt.Errorf("write document: %w", err)
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
_ = os.Remove(tmpName)
|
||
return documentStored{}, fmt.Errorf("close document: %w", err)
|
||
}
|
||
// 文件级上限的第二次把关(第一道是 multipart 部件大小):多读一个字节即可
|
||
// 判定超限,因此既不放大内存也不把超限文件留在盘上。
|
||
if written > limit {
|
||
_ = os.Remove(tmpName)
|
||
return documentStored{}, ErrDocumentTooLarge
|
||
}
|
||
digest := hasher.Sum(nil)
|
||
sum := hex.EncodeToString(digest)
|
||
name := documentHashName(digest, ext)
|
||
if info, serr := os.Stat(filepath.Join(dir, name)); serr == nil && !info.IsDir() {
|
||
_ = os.Remove(tmpName)
|
||
return documentStored{name: name, sha256: sum, size: written, deduped: true}, nil
|
||
}
|
||
if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
|
||
_ = os.Remove(tmpName)
|
||
return documentStored{}, fmt.Errorf("commit document: %w", err)
|
||
}
|
||
return documentStored{name: name, sha256: sum, size: written, deduped: false}, nil
|
||
}
|
||
|
||
// insertDocumentMetadata 把一行文档元数据写进 `documents` 表(迁移 0014)。
|
||
// 同一内容寻址名重复上传时 `DO NOTHING`:首次的 uploader/created_at 是权威,
|
||
// 去重不刷新它们。`s.dbPath` 未注入(未接数据库的构建/测试)时跳过 —— 上传
|
||
// 本身仍然成功,元数据留待 S7 的引用计数与 GC 统一接线。
|
||
func (s *Server) insertDocumentMetadata(ctx context.Context, rec documentRecord) error {
|
||
path := strings.TrimSpace(s.dbPath)
|
||
if path == "" {
|
||
return nil
|
||
}
|
||
conn, err := db.Open(ctx, path, s.log)
|
||
if err != nil {
|
||
return fmt.Errorf("open db for document metadata: %w", err)
|
||
}
|
||
defer func() { _ = conn.Close() }()
|
||
const query = `INSERT INTO documents (name, sha256, mime, size, uploader)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(name) DO NOTHING`
|
||
if _, err := conn.ExecContext(ctx, query, rec.Name, rec.SHA256, rec.MIME, rec.Size, rec.Uploader); err != nil {
|
||
return fmt.Errorf("insert document metadata: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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))
|
||
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 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库:
|
||
// 落盘内容就是唯一真源,嗅探不出放行类型时一律 application/octet-stream。
|
||
func documentReadMIME(f *os.File) 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(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。
|
||
// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里。
|
||
func documentUnsupportedError() error {
|
||
return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode,
|
||
"只接受白名单内的纯文本类文档(如 .txt/.md/.csv/.json),且内容必须确为文本")
|
||
}
|
||
|
||
// 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)
|
||
})
|
||
}
|