Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions realtime/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ config는 cmd, internal/* 모두에서 import 가능
> 모든 `/realtime/stream/*` (SSE) 및 `/realtime/sessions/{id}` (WS) 는 인증 필요 — `?access_token=<stream-token>` 쿼리로 Core 발급 토큰 검증 (EventSource/WS 헤더 한계 우회). `internal/auth` 미들웨어가 처리.
> WS는 SSE(`/realtime/stream/*`)와 **다른 path**라 Upgrade 헤더 분기가 필요 없다. WS 핸들러(`coder/websocket`)는 session 채널 fan-out을 그대로 구독(서버→클라)하고, 수신한 답변(`{type:"answer",content,idempotencyKey?}`)을 `internal/core` 클라이언트로 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)에 프록시한다.
> RT3(`/realtime/sessions/{id}/audio`)는 `WSAudioHandler`(`transport/ws_audio.go`)가 브라우저 WS를 AI WS(`REALTIME_AI_WS_URL` + `?sessionId=&messageId=`, `X-Internal-API-Key` 헤더)로 **양방향 프록시**한다. `messageId`는 사전에 Core `POST /api/sessions/{id}/messages/voice/stream-begin`이 만든 placeholder. 오디오 내용은 해석하지 않고 프레임 타입을 보존하며 복사만(`copyWS`).
> 브라우저의 `?contentType=` 을 AI 로 전달한다 — AI 가 이 값으로 STT 세션의 디코더를 고르므로, 넘기지 않으면 무엇을 보내든 `audio/webm` 으로 가정되어 Safari(mp4) 같은 경우 틀어진다. 사용자 제어 값이라 화이트리스트(webm/ogg/mp4/mpeg/wav)를 통과한 base MIME 만 붙이고, 그 외에는 기존 기본값으로 떨어진다.

---

Expand Down
32 changes: 31 additions & 1 deletion realtime/internal/transport/ws_audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/Team-StackUp/stackup/realtime/internal/auth"
Expand Down Expand Up @@ -48,7 +50,10 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) {
defer client.CloseNow()

ctx := r.Context()
aiURL := fmt.Sprintf("%s?sessionId=%d&messageId=%d", h.AIBaseURL, sid, mid)
// 브라우저가 실제로 만드는 코덱을 AI 에 알려야 STT 세션이 맞는 디코더로 열린다.
// 지금까지는 넘기지 않아 AI 가 항상 audio/webm 으로 가정했다 — Safari(mp4)에서는 틀린다.
contentType := resolveAudioContentType(r.URL.Query().Get("contentType"))
aiURL := buildAIStreamURL(h.AIBaseURL, sid, mid, contentType)
upstream, _, err := websocket.Dial(ctx, aiURL, &websocket.DialOptions{
HTTPHeader: http.Header{"X-Internal-API-Key": {h.InternalKey}},
})
Expand All @@ -70,6 +75,31 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) {
slog.Info("ws_audio.proxy.end", "session_id", sid, "message_id", mid)
}

// AI 가 다룰 수 있고 Core 가 저장을 허용하는 오디오 타입만 통과시킨다.
// 값은 사용자(브라우저)가 정하므로 그대로 업스트림 URL 에 붙이지 않는다.
var allowedAudioContentTypes = map[string]struct{}{
"audio/webm": {},
"audio/ogg": {},
"audio/mp4": {},
"audio/mpeg": {},
"audio/wav": {},
}

// resolveAudioContentType 은 "audio/webm;codecs=opus" 같은 값에서 base MIME 만 뽑고,
// 허용 목록에 없으면 기존 동작과 같은 기본값(audio/webm)으로 떨어진다.
func resolveAudioContentType(raw string) string {
base := strings.ToLower(strings.TrimSpace(strings.SplitN(raw, ";", 2)[0]))
if _, ok := allowedAudioContentTypes[base]; ok {
return base
}
return "audio/webm"
}

func buildAIStreamURL(base string, sessionID, messageID int64, contentType string) string {
return fmt.Sprintf("%s?sessionId=%d&messageId=%d&contentType=%s",
base, sessionID, messageID, url.QueryEscape(contentType))
}

// copyWS 는 src 에서 받은 프레임을 dst 로 그대로 전달한다 (타입 보존).
func copyWS(ctx context.Context, src, dst *websocket.Conn) error {
for {
Expand Down
57 changes: 57 additions & 0 deletions realtime/internal/transport/ws_audio_content_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package transport

import "testing"

// AI 는 contentType 으로 STT 세션의 디코더를 고른다. RealTime 이 넘기지 않으면
// 무엇을 보내든 audio/webm 으로 가정되어 webm 이 아닌 브라우저(Safari=mp4)에서 틀어진다.
func TestResolveAudioContentType(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"코덱 파라미터를 떼고 base MIME 만", "audio/webm;codecs=opus", "audio/webm"},
{"대문자도 정규화", "AUDIO/MP4", "audio/mp4"},
{"앞뒤 공백 허용", " audio/ogg ", "audio/ogg"},
{"허용 목록의 다른 타입", "audio/mpeg", "audio/mpeg"},
{"빈 값이면 기존 동작과 같은 기본값", "", "audio/webm"},
{"허용 목록 밖은 기본값", "audio/flac", "audio/webm"},
// 사용자 제어 값이므로 임의 문자열이 업스트림 URL 로 새어 나가면 안 된다.
{"주입 시도도 기본값", "audio/webm&apiKey=leak", "audio/webm"},
{"완전히 다른 값", "text/html", "audio/webm"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := resolveAudioContentType(c.in); got != c.want {
t.Fatalf("resolveAudioContentType(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}

func TestBuildAIStreamURL(t *testing.T) {
got := buildAIStreamURL("ws://ai:8000/internal/voice/stream", 7, 42, "audio/mp4")
want := "ws://ai:8000/internal/voice/stream?sessionId=7&messageId=42&contentType=audio%2Fmp4"
if got != want {
t.Fatalf("buildAIStreamURL = %q, want %q", got, want)
}
}

// 슬래시가 이스케이프되지 않으면 AI 쪽 쿼리 파싱이 어긋난다.
func TestBuildAIStreamURLEscapesContentType(t *testing.T) {
got := buildAIStreamURL("ws://ai:8000/s", 1, 2, "audio/webm")
if want := "contentType=audio%2Fwebm"; !contains(got, want) {
t.Fatalf("expected %q in %q", want, got)
}
}

func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
Loading