(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 个变异自检(含一条自认的等价变异)。
109 lines
4.9 KiB
Go
109 lines
4.9 KiB
Go
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)
|
||
})
|
||
}
|