Files
Cyrene_v2/internal/services/gateway/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

109 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 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)
})
}