diff --git a/.env.example b/.env.example
index 4a9567a..ff2646c 100644
--- a/.env.example
+++ b/.env.example
@@ -31,6 +31,13 @@ ADMIN_PATH=admin
# ≤0(写 0 或负数)= 用默认值 5 MiB,不是「禁用上限」——一张图能有多大只能有一个答案。
CHAT_IMAGE_MAX_BYTES=5242880
+# POST /api/v1/documents 的单文件字节上限,默认 20971520(20 MiB)。
+# 它与上面的图片上限【刻意解耦】:图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
+# 合用一个数会让「调文档上限」静默改掉图片行为。
+# 文档读侧(GET /api/v1/documents/{name})要 Bearer owner/admin,且一律 attachment + nosniff(永不可内联渲染)。
+# ≤0(写 0 或负数)= 用默认值 20 MiB,不是「禁用上限」。
+CHAT_DOCUMENT_MAX_BYTES=20971520
+
# ---- 认证 / 管理员 ----
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
JWT_SECRET=change-me-to-a-random-string-at-least-32-characters
diff --git a/internal/config/config.go b/internal/config/config.go
index 9e211f0..0d20467 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -74,6 +74,13 @@ type ServerConfig struct {
// 「用默认」**,不做「禁用上限」的解释——一张图能有多大只能有一个答案。
// 网关只在入口层用换算出的字符上限拦截,绝不把图片内容写进日志。
ChatImageMaxBytes int `env:"CHAT_IMAGE_MAX_BYTES" default:"5242880"`
+
+ // ChatDocumentMaxBytes 是 POST /api/v1/documents 允许的**单文件字节**上限,
+ // 默认 20971520(20 MiB)。它与图片上限(CHAT_IMAGE_MAX_BYTES)刻意解耦:
+ // 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定,
+ // 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
+ // **≤0(含未配置的零值)视为「用默认」**,不是「禁用上限」。
+ ChatDocumentMaxBytes int `env:"CHAT_DOCUMENT_MAX_BYTES" default:"20971520"`
}
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
diff --git a/internal/db/migrations/0014_documents.down.sql b/internal/db/migrations/0014_documents.down.sql
new file mode 100644
index 0000000..dea35ae
--- /dev/null
+++ b/internal/db/migrations/0014_documents.down.sql
@@ -0,0 +1,11 @@
+-- 0014_documents down: 回滚文档元数据表。
+--
+-- 回滚后的状态是「文档只落在文件系统、没有记账」(切片 S1 落地前的旧形态,
+-- 也等同于 S1 之前根本不存在文档端点)。丢弃是安全的——除本表外没有任何
+-- 存储读写 documents 表,也没有引用它的外键;已落盘的文档文件不因此删除
+-- (它们只占盘,不被任何元数据引用)。
+DROP INDEX IF EXISTS idx_documents_sha256;
+
+DROP INDEX IF EXISTS idx_documents_name;
+
+DROP TABLE IF EXISTS documents;
diff --git a/internal/db/migrations/0014_documents.up.sql b/internal/db/migrations/0014_documents.up.sql
new file mode 100644
index 0000000..4896688
--- /dev/null
+++ b/internal/db/migrations/0014_documents.up.sql
@@ -0,0 +1,35 @@
+-- 0014_documents: 文档存储元数据(design/24 §3.2 / §3.3 / §6.1,切片 S1)。
+--
+-- 一行 = 一份内容寻址落盘的文档。落盘名 name = sha256(bytes)[:32] + ext,
+-- 因此「同字节同扩展名」必然同名 —— 去重是内容寻址的自然结果,本表只负责
+-- 记账(谁传的、多大、什么类型、什么时候),不参与去重判定。
+--
+-- 为什么必须有这张表:文件系统只回答「文件在不在」,回答不了「这份文档是谁
+-- 传的、最后一次被消息引用是什么时候」。切片 S7 的引用计数与 GC(未被任何
+-- 消息引用且超过保留期者删除)据此工作,因此本表从一开始就留足列;S1 只写。
+--
+-- 口径:
+-- - name 唯一索引承载 ON CONFLICT(name) DO NOTHING(重复上传不刷新首次记录);
+-- - mime 是**服务端字节嗅探所得**的基类型,不是客户端声明的 Content-Type;
+-- - size 是落盘字节数;uploader 是 JWT sub,取不到时为 'anonymous';
+-- - last_referenced_at / ref_count 由 S7 维护,此处只建列(S1 不填)。
+-- 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。
+CREATE TABLE IF NOT EXISTS documents (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sha256 TEXT NOT NULL,
+ mime TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ uploader TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ last_referenced_at TEXT,
+ ref_count INTEGER NOT NULL DEFAULT 0
+);
+
+-- 唯一索引同时承担 upsert 的冲突目标(ON CONFLICT(name) 需要唯一约束/索引)。
+CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_name
+ ON documents (name);
+
+-- 按内容哈希查找(同字节不同扩展名时也想知道「这份内容还存着哪些名字」)。
+CREATE INDEX IF NOT EXISTS idx_documents_sha256
+ ON documents (sha256);
diff --git a/internal/services/gateway/documents.go b/internal/services/gateway/documents.go
new file mode 100644
index 0000000..073c36b
--- /dev/null
+++ b/internal/services/gateway/documents.go
@@ -0,0 +1,494 @@
+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 (
+ "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 是文档落盘子目录名:`/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 不是「贴进 `
` 就能取」的能力 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 返回 `/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 请求,
+// 用字节嗅探裁决类型,按内容寻址存到 `/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,再把临时文件重命名到 ``(部分写入因此绝不会以可读的
+// 名字露面)。目标名已存在(同字节同扩展名)时丢弃临时文件并报告 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)
+ })
+}
diff --git a/internal/services/gateway/documents_test.go b/internal/services/gateway/documents_test.go
new file mode 100644
index 0000000..12ace0b
--- /dev/null
+++ b/internal/services/gateway/documents_test.go
@@ -0,0 +1,497 @@
+package gateway
+
+// 本文件锁定切片 S1(design/24 §3 / §6.1)的存储端点口径:字节嗅探是唯一类型裁决、
+// 扩展名白名单 deny-by-default、内容寻址去重、独立限额、读侧 Bearer + 不可渲染、
+// 路径穿越统一 404、元数据落表。图片链路的既有行为不在断言范围内。
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/db"
+)
+
+// documentsEnv 打包文档端点测试所需的依赖:真实 JWT 服务、临时数据目录,以及
+// 一个**已跑完全部迁移的临时 SQLite 库**(绝不触碰 data/cyrene.db)。
+type documentsEnv struct {
+ srv *Server
+ dataDir string
+ dbPath string
+ auth *auth.JWTService
+}
+
+func newDocumentsEnv(t *testing.T, limit int) *documentsEnv {
+ t.Helper()
+ dataDir := t.TempDir()
+ dbPath := filepath.Join(dataDir, "test.db")
+ conn, err := db.Open(context.Background(), dbPath, nil)
+ if err != nil {
+ t.Fatalf("打开临时库失败: %v", err)
+ }
+ if err := db.MigrateUp(context.Background(), conn, nil); err != nil {
+ _ = conn.Close()
+ t.Fatalf("临时库跑迁移失败: %v", err)
+ }
+ if err := conn.Close(); err != nil {
+ t.Fatalf("关闭临时库失败: %v", err)
+ }
+ jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
+ cfg := &config.Config{
+ Server: config.ServerConfig{ChatDocumentMaxBytes: limit},
+ Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret},
+ }
+ srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, DatabasePath: dbPath})
+ if err != nil {
+ t.Fatalf("构造网关失败: %v", err)
+ }
+ t.Cleanup(srv.Close)
+ return &documentsEnv{srv: srv, dataDir: dataDir, dbPath: dbPath, auth: jwt}
+}
+
+func (e *documentsEnv) token(t *testing.T, role auth.Role) string {
+ t.Helper()
+ tok, err := e.auth.Sign("acct", role, time.Hour)
+ if err != nil {
+ t.Fatalf("签发 token 失败: %v", err)
+ }
+ return tok
+}
+
+func (e *documentsEnv) documentsDir() string {
+ return filepath.Join(e.dataDir, "uploads", documentSubDir)
+}
+
+// documentEnvelope 是两个信封的合并视图:成功 `{data:{…}}`、失败
+// `{error:{code,message}}`。
+type documentEnvelope struct {
+ Data 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"`
+ } `json:"data"`
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func decodeDocumentEnvelope(t *testing.T, body []byte) documentEnvelope {
+ t.Helper()
+ var env documentEnvelope
+ if err := json.Unmarshal(body, &env); err != nil {
+ t.Fatalf("解析响应失败: %v (body=%s)", err, body)
+ }
+ return env
+}
+
+// postDocument 组装一次 multipart 上传并直接喂给网关 handler。partContentType
+// 是**客户端谎报**的部件类型 —— 它必须无法影响任何裁决(真值是字节本身)。
+func postDocument(t *testing.T, env *documentsEnv, filename string, content []byte, partContentType, token string) (*http.Response, []byte) {
+ t.Helper()
+ body := &bytes.Buffer{}
+ mw := multipart.NewWriter(body)
+ header := textproto.MIMEHeader{}
+ header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, documentFormField, filename))
+ header.Set("Content-Type", partContentType)
+ part, err := mw.CreatePart(header)
+ if err != nil {
+ t.Fatalf("创建 multipart 部件失败: %v", err)
+ }
+ if _, err := part.Write(content); err != nil {
+ t.Fatalf("写入 multipart 部件失败: %v", err)
+ }
+ if err := mw.Close(); err != nil {
+ t.Fatalf("关闭 multipart 失败: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", body)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ env.srv.Handler().ServeHTTP(rec, req)
+ return rec.Result(), rec.Body.Bytes()
+}
+
+// getDocument 直接以原始 target 调网关 handler(不经过真实网络与客户端重定向
+// 策略),因此 `..` 形态到底返回 404 还是 301 能被精确看见。
+func getDocument(t *testing.T, env *documentsEnv, target, token string) (*http.Response, []byte) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ env.srv.Handler().ServeHTTP(rec, req)
+ return rec.Result(), rec.Body.Bytes()
+}
+
+// docHashNameOf 复算一份内容在给定文件名下的期望落盘名(内容寻址口径的独立实现)。
+func docHashNameOf(t *testing.T, filename string, content []byte) (string, string) {
+ t.Helper()
+ sum := sha256.Sum256(content)
+ hexSum := hex.EncodeToString(sum[:])
+ return hexSum[:documentHashHexLen] + documentExtOf(filename), hexSum
+}
+
+// TestDocumentUploadAcceptsTextFormats 覆盖验收第 1 条:txt/md/csv/json 上传得
+// 201,响应含 name/url/mime/size/sha256/deduped(false),且盘上确实落了一份同名文件。
+func TestDocumentUploadAcceptsTextFormats(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ tok := env.token(t, auth.RoleOwner)
+
+ cases := []struct {
+ filename string
+ content []byte
+ }{
+ {"notes.txt", []byte("昔涟的测试文本\n")},
+ {"readme.md", []byte("# 标题\n\n正文\n")},
+ {"table.csv", []byte("name,age\n昔涟,1\n")},
+ {"data.json", []byte(`{"who":"cyrene"}`)},
+ }
+ for _, tc := range cases {
+ resp, body := postDocument(t, env, tc.filename, tc.content, "text/plain", tok)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("%s: status = %d, want 201 (body=%s)", tc.filename, resp.StatusCode, body)
+ }
+ got := decodeDocumentEnvelope(t, body)
+ wantName, wantSHA := docHashNameOf(t, tc.filename, tc.content)
+ if got.Data.Name != wantName {
+ t.Fatalf("%s: name = %q, want 内容寻址名 %q", tc.filename, got.Data.Name, wantName)
+ }
+ if got.Data.URL != documentFilePrefix+wantName || got.Data.SHA256 != wantSHA {
+ t.Fatalf("%s: url/sha256 = (%q,%q), want (%q,%q)",
+ tc.filename, got.Data.URL, got.Data.SHA256, documentFilePrefix+wantName, wantSHA)
+ }
+ if got.Data.Size != int64(len(tc.content)) || got.Data.MIME != documentSniffOKMIME || got.Data.Deduped {
+ t.Fatalf("%s: size/mime/deduped = (%d,%q,%v), want (%d,%q,false)",
+ tc.filename, got.Data.Size, got.Data.MIME, got.Data.Deduped, len(tc.content), documentSniffOKMIME)
+ }
+ stored, err := os.ReadFile(filepath.Join(env.documentsDir(), wantName))
+ if err != nil {
+ t.Fatalf("%s: 盘上找不到 %s: %v", tc.filename, wantName, err)
+ }
+ if !bytes.Equal(stored, tc.content) {
+ t.Fatalf("%s: 落盘内容与上传内容不一致(len %d vs %d)", tc.filename, len(stored), len(tc.content))
+ }
+ }
+}
+
+// TestDocumentUploadDeduplicatesIdenticalBytes 覆盖验收第 2 条:同字节重复上传回
+// 200 + 同一个 name + deduped(true),盘上只有一份;且名字只由内容与扩展名决定,
+// 客户端换个文件名也命中同一份。
+func TestDocumentUploadDeduplicatesIdenticalBytes(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ tok := env.token(t, auth.RoleOwner)
+ content := []byte("同一份内容的两次上传\n")
+
+ resp, body := postDocument(t, env, "first.txt", content, "text/plain", tok)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("首次上传 status = %d, want 201 (body=%s)", resp.StatusCode, body)
+ }
+ first := decodeDocumentEnvelope(t, body)
+ if first.Data.Deduped {
+ t.Fatalf("首次上传 deduped = true, want false")
+ }
+
+ resp, body = postDocument(t, env, "first.txt", content, "text/plain", tok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("重复上传 status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ second := decodeDocumentEnvelope(t, body)
+ if !second.Data.Deduped {
+ t.Fatalf("重复上传 deduped = false, want true")
+ }
+ if second.Data.Name != first.Data.Name {
+ t.Fatalf("重复上传 name = %q, want 与首次一致 %q", second.Data.Name, first.Data.Name)
+ }
+
+ // 换一个客户端文件名(扩展名不变)仍是同一份内容寻址名。
+ resp, body = postDocument(t, env, "renamed.txt", content, "text/plain", tok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("改名重传 status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ if third := decodeDocumentEnvelope(t, body); third.Data.Name != first.Data.Name {
+ t.Fatalf("改名重传 name = %q, want %q(名字只由内容与扩展名决定)", third.Data.Name, first.Data.Name)
+ }
+
+ entries, err := os.ReadDir(env.documentsDir())
+ if err != nil {
+ t.Fatalf("读取文档目录失败: %v", err)
+ }
+ if len(entries) != 1 {
+ names := make([]string, 0, len(entries))
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Fatalf("盘上文件数 = %d (%v), want 1(同字节同扩展名只留一份)", len(entries), names)
+ }
+}
+
+// TestDocumentUploadSniffVetoesDeclaredType 覆盖验收第 3 条:客户端把部件
+// Content-Type 谎报成 text/plain、文件名也用白名单扩展名,内容却是真 PNG/HTML/zip
+// 时,字节嗅探必须否决声明并回 415。
+func TestDocumentUploadSniffVetoesDeclaredType(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ tok := env.token(t, auth.RoleOwner)
+
+ png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 64)...)
+ html := []byte("hi")
+ zip := append([]byte("PK\x03\x04"), bytes.Repeat([]byte{0x00}, 64)...)
+ cases := []struct {
+ label string
+ content []byte
+ }{
+ {"PNG", png},
+ {"HTML", html},
+ {"zip", zip},
+ }
+ for _, tc := range cases {
+ resp, body := postDocument(t, env, "lie.txt", tc.content, "text/plain", tok)
+ if resp.StatusCode != http.StatusUnsupportedMediaType {
+ t.Fatalf("%s 谎报 text/plain: status = %d, want 415 (body=%s)", tc.label, resp.StatusCode, body)
+ }
+ if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentUnsupportedCode {
+ t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentUnsupportedCode)
+ }
+ }
+ if _, err := os.Stat(env.documentsDir()); !os.IsNotExist(err) {
+ t.Fatalf("被嗅探否决的上传仍创建了文档目录(err=%v)", err)
+ }
+}
+
+// TestDocumentUploadRejectsDisallowedTypes 覆盖验收第 4 条:HTML/SVG/XML/zip/
+// docx/exe,以及嗅探成纯文本但扩展名不在白名单的 .js/.py/.sh,一律 415。
+func TestDocumentUploadRejectsDisallowedTypes(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ tok := env.token(t, auth.RoleOwner)
+
+ zipLike := append([]byte("PK\x03\x04"), bytes.Repeat([]byte{0x00}, 64)...)
+ pngLike := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 64)...)
+ cases := []struct {
+ label string
+ filename string
+ content []byte
+ }{
+ {"html", "evil.html", []byte("x")},
+ {"svg", "logo.svg", []byte(``)},
+ {"xml", "data.xml", []byte(``)},
+ {"zip", "pack.zip", zipLike},
+ {"docx", "report.docx", zipLike},
+ {"exe", "tool.exe", pngLike},
+ {"js", "app.js", []byte("const a = 1;\n")},
+ {"py", "tool.py", []byte("print('hi')\n")},
+ {"sh", "run.sh", []byte("#!/bin/sh\necho hi\n")},
+ }
+ for _, tc := range cases {
+ resp, body := postDocument(t, env, tc.filename, tc.content, "text/plain", tok)
+ if resp.StatusCode != http.StatusUnsupportedMediaType {
+ t.Fatalf("%s(%s): status = %d, want 415 (body=%s)", tc.label, tc.filename, resp.StatusCode, body)
+ }
+ if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentUnsupportedCode {
+ t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentUnsupportedCode)
+ }
+ }
+}
+
+// TestDocumentUploadRejectsOversize 覆盖验收第 5 条:文件超过生效上限(含恰好
+// 超一个字节的边界)回 413 document_too_large;恰好等于上限必须通过。请求体本身
+// 超上限(multipart 信封一起撑爆)走同一条错误码。
+func TestDocumentUploadRejectsOversize(t *testing.T) {
+ t.Parallel()
+ const limit = 1024
+ env := newDocumentsEnv(t, limit)
+ tok := env.token(t, auth.RoleOwner)
+
+ resp, body := postDocument(t, env, "big.txt", bytes.Repeat([]byte("a"), limit+1), "text/plain", tok)
+ if resp.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("超限一个字节: status = %d, want 413 (body=%s)", resp.StatusCode, body)
+ }
+ if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentTooLargeCode {
+ t.Fatalf("超限一个字节: error.code = %q, want %q", code, documentTooLargeCode)
+ }
+
+ huge := bytes.Repeat([]byte("a"), 2*limit+2*documentMultipartSlack)
+ resp, body = postDocument(t, env, "huge.txt", huge, "text/plain", tok)
+ if resp.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("请求体超限: status = %d, want 413 (body=%s)", resp.StatusCode, body)
+ }
+ if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentTooLargeCode {
+ t.Fatalf("请求体超限: error.code = %q, want %q", code, documentTooLargeCode)
+ }
+
+ resp, body = postDocument(t, env, "exact.txt", bytes.Repeat([]byte("b"), limit), "text/plain", tok)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("恰好等于上限: status = %d, want 201 (body=%s)", resp.StatusCode, body)
+ }
+}
+
+// TestDocumentReadRequiresOwnerOrAdmin 覆盖验收第 6 条:无 Bearer / 无效 Bearer /
+// viewer 角色读 → 401/403;owner 与 admin 读 → 200 且带 attachment + nosniff,
+// 字节与上传内容逐字一致。
+func TestDocumentReadRequiresOwnerOrAdmin(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ content := []byte("读侧鉴权与不可渲染断言\n")
+ resp, body := postDocument(t, env, "auth.txt", content, "text/plain", env.token(t, auth.RoleOwner))
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("准备上传失败: status = %d (body=%s)", resp.StatusCode, body)
+ }
+ name := decodeDocumentEnvelope(t, body).Data.Name
+ target := documentFilePrefix + name
+
+ cases := []struct {
+ label string
+ token string
+ want int
+ }{
+ {"无 Bearer", "", http.StatusUnauthorized},
+ {"无效 Bearer", "not-a-jwt", http.StatusUnauthorized},
+ {"viewer 角色", env.token(t, auth.RoleViewer), http.StatusForbidden},
+ }
+ for _, tc := range cases {
+ resp, body := getDocument(t, env, target, tc.token)
+ if resp.StatusCode != tc.want {
+ t.Fatalf("%s: status = %d, want %d (body=%s)", tc.label, resp.StatusCode, tc.want, body)
+ }
+ }
+
+ for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
+ resp, body := getDocument(t, env, target, env.token(t, role))
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("%s 读: status = %d, want 200 (body=%s)", role, resp.StatusCode, body)
+ }
+ disposition := resp.Header.Get("Content-Disposition")
+ if !strings.HasPrefix(disposition, "attachment") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
+ t.Fatalf("%s 读: 响应头 = (%q,%q), want attachment 前缀 + nosniff(永不可渲染)",
+ role, disposition, resp.Header.Get("X-Content-Type-Options"))
+ }
+ if !bytes.Equal(body, content) {
+ t.Fatalf("%s 读: 响应字节与上传内容不一致(len %d vs %d)", role, len(body), len(content))
+ }
+ }
+}
+
+// TestDocumentReadRejectsTraversalAndMalformedNames 覆盖验收第 7 条:字面 `..`、
+// 转义后的 `..`/斜杠、非 32 位十六进制主名、非白名单扩展名一律 404
+// document_not_found(且**不是** ServeMux 的 301 重定向)。
+func TestDocumentReadRejectsTraversalAndMalformedNames(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ tok := env.token(t, auth.RoleOwner)
+ validName, _ := docHashNameOf(t, "x.txt", []byte("x"))
+ stem := strings.TrimSuffix(validName, ".txt")
+
+ cases := []struct {
+ label string
+ target string
+ }{
+ {"字面父目录段", "/api/v1/documents/../../etc/passwd"},
+ {"转义的父目录段", "/api/v1/documents/%2e%2e%2f%2e%2e%2fetc%2fpasswd"},
+ {"转义斜杠", "/api/v1/documents/..%2f"},
+ {"缺扩展名", documentFilePrefix + stem},
+ {"非白名单扩展名", documentFilePrefix + stem + ".exe"},
+ {"大写十六进制", documentFilePrefix + strings.ToUpper(stem) + ".txt"},
+ {"主名长度不对", documentFilePrefix + "0123456789abcdef.txt"},
+ {"名字不存在", documentFilePrefix + strings.Repeat("ab", documentHashHexLen) + ".txt"},
+ }
+ for _, tc := range cases {
+ resp, body := getDocument(t, env, tc.target, tok)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("%s(%s): status = %d, want 404 (body=%s)", tc.label, tc.target, resp.StatusCode, body)
+ }
+ if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentNotFoundCode {
+ t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentNotFoundCode)
+ }
+ }
+}
+
+// TestDocumentUploadWritesMetadataRow 覆盖验收第 8 条:上传成功后 documents 表里
+// 确实有一行(name/sha256/mime/size/uploader),且 S7 要用的引用计数列存在。
+func TestDocumentUploadWritesMetadataRow(t *testing.T) {
+ t.Parallel()
+ env := newDocumentsEnv(t, 0)
+ content := []byte("元数据行断言\n")
+ resp, body := postDocument(t, env, "meta.txt", content, "text/plain", env.token(t, auth.RoleOwner))
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("准备上传失败: status = %d (body=%s)", resp.StatusCode, body)
+ }
+ got := decodeDocumentEnvelope(t, body)
+
+ conn, err := db.Open(context.Background(), env.dbPath, nil)
+ if err != nil {
+ t.Fatalf("打开临时库失败: %v", err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ var name, sha, mimeType, uploader string
+ var size, refCount int64
+ var referenced *string
+ row := conn.QueryRowContext(context.Background(),
+ `SELECT name, sha256, mime, size, uploader, ref_count, last_referenced_at FROM documents WHERE name = ?`,
+ got.Data.Name)
+ if err := row.Scan(&name, &sha, &mimeType, &size, &uploader, &refCount, &referenced); err != nil {
+ t.Fatalf("documents 表里没有对应元数据行: %v", err)
+ }
+ if name != got.Data.Name || sha != got.Data.SHA256 || mimeType != got.Data.MIME {
+ t.Fatalf("元数据行 = (%s,%s,%s), want (%s,%s,%s)", name, sha, mimeType, got.Data.Name, got.Data.SHA256, got.Data.MIME)
+ }
+ if size != int64(len(content)) || uploader != "acct" {
+ t.Fatalf("元数据 size/uploader = (%d,%q), want (%d,%q)", size, uploader, len(content), "acct")
+ }
+ if refCount != 0 || referenced != nil {
+ t.Fatalf("引用计数列 = (%d,%v), want (0,nil)(S1 只写元数据,引用计数属 S7)", refCount, referenced)
+ }
+}
+
+// TestDocumentLimitConfigContract 钉住限额口径:默认值与 env 标签逐字节一致、
+// ≤0 回落到默认、**不做钳位**(文档上限与图片上限刻意解耦,不得互相牵制)。
+func TestDocumentLimitConfigContract(t *testing.T) {
+ t.Parallel()
+ field, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName("ChatDocumentMaxBytes")
+ if !ok {
+ t.Fatal("config.ServerConfig 缺 ChatDocumentMaxBytes 字段(配置契约已断)")
+ }
+ if got := field.Tag.Get("env"); got != "CHAT_DOCUMENT_MAX_BYTES" {
+ t.Fatalf("env 标签 = %q, want CHAT_DOCUMENT_MAX_BYTES", got)
+ }
+ if got := field.Tag.Get("default"); got != strconv.Itoa(maxDocumentBytes) {
+ t.Fatalf("默认值 = %q, want %d", got, maxDocumentBytes)
+ }
+ if maxDocumentBytes == maxUploadBytes {
+ t.Fatalf("文档上限 = 图片上限 = %d:两者必须解耦(design/24 §3.3)", maxDocumentBytes)
+ }
+ for _, configured := range []int{0, -1, -1 << 20} {
+ if got := documentLimitBytes(configured); got != maxDocumentBytes {
+ t.Fatalf("documentLimitBytes(%d) = %d, want 默认 %d", configured, got, maxDocumentBytes)
+ }
+ }
+ wider := maxDocumentBytes + 1
+ if got := documentLimitBytes(wider); got != wider {
+ t.Fatalf("documentLimitBytes(%d) = %d, want 原值(文档上限无解码层硬顶,不做钳位)", wider, got)
+ }
+}
diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go
index 3345c92..8bfbbc2 100644
--- a/internal/services/gateway/routes.go
+++ b/internal/services/gateway/routes.go
@@ -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):与图片上传同一个数据目录根
+ // (/uploads/documents),但**写读两侧都要 owner/admin** —— 文档读侧
+ // 不需要被
/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 的