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`(文档不需要被 ``/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" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript" ) 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 回退),并属于版本化客户端契约。字面量只有一处: // 转录还原(internal/runtime/transcript)用的必须是同一个前缀,否则续接的 // 回合会把模型指到一个取不到的地址。 documentFilePrefix = transcript.DocumentPathPrefix // documentSubDir 是文档落盘子目录名:`/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 不是「贴进 `` 就能取」的能力 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 返回 `/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 请求, // 用字节嗅探裁决类型,按内容寻址存到 `/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) }) }