Files
Cyrene_v2/internal/runtime/voice/media.go
T
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

119 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
}