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 重定向,正是那道防护存在的理由)。
This commit is contained in:
AskaEth committed 2026-10-04 17:07:20 +08:00
1 parent 52f08dc343
commit 407c934a05
7 files changed
+1061

No files matched your search

+10
View File
@@ -86,6 +86,13 @@ func (s *Server) registerUserRoutes(
// bearer token。二者都走同一个网关端口(单二进制红线)。
mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload)))
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
// 文档存储(design/24 §3 / §6.1,切片 S1):与图片上传同一个数据目录根
// (<DataDir>/uploads/documents),但**写读两侧都要 owner/admin** —— 文档读侧
// 不需要被 <img>/provider 直接消费(摄取在服务端完成),因此不走图片那套
// 「随机名即凭据」的免鉴权读法,也不会放开内联服务 HTML/SVG 的口子。
mux.Handle("POST /api/v1/documents", writeAuth(http.HandlerFunc(s.handleDocumentUpload)))
mux.Handle("GET /api/v1/documents/{name}", writeAuth(http.HandlerFunc(s.handleDocumentFile)))
}
// registerHistoryRoutes 挂载持久化聊天历史表面(design/16)
@@ -266,6 +273,9 @@ func (s *Server) apiKeyTable() auth.KeyTable {
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
var h http.Handler = mux
h = guardUploadPaths(h)
// 文档路径的同类防护(design/24 §3.2「路径穿越」):`..` 形态必须在 ServeMux
// 清理路径并重定向之前被拦下,否则穿越请求会变成一个非文档路径的重定向。
h = guardDocumentPaths(h)
if s.frontend != nil {
h = s.spa(h)
// 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的