(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 个变异自检(含一条自认的等价变异)。
244 lines
9.2 KiB
Go
244 lines
9.2 KiB
Go
package gateway
|
||
|
||
// 本文件锁定**语音副本回放端点** `GET /api/v1/media/{name}`(design/24 §10.11;契约
|
||
// v0.67 登记、本片落地)的六条口径:
|
||
// ① 200 + `audio/*` + 内联 + `nosniff` + `Accept-Ranges`(**不设** ETag);
|
||
// ② `Range` ⇒ 206 + `Content-Range`;`If-Modified-Since` ⇒ 304;`HEAD` ⇒ 200 无正文
|
||
// —— 三者都由标准库的 `ServeContent` 给出;
|
||
// ③ 不可满足的 `Range` ⇒ 416,且**不是** JSON 信封(标准库直写 `text/plain`);
|
||
// ④ 名字形态非法 / 路径穿越 / 文件不存在 ⇒ **同一个** 404 `media_not_found`;
|
||
// ⑤ 门与文档读侧同档(无凭据 401 / viewer 403 / owner|admin 200);
|
||
// ⑥ 主名宽度与 documents 包的内容寻址常量同值(32 位小写十六进制)。
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
)
|
||
|
||
// mediaFixture 是一份已落盘的语音副本 + 一个只带 DataDir 的网关。
|
||
type mediaFixture struct {
|
||
ts *httptest.Server
|
||
jwt *auth.JWTService
|
||
name string
|
||
body []byte
|
||
dir string
|
||
}
|
||
|
||
// newMediaFixture 在 `$DATA_DIR/uploads/documents` 下写一份内容寻址命名的音频副本
|
||
// (与 `documents.SaveAudio` 的落盘口径逐字一致:sha256[:32] + ext、0600)。
|
||
func newMediaFixture(t *testing.T, body []byte, ext string) *mediaFixture {
|
||
t.Helper()
|
||
dataDir := t.TempDir()
|
||
dir := filepath.Join(dataDir, "uploads", documentSubDir)
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
t.Fatalf("建副本目录: %v", err)
|
||
}
|
||
sum := sha256.Sum256(body)
|
||
name := hex.EncodeToString(sum[:])[:documents.HashHexLen] + ext
|
||
if err := os.WriteFile(filepath.Join(dir, name), body, 0o600); err != nil {
|
||
t.Fatalf("写副本: %v", err)
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DataDir: dataDir})
|
||
return &mediaFixture{ts: ts, jwt: jwt, name: name, body: body, dir: dir}
|
||
}
|
||
|
||
// doMediaReq 发一次可带额外请求头的媒体请求(doReq 不支持自定义头)。
|
||
func doMediaReq(t *testing.T, f *mediaFixture, method, path string, headers map[string]string, token string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(method, f.ts.URL+path, nil)
|
||
if err != nil {
|
||
t.Fatalf("new request: %v", err)
|
||
}
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do request: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
buf := new(bytes.Buffer)
|
||
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
||
t.Fatalf("read body: %v", err)
|
||
}
|
||
return resp, buf.Bytes()
|
||
}
|
||
|
||
func TestMediaEndpointServesInlineAudio(t *testing.T) {
|
||
t.Parallel()
|
||
body := []byte("RIFF\x24\x00\x00\x00WAVEfmt voice-bytes")
|
||
f := newMediaFixture(t, body, ".ogg")
|
||
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, got := doMediaReq(t, f, http.MethodGet, voiceref.MediaPathPrefix+f.name, nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, got)
|
||
}
|
||
if ct := resp.Header.Get("Content-Type"); ct != "audio/ogg" {
|
||
t.Fatalf("Content-Type = %q, want audio/ogg(按扩展名裁决,不嗅探)", ct)
|
||
}
|
||
if cd := resp.Header.Get("Content-Disposition"); cd != `inline; filename="`+f.name+`"` {
|
||
t.Fatalf("Content-Disposition = %q, want inline; filename=%q", cd, f.name)
|
||
}
|
||
if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||
t.Fatalf("缺 nosniff: %v", resp.Header)
|
||
}
|
||
if resp.Header.Get("Accept-Ranges") != "bytes" {
|
||
t.Fatalf("Accept-Ranges = %q, want bytes", resp.Header.Get("Accept-Ranges"))
|
||
}
|
||
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "private") || !strings.Contains(cc, "immutable") {
|
||
t.Fatalf("Cache-Control = %q, want private + immutable", cc)
|
||
}
|
||
if resp.Header.Get("ETag") != "" {
|
||
t.Fatalf("不该自造 ETag,得到 %q", resp.Header.Get("ETag"))
|
||
}
|
||
if !bytes.Equal(got, body) {
|
||
t.Fatalf("正文 = %q, want %q", got, body)
|
||
}
|
||
}
|
||
|
||
func TestMediaEndpointRangeConditionalAndHead(t *testing.T) {
|
||
t.Parallel()
|
||
body := []byte("0123456789abcdef")
|
||
f := newMediaFixture(t, body, ".wav")
|
||
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
|
||
path := voiceref.MediaPathPrefix + f.name
|
||
size := strconv.Itoa(len(body))
|
||
|
||
// Range ⇒ 206 部分内容。
|
||
resp, got := doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=0-3"}, tok)
|
||
if resp.StatusCode != http.StatusPartialContent {
|
||
t.Fatalf("Range status = %d, want 206 (body=%s)", resp.StatusCode, got)
|
||
}
|
||
if cr := resp.Header.Get("Content-Range"); cr != "bytes 0-3/"+size {
|
||
t.Fatalf("Content-Range = %q, want bytes 0-3/%s", cr, size)
|
||
}
|
||
if !bytes.Equal(got, body[:4]) {
|
||
t.Fatalf("Range 正文 = %q, want %q", got, body[:4])
|
||
}
|
||
|
||
// If-Modified-Since(不早于 mtime)⇒ 304,无正文。
|
||
lastMod := resp.Header.Get("Last-Modified")
|
||
if lastMod == "" {
|
||
t.Fatal("缺 Last-Modified:条件请求无从判定")
|
||
}
|
||
resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"If-Modified-Since": lastMod}, tok)
|
||
if resp.StatusCode != http.StatusNotModified {
|
||
t.Fatalf("条件请求 status = %d, want 304 (body=%s)", resp.StatusCode, got)
|
||
}
|
||
if len(got) != 0 {
|
||
t.Fatalf("304 不该带正文:%q", got)
|
||
}
|
||
|
||
// HEAD ⇒ 200 + 空正文 + 正确长度(GET 模式同时匹配 HEAD)。
|
||
resp, got = doMediaReq(t, f, http.MethodHead, path, nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("HEAD status = %d, want 200", resp.StatusCode)
|
||
}
|
||
if len(got) != 0 {
|
||
t.Fatalf("HEAD 不该带正文:%q", got)
|
||
}
|
||
if cl := resp.Header.Get("Content-Length"); cl != size {
|
||
t.Fatalf("HEAD Content-Length = %q, want %s", cl, size)
|
||
}
|
||
|
||
// 不可满足的 Range ⇒ 416,且**不是** JSON 信封(契约 v0.67 明写)。
|
||
resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=9999-10000"}, tok)
|
||
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||
t.Fatalf("越界 Range status = %d, want 416 (body=%s)", resp.StatusCode, got)
|
||
}
|
||
if cr := resp.Header.Get("Content-Range"); cr != "bytes */"+size {
|
||
t.Fatalf("416 Content-Range = %q, want bytes */%s", cr, size)
|
||
}
|
||
if len(got) > 0 && got[0] == '{' {
|
||
t.Fatalf("416 不该是 JSON 信封:%q", got)
|
||
}
|
||
}
|
||
|
||
func TestMediaEndpointNotFoundIsUniform(t *testing.T) {
|
||
t.Parallel()
|
||
f := newMediaFixture(t, []byte("audio-bytes"), ".mp3")
|
||
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
|
||
|
||
missing := hex.EncodeToString(bytes.Repeat([]byte{0xab}, 32)) + ".mp3"
|
||
cases := []struct {
|
||
desc string
|
||
path string
|
||
}{
|
||
{"名字形态非法(没有扩展名)", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef"},
|
||
{"扩展名不在音频白名单", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef.txt"},
|
||
{"主名不是小写十六进制", voiceref.MediaPathPrefix + "0123456789ABCDEF0123456789abcdef.mp3"},
|
||
{"路径穿越", "/api/v1/media/../cyrene.db"},
|
||
{"穿越到子目录", "/api/v1/media/%2e%2e/cyrene.db"},
|
||
{"文件不存在", voiceref.MediaPathPrefix + missing},
|
||
}
|
||
for _, tc := range cases {
|
||
resp, body := doMediaReq(t, f, http.MethodGet, tc.path, nil, tok)
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("%s: status = %d, want 404 (body=%s)", tc.desc, resp.StatusCode, body)
|
||
}
|
||
if code := mediaErrorCode(t, body); code != mediaNotFoundCode {
|
||
t.Fatalf("%s: code = %q, want %q (body=%s)", tc.desc, code, mediaNotFoundCode, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMediaEndpointRequiresOwnerOrAdmin(t *testing.T) {
|
||
t.Parallel()
|
||
body := []byte("audio-bytes")
|
||
f := newMediaFixture(t, body, ".flac")
|
||
path := voiceref.MediaPathPrefix + f.name
|
||
|
||
if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("匿名 status = %d, want 401", resp.StatusCode)
|
||
}
|
||
viewer := signAny(t, f.jwt, "acct", auth.RoleViewer)
|
||
if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, viewer); resp.StatusCode != http.StatusForbidden {
|
||
t.Fatalf("viewer status = %d, want 403", resp.StatusCode)
|
||
}
|
||
admin := signAny(t, f.jwt, "acct", auth.RoleAdmin)
|
||
if resp, got := doMediaReq(t, f, http.MethodGet, path, nil, admin); resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, got)
|
||
}
|
||
}
|
||
|
||
// TestMediaStoredNameHexLenMatchesDocuments 钉住「内容寻址主名宽度」两处同值:
|
||
// voice 包不 import documents(会成环),因此这条交叉断言就是它们的连接点。
|
||
func TestMediaStoredNameHexLenMatchesDocuments(t *testing.T) {
|
||
t.Parallel()
|
||
if voiceref.StoredNameHexLen != documents.HashHexLen {
|
||
t.Fatalf("voice.StoredNameHexLen = %d, documents.HashHexLen = %d, want 同值",
|
||
voiceref.StoredNameHexLen, documents.HashHexLen)
|
||
}
|
||
}
|
||
|
||
// mediaErrorCode 从 `{error:{code}}` 信封里取出错误码(解析失败即终止用例)。
|
||
func mediaErrorCode(t *testing.T, body []byte) string {
|
||
t.Helper()
|
||
var env struct {
|
||
Error struct {
|
||
Code string `json:"code"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(body, &env); err != nil {
|
||
t.Fatalf("解析错误信封失败: %v (body=%s)", err, body)
|
||
}
|
||
return env.Error.Code
|
||
}
|