mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 11:26:58 +00:00
574 lines
16 KiB
Go
574 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DeBrosOfficial/network/pkg/gateway/sfu"
|
|
"github.com/DeBrosOfficial/network/pkg/logging"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/pion/webrtc/v4"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// SFUManager is a type alias for the SFU room manager
|
|
type SFUManager = sfu.RoomManager
|
|
|
|
// CreateRoomRequest is the request body for creating a room
|
|
type CreateRoomRequest struct {
|
|
RoomID string `json:"roomId"`
|
|
}
|
|
|
|
// CreateRoomResponse is the response for creating/joining a room
|
|
type CreateRoomResponse struct {
|
|
RoomID string `json:"roomId"`
|
|
Created bool `json:"created"`
|
|
RTPCapabilities map[string]interface{} `json:"rtpCapabilities"`
|
|
}
|
|
|
|
// JoinRoomRequest is the request body for joining a room
|
|
type JoinRoomRequest struct {
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
// JoinRoomResponse is the response for joining a room
|
|
type JoinRoomResponse struct {
|
|
ParticipantID string `json:"participantId"`
|
|
Participants []sfu.ParticipantInfo `json:"participants"`
|
|
}
|
|
|
|
// sfuCreateRoomHandler handles POST /v1/sfu/room
|
|
func (g *Gateway) sfuCreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Check if SFU is enabled
|
|
if g.sfuManager == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "SFU service not enabled")
|
|
return
|
|
}
|
|
|
|
// Get namespace from auth context
|
|
ns := resolveNamespaceFromRequest(r)
|
|
if ns == "" {
|
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
var req CreateRoomRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.RoomID == "" {
|
|
writeError(w, http.StatusBadRequest, "roomId is required")
|
|
return
|
|
}
|
|
|
|
// Create or get room
|
|
room, created := g.sfuManager.GetOrCreateRoom(ns, req.RoomID)
|
|
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU room request",
|
|
zap.String("room_id", req.RoomID),
|
|
zap.String("namespace", ns),
|
|
zap.Bool("created", created),
|
|
zap.Int("participants", room.GetParticipantCount()),
|
|
)
|
|
|
|
writeJSON(w, http.StatusOK, &CreateRoomResponse{
|
|
RoomID: req.RoomID,
|
|
Created: created,
|
|
RTPCapabilities: sfu.GetRTPCapabilities(),
|
|
})
|
|
}
|
|
|
|
// sfuRoomHandler handles all /v1/sfu/room/:roomId/* endpoints
|
|
func (g *Gateway) sfuRoomHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Check if SFU is enabled
|
|
if g.sfuManager == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "SFU service not enabled")
|
|
return
|
|
}
|
|
|
|
// Get namespace from auth context
|
|
ns := resolveNamespaceFromRequest(r)
|
|
if ns == "" {
|
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
|
return
|
|
}
|
|
|
|
// Parse room ID and action from path
|
|
// Path format: /v1/sfu/room/{roomId}/{action}
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/sfu/room/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
writeError(w, http.StatusBadRequest, "room ID required")
|
|
return
|
|
}
|
|
|
|
roomID := parts[0]
|
|
action := ""
|
|
if len(parts) > 1 {
|
|
action = parts[1]
|
|
}
|
|
|
|
// Route to appropriate handler
|
|
switch action {
|
|
case "":
|
|
// GET /v1/sfu/room/:roomId - Get room info
|
|
g.sfuGetRoomHandler(w, r, ns, roomID)
|
|
case "join":
|
|
// POST /v1/sfu/room/:roomId/join - Join room
|
|
g.sfuJoinRoomHandler(w, r, ns, roomID)
|
|
case "leave":
|
|
// POST /v1/sfu/room/:roomId/leave - Leave room
|
|
g.sfuLeaveRoomHandler(w, r, ns, roomID)
|
|
case "participants":
|
|
// GET /v1/sfu/room/:roomId/participants - List participants
|
|
g.sfuParticipantsHandler(w, r, ns, roomID)
|
|
case "ws":
|
|
// GET /v1/sfu/room/:roomId/ws - WebSocket signaling
|
|
g.sfuWebSocketHandler(w, r, ns, roomID)
|
|
default:
|
|
writeError(w, http.StatusNotFound, "unknown action")
|
|
}
|
|
}
|
|
|
|
// sfuGetRoomHandler handles GET /v1/sfu/room/:roomId
|
|
func (g *Gateway) sfuGetRoomHandler(w http.ResponseWriter, r *http.Request, ns, roomID string) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
info, err := g.sfuManager.GetRoomInfo(ns, roomID)
|
|
if err != nil {
|
|
if err == sfu.ErrRoomNotFound {
|
|
writeError(w, http.StatusNotFound, "room not found")
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, info)
|
|
}
|
|
|
|
// sfuJoinRoomHandler handles POST /v1/sfu/room/:roomId/join
|
|
func (g *Gateway) sfuJoinRoomHandler(w http.ResponseWriter, r *http.Request, ns, roomID string) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
var req JoinRoomRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
// Default display name if not provided
|
|
req.DisplayName = "Anonymous"
|
|
}
|
|
|
|
// Get user ID from query param or auth context
|
|
userID := r.URL.Query().Get("userId")
|
|
if userID == "" {
|
|
userID = g.extractUserID(r)
|
|
}
|
|
if userID == "" {
|
|
userID = "anonymous"
|
|
}
|
|
|
|
// Get or create room
|
|
room, _ := g.sfuManager.GetOrCreateRoom(ns, roomID)
|
|
|
|
// This endpoint just returns room info
|
|
// The actual peer connection is established via WebSocket
|
|
writeJSON(w, http.StatusOK, &JoinRoomResponse{
|
|
ParticipantID: "", // Will be assigned when WebSocket connects
|
|
Participants: room.GetParticipants(),
|
|
})
|
|
}
|
|
|
|
// sfuLeaveRoomHandler handles POST /v1/sfu/room/:roomId/leave
|
|
func (g *Gateway) sfuLeaveRoomHandler(w http.ResponseWriter, r *http.Request, ns, roomID string) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Parse participant ID from request body
|
|
var req struct {
|
|
ParticipantID string `json:"participantId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ParticipantID == "" {
|
|
writeError(w, http.StatusBadRequest, "participantId required")
|
|
return
|
|
}
|
|
|
|
room, err := g.sfuManager.GetRoom(ns, roomID)
|
|
if err != nil {
|
|
if err == sfu.ErrRoomNotFound {
|
|
writeError(w, http.StatusNotFound, "room not found")
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := room.RemovePeer(req.ParticipantID); err != nil {
|
|
if err == sfu.ErrPeerNotFound {
|
|
writeError(w, http.StatusNotFound, "participant not found")
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
})
|
|
}
|
|
|
|
// sfuParticipantsHandler handles GET /v1/sfu/room/:roomId/participants
|
|
func (g *Gateway) sfuParticipantsHandler(w http.ResponseWriter, r *http.Request, ns, roomID string) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
room, err := g.sfuManager.GetRoom(ns, roomID)
|
|
if err != nil {
|
|
if err == sfu.ErrRoomNotFound {
|
|
writeError(w, http.StatusNotFound, "room not found")
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"participants": room.GetParticipants(),
|
|
})
|
|
}
|
|
|
|
// sfuWebSocketHandler handles WebSocket signaling for a room
|
|
func (g *Gateway) sfuWebSocketHandler(w http.ResponseWriter, r *http.Request, ns, roomID string) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Get user ID and display name from query parameters
|
|
// Priority: 1) userId query param, 2) JWT/API key auth, 3) "anonymous"
|
|
userID := r.URL.Query().Get("userId")
|
|
if userID == "" {
|
|
// Fall back to authentication-based user ID
|
|
userID = g.extractUserID(r)
|
|
}
|
|
if userID == "" {
|
|
userID = "anonymous"
|
|
}
|
|
|
|
displayName := r.URL.Query().Get("displayName")
|
|
if displayName == "" {
|
|
displayName = "Anonymous"
|
|
}
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
g.logger.ComponentWarn(logging.ComponentGeneral, "SFU WebSocket upgrade failed",
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
|
|
// Recover from panics to avoid silent crashes
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
g.logger.ComponentError(logging.ComponentGeneral, "SFU WebSocket handler panic",
|
|
zap.Any("panic", r),
|
|
zap.String("room_id", roomID),
|
|
zap.String("user_id", userID),
|
|
)
|
|
conn.Close()
|
|
}
|
|
}()
|
|
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU WebSocket connected",
|
|
zap.String("room_id", roomID),
|
|
zap.String("namespace", ns),
|
|
zap.String("user_id", userID),
|
|
zap.String("display_name", displayName),
|
|
)
|
|
|
|
// Get or create room
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "SFU getting/creating room",
|
|
zap.String("room_id", roomID),
|
|
zap.String("namespace", ns),
|
|
)
|
|
room, created := g.sfuManager.GetOrCreateRoom(ns, roomID)
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU room ready",
|
|
zap.String("room_id", roomID),
|
|
zap.Bool("created", created),
|
|
)
|
|
|
|
// Create peer
|
|
peer := sfu.NewPeer(userID, displayName, conn, room, g.logger.Logger)
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU peer created",
|
|
zap.String("peer_id", peer.ID),
|
|
)
|
|
|
|
// Add peer to room
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "SFU adding peer to room (will init peer connection)",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.String("room_id", roomID),
|
|
)
|
|
if err := room.AddPeer(peer); err != nil {
|
|
g.logger.ComponentError(logging.ComponentGeneral, "Failed to add peer to room",
|
|
zap.String("room_id", roomID),
|
|
zap.Error(err),
|
|
)
|
|
peer.SendMessage(sfu.NewErrorMessage("join_failed", err.Error()))
|
|
conn.Close()
|
|
return
|
|
}
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU peer added to room successfully",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.String("room_id", roomID),
|
|
)
|
|
|
|
// Send welcome message
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "SFU preparing welcome message",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.Int("num_participants", len(room.GetParticipants())),
|
|
)
|
|
welcomeMsg := sfu.NewServerMessage(sfu.MessageTypeWelcome, &sfu.WelcomeData{
|
|
ParticipantID: peer.ID,
|
|
RoomID: roomID,
|
|
Participants: room.GetParticipants(),
|
|
})
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "SFU sending welcome message via SendMessage",
|
|
zap.String("peer_id", peer.ID),
|
|
)
|
|
if err := peer.SendMessage(welcomeMsg); err != nil {
|
|
g.logger.ComponentError(logging.ComponentGeneral, "Failed to send welcome message",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.Error(err),
|
|
)
|
|
conn.Close()
|
|
return
|
|
}
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU welcome message sent successfully",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.String("room_id", roomID),
|
|
)
|
|
|
|
// Handle signaling messages
|
|
// Note: existing tracks are sent AFTER the first offer/answer exchange completes
|
|
g.handleSFUSignaling(conn, peer, room)
|
|
}
|
|
|
|
// handleSFUSignaling handles WebSocket signaling messages for a peer
|
|
func (g *Gateway) handleSFUSignaling(conn *websocket.Conn, peer *sfu.Peer, room *sfu.Room) {
|
|
defer func() {
|
|
room.RemovePeer(peer.ID)
|
|
conn.Close()
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU WebSocket disconnected",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.String("room_id", room.ID),
|
|
)
|
|
}()
|
|
|
|
// Set up ping/pong for keepalive
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
return nil
|
|
})
|
|
|
|
// Start ping ticker
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
go func() {
|
|
for range ticker.C {
|
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Read messages
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
g.logger.ComponentWarn(logging.ComponentGeneral, "SFU WebSocket read error",
|
|
zap.String("peer_id", peer.ID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Reset read deadline
|
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
|
|
// Parse message
|
|
var msg sfu.ClientMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("invalid_message", "failed to parse message"))
|
|
continue
|
|
}
|
|
|
|
// Handle message
|
|
g.handleSFUMessage(peer, room, &msg)
|
|
}
|
|
}
|
|
|
|
// handleSFUMessage handles a single signaling message
|
|
func (g *Gateway) handleSFUMessage(peer *sfu.Peer, room *sfu.Room, msg *sfu.ClientMessage) {
|
|
switch msg.Type {
|
|
case sfu.MessageTypeOffer:
|
|
var data sfu.OfferData
|
|
if err := json.Unmarshal(msg.Data, &data); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("invalid_offer", err.Error()))
|
|
return
|
|
}
|
|
if err := peer.HandleOffer(data.SDP); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("offer_failed", err.Error()))
|
|
return
|
|
}
|
|
|
|
// After successfully handling the FIRST offer, send existing tracks
|
|
// This ensures the WebRTC connection is established before adding more tracks
|
|
if peer.MarkInitialOfferHandled() {
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "First offer handled, sending existing tracks",
|
|
zap.String("peer_id", peer.ID),
|
|
)
|
|
room.SendExistingTracksTo(peer)
|
|
}
|
|
|
|
case sfu.MessageTypeAnswer:
|
|
var data sfu.AnswerData
|
|
if err := json.Unmarshal(msg.Data, &data); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("invalid_answer", err.Error()))
|
|
return
|
|
}
|
|
if err := peer.HandleAnswer(data.SDP); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("answer_failed", err.Error()))
|
|
}
|
|
|
|
case sfu.MessageTypeICECandidate:
|
|
var data sfu.ICECandidateData
|
|
if err := json.Unmarshal(msg.Data, &data); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("invalid_candidate", err.Error()))
|
|
return
|
|
}
|
|
if err := peer.HandleICECandidate(&data); err != nil {
|
|
peer.SendMessage(sfu.NewErrorMessage("candidate_failed", err.Error()))
|
|
}
|
|
|
|
case sfu.MessageTypeMute:
|
|
peer.SetAudioMuted(true)
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "Peer muted audio", zap.String("peer_id", peer.ID))
|
|
|
|
case sfu.MessageTypeUnmute:
|
|
peer.SetAudioMuted(false)
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "Peer unmuted audio", zap.String("peer_id", peer.ID))
|
|
|
|
case sfu.MessageTypeStartVideo:
|
|
peer.SetVideoMuted(false)
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "Peer started video", zap.String("peer_id", peer.ID))
|
|
|
|
case sfu.MessageTypeStopVideo:
|
|
peer.SetVideoMuted(true)
|
|
g.logger.ComponentDebug(logging.ComponentGeneral, "Peer stopped video", zap.String("peer_id", peer.ID))
|
|
|
|
case sfu.MessageTypeLeave:
|
|
// Will be handled by deferred cleanup
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "Peer leaving room", zap.String("peer_id", peer.ID))
|
|
|
|
default:
|
|
peer.SendMessage(sfu.NewErrorMessage("unknown_type", "unknown message type"))
|
|
}
|
|
}
|
|
|
|
// initializeSFUManager initializes the SFU manager with the gateway's TURN config
|
|
func (g *Gateway) initializeSFUManager() error {
|
|
if g.cfg.SFU == nil || !g.cfg.SFU.Enabled {
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU service disabled")
|
|
return nil
|
|
}
|
|
|
|
// Build ICE servers from config
|
|
iceServers := make([]webrtc.ICEServer, 0)
|
|
|
|
// Add configured ICE servers
|
|
if g.cfg.SFU.ICEServers != nil {
|
|
for _, server := range g.cfg.SFU.ICEServers {
|
|
iceServers = append(iceServers, webrtc.ICEServer{
|
|
URLs: server.URLs,
|
|
Username: server.Username,
|
|
Credential: server.Credential,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add TURN servers if configured (credentials will be generated dynamically)
|
|
if g.cfg.TURN != nil {
|
|
// Determine hostname for ICE server URLs
|
|
// Use configured domain, or fallback to localhost for local development
|
|
iceHost := g.cfg.DomainName
|
|
if iceHost == "" {
|
|
iceHost = "localhost"
|
|
}
|
|
|
|
if len(g.cfg.TURN.STUNURLs) > 0 {
|
|
// Process URLs to replace empty hostnames (e.g., "stun::3478" -> "stun:localhost:3478")
|
|
processedURLs := processURLsWithHost(g.cfg.TURN.STUNURLs, iceHost)
|
|
iceServers = append(iceServers, webrtc.ICEServer{
|
|
URLs: processedURLs,
|
|
})
|
|
}
|
|
// Note: TURN credentials are time-limited, so clients should fetch them
|
|
// via the /v1/turn/credentials endpoint before joining a room
|
|
}
|
|
|
|
// Create SFU config
|
|
sfuConfig := &sfu.Config{
|
|
MaxParticipants: g.cfg.SFU.MaxParticipants,
|
|
MediaTimeout: g.cfg.SFU.MediaTimeout,
|
|
ICEServers: iceServers,
|
|
}
|
|
|
|
if sfuConfig.MaxParticipants == 0 {
|
|
sfuConfig.MaxParticipants = 10
|
|
}
|
|
if sfuConfig.MediaTimeout == 0 {
|
|
sfuConfig.MediaTimeout = 30 * time.Second
|
|
}
|
|
|
|
manager, err := sfu.NewRoomManager(sfuConfig, g.logger.Logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
g.sfuManager = manager
|
|
|
|
g.logger.ComponentInfo(logging.ComponentGeneral, "SFU manager initialized",
|
|
zap.Int("max_participants", sfuConfig.MaxParticipants),
|
|
zap.Duration("media_timeout", sfuConfig.MediaTimeout),
|
|
zap.Int("ice_servers", len(iceServers)),
|
|
)
|
|
|
|
return nil
|
|
}
|