feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
(200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
+ documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动
缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
This commit is contained in:
1 parent
4a937bd269
commit
1420e8aaa6
15 files changed
+2202
-5
No files matched your search
@@ -0,0 +1,108 @@
|
||||
package gateway
|
||||
|
||||
// 本文件承载**语音副本的回放端点**:`GET /api/v1/media/{name}`(design/24 §10.11;
|
||||
// 契约 v0.67 已登记形状,本片落地)。
|
||||
//
|
||||
// 它与文档读侧(`GET /api/v1/documents/{name}`)刻意分道,三处不同:
|
||||
//
|
||||
// - **下发类型恒为 `audio/*`**,按**存储名的扩展名**映射(`voice.MediaMIME`),
|
||||
// **不查库、不重新嗅探** —— 「扩展名 ↔ 字节」在保存那一刻已经成对裁决
|
||||
// (`documents.SaveAudio`:音频字节的嗅探结果恒为 `application/octet-stream`,
|
||||
// 嗅探对音频没有意义);
|
||||
// - **内联而非附件**(`inline; filename=…`):这份字节就是要被播放器消费的。
|
||||
// 因下发类型恒在 `audio/*` + `nosniff`,内联不构成文档侧那类 XSS 面(文档读侧
|
||||
// 之所以永不内联,是因为它可能装着 HTML/SVG 这类可渲染内容);
|
||||
// - **鉴权与文档同档**(Bearer `owner|admin`,门在路由表):语音是私密内容,**不**
|
||||
// 走图片那套「随机名即凭据」的免鉴权读法 —— 客户端因此必须带 token 取字节
|
||||
// (Web:`fetch` → `blob:` → 播放),音频 URL **不能**直接贴进 `<audio src>`。
|
||||
//
|
||||
// `Range` / 条件请求 / `HEAD` 一律交给标准库的 `http.ServeContent`:`206`/`304`/`416`
|
||||
// 的语义因此是 Go 的既有语义,而不是本项目自研的一套。⚠️ 其中 **`416` 不是 JSON
|
||||
// 信封**(标准库直写 `text/plain` + `Content-Range: bytes */<size>`),契约已登记。
|
||||
//
|
||||
// 名字形态非法 / 路径穿越 / 文件不存在**三者同一个 404 `media_not_found`**:不泄露
|
||||
// 「存在与否」以外的任何信息。404 是**可预期的输入问题**,因此不记错误日志。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// mediaNotFoundCode 是语音副本缺失的线上错误码(契约 v0.67 §4.1)。
|
||||
const mediaNotFoundCode = "media_not_found"
|
||||
|
||||
// handleMediaFile 下发一份语音副本。门在路由表(owner|admin,与文档读侧同一道门)。
|
||||
//
|
||||
// 它**不读数据库**:落盘的文件就是唯一真源,存储名既决定路径也决定下发类型。
|
||||
// 因此一个「库里有引用、盘上没文件」的状态在这里如实表现为 404,而不是一个
|
||||
// 看起来成功却播放不了的响应。
|
||||
func (s *Server) handleMediaFile(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !voiceref.ValidMediaName(name) {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
dir, err := s.documentsDir()
|
||||
if err != nil {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
// 名字已由 ValidMediaName 双检过;这里是第二道门(与文档读侧同一手法)。
|
||||
if filepath.Dir(path) != filepath.Clean(dir) {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mediaReadMIME(name))
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+name+"\"")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
|
||||
// 条件请求与 Range 交给标准库:Last-Modified 来自文件 mtime(**不设 ETag**,
|
||||
// 契约明写「不自造语义」)。它同时处理 HEAD(GET 模式匹配 HEAD)。
|
||||
http.ServeContent(w, r, name, info.ModTime(), f)
|
||||
}
|
||||
|
||||
// mediaReadMIME 按存储名的扩展名给出下发类型。白名单以外的扩展名不可能走到这里
|
||||
// (名字形态已判),因此兜底分支只防「表与判定漂移」,绝不回一个「看起来像音频」的
|
||||
// 类型:未知就退化为 `application/octet-stream`。
|
||||
func mediaReadMIME(name string) string {
|
||||
if mime := voiceref.MediaMIME(documentExtOf(name)); mime != "" {
|
||||
return mime
|
||||
}
|
||||
return documentFallbackMIME
|
||||
}
|
||||
|
||||
// mediaNotFoundError 构造语音副本缺失错误:404 + `media_not_found`。名字形态非法与
|
||||
// 路径穿越都在此统一收敛。
|
||||
func mediaNotFoundError() error {
|
||||
return apperr.Wrap(apperr.ErrNotFound, mediaNotFoundCode, "语音副本不存在")
|
||||
}
|
||||
|
||||
// guardMediaPaths 在 ServeMux 把含 `..` 段的媒体路径清理并 301 重定向之前把它拦下,
|
||||
// 统一回 404 `media_not_found`。与 guardUploadPaths / guardDocumentPaths 同一套理由。
|
||||
func guardMediaPaths(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, voiceref.MediaPathPrefix) && hasDotDotSegment(r.URL.Path) {
|
||||
writeError(w, mediaNotFoundError())
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user