Files
Cyrene_v2/internal/services/gateway/documents.go
T
AskaEth 407c934a05 feat(gateway): 文档存储端点 POST/GET /api/v1/documents(design/24 S1)
- 字节嗅探唯一裁决 + 扩展名白名单 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 重定向,正是那道防护存在的理由)。
2026-10-04 17:07:20 +08:00

495 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 (
"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)
})
}