feat(gateway): 文档存储端点 POST/GET /api/v1/documents(design/24 S1)

- 字节嗅探唯一裁决 + 扩展名白名单 deny-by-default(23 个文本/代码扩展名);
  客户端 Content-Type 与文件名只作参考、可被否决。
- 内容寻址落盘名 sha256[:32]+ext ⇒ 同字节同扩展名天然去重;首次 201/deduped:false,
  命中 200/同一 name/deduped:true。
- 独立限额 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,与图片 5 MiB 刻意解耦)+ MaxBytesReader。
- 读侧 Bearer owner|admin + Content-Disposition: attachment + nosniff(与图片读侧刻意不同);
  写侧审计 document.upload;错误码 unsupported_media_type/document_too_large/document_not_found。
- 元数据表 documents(0014,含 idx_documents_name 唯一、idx_documents_sha256);
  guardDocumentPaths 防路径穿越(否则 ServeMux 会 307 泄漏路径)。
- 既有 /api/v1/upload 与 /api/v1/files/{name} 零改动(upload.go 零 diff)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、9 条新单测 PASS。
变异独立复现 3 条:白名单加 ".js" ⇒ 415 用例红;嗅探裁决一律放行 ⇒ 谎报类型用例红;
摘掉 guardDocumentPaths ⇒ 穿越用例红(307 重定向,正是那道防护存在的理由)。
This commit is contained in:
AskaEth committed 2026-10-04 17:07:20 +08:00
1 parent 52f08dc343
commit 407c934a05
7 files changed
+1061

No files matched your search

@@ -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);