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/`)。它是 // 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') }