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,118 @@
|
||||
package voice
|
||||
|
||||
// 本文件承载**回放端点**(`GET /api/v1/media/{name}`)两侧的格式事实
|
||||
// (design/24 §10.11 的端点表;契约 v0.67 已登记形状):**名字形态**
|
||||
// (32 位小写十六进制主名 + 音频白名单扩展名)与**下发 MIME**。
|
||||
//
|
||||
// 两条口径与 design/24 §10.11 逐字一致:
|
||||
// - 下发 `Content-Type` **只看存储名的扩展名**(`.wav → audio/wav` …),**不查库、
|
||||
// 不重新嗅探** —— 「扩展名 ↔ 字节」在**保存那一刻**已成对裁决过(`documents.SaveAudio`);
|
||||
// - 名字形态是 deny-by-default:主名必须是 32 位小写十六进制、扩展名必须在 10 项
|
||||
// 音频白名单内,其余(含路径穿越的一切形态)一律拒绝,由调用方收敛成同一个 404。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MediaPathPrefix 是语音副本对外的**服务端相对路径**前缀(契约 v0.67 §4.4):
|
||||
// DTO 里的 `voice.url` 由服务端**一处**拼出(`MediaPath`),客户端绝不自己拼。
|
||||
MediaPathPrefix = "/api/v1/media/"
|
||||
|
||||
// StoredNameHexLen 是内容寻址落盘名的十六进制主名长度(32 位 = 128 位)。
|
||||
//
|
||||
// 它与 `documents.HashHexLen` 是**同一个数**(同一套内容寻址)。这里刻意独立具名:
|
||||
// `documents` 已经 import 本包(`documents → history → voice` 的方向),本包一旦
|
||||
// import `documents` 就会成环。两处必须同值,由网关侧的交叉断言钉住
|
||||
// (`media_test.go` 的 TestMediaStoredNameHexLenMatchesDocuments)。
|
||||
StoredNameHexLen = 32
|
||||
)
|
||||
|
||||
// mediaMIMEByExt 是音频白名单**唯一**的一份表:扩展名(小写、含点)→ 下发 MIME。
|
||||
// 白名单成员由它派生(`MediaExtAllowed`),因此「能下发」与「下发的类型」不可能分叉。
|
||||
//
|
||||
// 10 项 = design/24 §10.11 的端点表定稿值:`.wav` `.mp3` `.aac` `.ogg` `.oga` `.opus`
|
||||
// `.m4a` `.webm` `.flac` `.amr`。取值与 design/24 §10.11 的映射表逐字对应
|
||||
// (`.ogg`/`.oga`/`.opus` 一律 `audio/ogg` —— 三者是同一容器的不同封装名)。
|
||||
var mediaMIMEByExt = map[string]string{
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".aac": "audio/aac",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".webm": "audio/webm",
|
||||
".flac": "audio/flac",
|
||||
".amr": "audio/amr",
|
||||
}
|
||||
|
||||
// MediaExtAllowed 报告扩展名(含点,大小写不敏感)是否在音频白名单内。
|
||||
// deny-by-default:不认识的扩展名一律 false。
|
||||
func MediaExtAllowed(ext string) bool {
|
||||
return mediaMIMEByExt[normalizeExt(ext)] != ""
|
||||
}
|
||||
|
||||
// MediaMIME 给出该扩展名要下发的 MIME。扩展名不在白名单内时返回 ""(调用方据此
|
||||
// 拒绝,绝不回一个「看起来像音频」的兜底类型)。
|
||||
func MediaMIME(ext string) string {
|
||||
return mediaMIMEByExt[normalizeExt(ext)]
|
||||
}
|
||||
|
||||
// MediaPath 拼出存储名对应的**服务端相对路径**(`/api/v1/media/<name>`)。它是
|
||||
// DTO `voice.url` 的唯一派生点:客户端绝不自己拼路径(契约 v0.67 §4.4)。
|
||||
func MediaPath(name string) string {
|
||||
return MediaPathPrefix + name
|
||||
}
|
||||
|
||||
// ValidMediaName 报告 name 是否恰好是「32 位小写十六进制主名 + 音频白名单扩展名」。
|
||||
// 它与网关文档读侧的 `validDocumentName` 同一手法(双检):分隔符、点、双点及任何
|
||||
// 其他形态都在触碰文件系统之前被拒绝 —— 调用方因此可以把「名字非法」与「文件不存在」
|
||||
// 收敛成同一个 404,不泄露任何「存在与否」以外的信息。
|
||||
func ValidMediaName(name string) bool {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
|
||||
return false
|
||||
}
|
||||
stem, ext, ok := strings.Cut(name, ".")
|
||||
if !ok || len(stem) != StoredNameHexLen || ext == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range stem {
|
||||
if !isLowerHex(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return MediaExtAllowed("." + strings.ToLower(ext))
|
||||
}
|
||||
|
||||
// MediaExtensions 返回音频白名单的扩展名(含点,已排序)。它是**发现**用出口
|
||||
// (面板/日志/测试能列出「当前认定哪些容器可回放」),调用方不得依赖它在
|
||||
// 业务判定上的顺序 —— 判定一律走 MediaExtAllowed。
|
||||
func MediaExtensions() []string {
|
||||
out := make([]string, 0, len(mediaMIMEByExt))
|
||||
for ext := range mediaMIMEByExt {
|
||||
out = append(out, ext)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeExt 把扩展名归一成「小写、带前导点」。归一只有这一处,使
|
||||
// `.OGG` / `OGG` / `ogg` 三种写法落到同一个判定上(读侧与写侧同源)。
|
||||
func normalizeExt(ext string) string {
|
||||
e := strings.ToLower(strings.TrimSpace(ext))
|
||||
if e == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(e, ".") {
|
||||
e = "." + e
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// isLowerHex 报告 c 是否是 `0-9a-f`。主名形态据此拒绝大写十六进制与其他一切字符。
|
||||
func isLowerHex(c rune) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package voice
|
||||
|
||||
// 本文件锁定**回放端点**那一侧的格式事实(design/24 §10.11;契约 v0.67 的
|
||||
// `GET /api/v1/media/{name}` 表):白名单**字面**成员、扩展名归一、名字形态的
|
||||
// deny-by-default,以及唯一派生字段 `url` 的拼法。
|
||||
//
|
||||
// 白名单用**字面量**钉住(而不是从被测表反推):表改成员时本用例必须红 ——
|
||||
// 与「拿被测常量当期望」的教训同一条纪律。
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaExtensionsAreTheDeclaredTen 钉住白名单成员与项数(10 项,逐字)。
|
||||
func TestMediaExtensionsAreTheDeclaredTen(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{
|
||||
".aac", ".amr", ".flac", ".m4a", ".mp3",
|
||||
".oga", ".ogg", ".opus", ".wav", ".webm",
|
||||
}
|
||||
if got := MediaExtensions(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("MediaExtensions() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaExtAllowedNormalizesAndDeniesByDefault 覆盖归一与默认拒。
|
||||
func TestMediaExtAllowedNormalizesAndDeniesByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
allowed := []string{".ogg", "ogg", ".OGG", " .Opus ", ".wav", ".amr", ".webm"}
|
||||
for _, ext := range allowed {
|
||||
if !MediaExtAllowed(ext) {
|
||||
t.Fatalf("MediaExtAllowed(%q) = false, want true", ext)
|
||||
}
|
||||
}
|
||||
denied := []string{"", ".", ".txt", ".mp4", ".pdf", ".ogg2", "ogs", ".OGX"}
|
||||
for _, ext := range denied {
|
||||
if MediaExtAllowed(ext) {
|
||||
t.Fatalf("MediaExtAllowed(%q) = true, want false(deny-by-default)", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaMIME 逐项钉住下发类型(契约 v0.67 §4.1 的映射表)。
|
||||
func TestMediaMIME(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".aac": "audio/aac",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".webm": "audio/webm",
|
||||
".flac": "audio/flac",
|
||||
".amr": "audio/amr",
|
||||
}
|
||||
for ext, want := range cases {
|
||||
if got := MediaMIME(ext); got != want {
|
||||
t.Fatalf("MediaMIME(%q) = %q, want %q", ext, got, want)
|
||||
}
|
||||
if got := MediaMIME("." + ext[1:]); got != want {
|
||||
t.Fatalf("MediaMIME(无点 %q) = %q, want %q", ext, got, want)
|
||||
}
|
||||
}
|
||||
// 未知扩展名绝不回一个「看起来像音频」的兜底类型。
|
||||
if got := MediaMIME(".txt"); got != "" {
|
||||
t.Fatalf("MediaMIME(.txt) = %q, want 空串", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaPath 钉住唯一派生字段的拼法(服务端一处拼出,客户端绝不自己拼)。
|
||||
func TestMediaPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
name := "0123456789abcdef0123456789abcdef.ogg"
|
||||
if got, want := MediaPath(name), "/api/v1/media/"+name; got != want {
|
||||
t.Fatalf("MediaPath = %q, want %q", got, want)
|
||||
}
|
||||
if MediaPathPrefix != "/api/v1/media/" {
|
||||
t.Fatalf("MediaPathPrefix = %q, want /api/v1/media/", MediaPathPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoredNameHexLen 钉住主名宽度(与 documents 的同名常量同值由网关侧交叉断言)。
|
||||
func TestStoredNameHexLen(t *testing.T) {
|
||||
t.Parallel()
|
||||
if StoredNameHexLen != 32 {
|
||||
t.Fatalf("StoredNameHexLen = %d, want 32", StoredNameHexLen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidMediaName 覆盖名字形态的放行与拒绝(含路径穿越的一切常见形态)。
|
||||
func TestValidMediaName(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := []string{
|
||||
"0123456789abcdef0123456789abcdef.ogg",
|
||||
"ffffffffffffffffffffffffffffffff.amr",
|
||||
"0123456789abcdef0123456789abcdef.webm",
|
||||
}
|
||||
for _, name := range valid {
|
||||
if !ValidMediaName(name) {
|
||||
t.Fatalf("ValidMediaName(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"",
|
||||
"0123456789ABCDEF0123456789abcdef.ogg", // 大写十六进制
|
||||
"0123456789abcdef0123456789abcde.ogg", // 主名不足 32 位
|
||||
"0123456789abcdef0123456789abcdefg.ogg", // 主名超 32 位
|
||||
"0123456789abcdef0123456789abcdef", // 没有扩展名
|
||||
"0123456789abcdef0123456789abcdef.xml", // 扩展名不在白名单
|
||||
"0123456789abcdef0123456789abcdef.exe", // 同上
|
||||
"0123456789abcdef0123456789abcdef.o.ogg", // 多一个点
|
||||
"../0123456789abcdef0123456789abcdef.ogg",
|
||||
"..\\0123456789abcdef0123456789abcdef.ogg",
|
||||
"/etc/passwd",
|
||||
"sub/0123456789abcdef0123456789abcdef.ogg",
|
||||
"voice.ogg",
|
||||
}
|
||||
for _, name := range invalid {
|
||||
if ValidMediaName(name) {
|
||||
t.Fatalf("ValidMediaName(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user