From 407c934a05a2f106d6cf7ac2ead722fb9b64402d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 17:07:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=96=87=E6=A1=A3=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E7=AB=AF=E7=82=B9=20POST/GET=20/api/v1/documents?= =?UTF-8?q?=EF=BC=88design/24=20S1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 字节嗅探唯一裁决 + 扩展名白名单 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 重定向,正是那道防护存在的理由)。 --- .env.example | 7 + internal/config/config.go | 7 + .../db/migrations/0014_documents.down.sql | 11 + internal/db/migrations/0014_documents.up.sql | 35 ++ internal/services/gateway/documents.go | 494 +++++++++++++++++ internal/services/gateway/documents_test.go | 497 ++++++++++++++++++ internal/services/gateway/routes.go | 10 + 7 files changed, 1061 insertions(+) create mode 100644 internal/db/migrations/0014_documents.down.sql create mode 100644 internal/db/migrations/0014_documents.up.sql create mode 100644 internal/services/gateway/documents.go create mode 100644 internal/services/gateway/documents_test.go 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 的