Files
Cyrene_v2/internal/services/gateway/documents.go
T
AskaEth 9b903a89bc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
2026-10-05 11:36:18 +08:00

437 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 (
"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 是文档落盘子目录名:`<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)
})
}