diff --git a/.gitignore b/.gitignore index 7f268eb..e816974 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ prd/ .vercel venv/ -.venv/ \ No newline at end of file +.venv/ +.playwright-mcp diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py index 49ec1bc..058def5 100644 --- a/api/routes/public_embed.py +++ b/api/routes/public_embed.py @@ -19,6 +19,11 @@ from pydantic import BaseModel from api.db import db_client from api.enums import WorkflowRunMode +from api.routes.turn_credentials import ( + TURN_SECRET, + TurnCredentialsResponse, + generate_turn_credentials, +) router = APIRouter(prefix="/public/embed") @@ -108,6 +113,14 @@ def generate_session_token() -> str: return f"emb_session_{secrets.token_urlsafe(32)}" +def get_request_origin(request: Request) -> str: + """Extract origin from request headers, falling back to referer if not present.""" + origin = request.headers.get("origin", "") + if not origin: + origin = request.headers.get("referer", "") + return origin + + @router.post("/init", response_model=InitEmbedResponse) async def initialize_embed_session(request: Request, init_request: InitEmbedRequest): """Initialize an embed session with token validation and domain checking. @@ -119,10 +132,7 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ 4. Generates a temporary session token 5. Returns configuration for the widget """ - # Get origin header for domain validation - origin = request.headers.get("origin", "") - if not origin: - origin = request.headers.get("referer", "") + origin = get_request_origin(request) # Validate embed token embed_token = await db_client.get_embed_token_by_token(init_request.token) @@ -201,10 +211,7 @@ async def get_embed_config(token: str, request: Request): This endpoint is used to fetch widget configuration for display purposes without actually starting a call session. """ - # Get origin header for domain validation - origin = request.headers.get("origin", "") - if not origin: - origin = request.headers.get("referer", "") + origin = get_request_origin(request) # Validate embed token embed_token = await db_client.get_embed_token_by_token(token) @@ -252,6 +259,94 @@ async def options_init(request: Request): ) +@router.get("/turn-credentials/{session_token}", response_model=TurnCredentialsResponse) +async def get_public_turn_credentials(session_token: str, request: Request): + """Get TURN credentials for an embed session. + + This endpoint allows embedded widgets to obtain TURN server credentials + for WebRTC connections without requiring authentication. + + Args: + session_token: The session token from embed initialization + + Returns: + TurnCredentialsResponse with username, password, ttl, and TURN URIs + """ + origin = get_request_origin(request) + + # Validate session token + embed_session = await db_client.get_embed_session_by_token(session_token) + if not embed_session: + raise HTTPException(status_code=404, detail="Invalid session token") + + # Check if session is expired + if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC): + raise HTTPException(status_code=403, detail="Session expired") + + # Get the embed token to check allowed domains + embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id) + if not embed_token: + raise HTTPException(status_code=404, detail="Invalid embed token") + + # Validate domain (empty allowed_domains means allow all) + if not validate_origin(origin, embed_token.allowed_domains or []): + logger.warning( + f"Domain validation failed for TURN credentials: {origin} not in {embed_token.allowed_domains}" + ) + raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + + # Check if TURN is configured + if not TURN_SECRET: + raise HTTPException( + status_code=503, + detail="TURN server not configured", + ) + + try: + # Use session token as identifier for TURN credentials + credentials = generate_turn_credentials(f"embed:{session_token[:16]}") + return TurnCredentialsResponse(**credentials) + except Exception as e: + logger.error(f"Failed to generate TURN credentials for embed session: {e}") + raise HTTPException( + status_code=500, + detail="Failed to generate TURN credentials", + ) + + +@router.options("/turn-credentials/{session_token}") +async def options_turn_credentials(request: Request, session_token: str): + """Handle CORS preflight for TURN credentials endpoint""" + origin = request.headers.get("origin", "*") + + # Try to validate the session token and get allowed domains + allowed_origin = origin + try: + embed_session = await db_client.get_embed_session_by_token(session_token) + if embed_session: + embed_token = await db_client.get_embed_token_by_id( + embed_session.embed_token_id + ) + if embed_token: + # Check if origin is in allowed domains (empty means allow all) + if validate_origin(origin, embed_token.allowed_domains or []): + allowed_origin = origin + else: + allowed_origin = "" + except Exception: + # On error, be permissive for OPTIONS + pass + + return Response( + headers={ + "Access-Control-Allow-Origin": allowed_origin, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + } + ) + + @router.options("/config/{token}") async def options_config(request: Request, token: str): """Handle CORS preflight for config endpoint""" diff --git a/ui/public/embed/dograh-widget.js b/ui/public/embed/dograh-widget.js index a9c93ea..7d4ae7c 100644 --- a/ui/public/embed/dograh-widget.js +++ b/ui/public/embed/dograh-widget.js @@ -28,6 +28,7 @@ workflowRunId: null, connectionStatus: 'idle', // idle, connecting, connected, failed audioElement: null, + turnCredentials: null, // TURN server credentials callbacks: { onReady: null, onCallStart: null, @@ -680,14 +681,62 @@ state.sessionToken = data.session_token; state.workflowRunId = data.workflow_run_id; state.workflowId = data.config.workflow_id; + + // Fetch TURN credentials after session initialization + await fetchTurnCredentials(); + } + + /** + * Fetch TURN credentials for WebRTC connection + */ + async function fetchTurnCredentials() { + if (!state.sessionToken) { + console.warn('Dograh Widget: No session token available for TURN credentials'); + return; + } + + try { + const response = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/turn-credentials/${state.sessionToken}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Origin': window.location.origin + } + }); + + if (response.ok) { + state.turnCredentials = await response.json(); + console.log(`TURN credentials obtained, TTL: ${state.turnCredentials.ttl}s`); + } else if (response.status === 503) { + // TURN not configured on server - this is OK, we'll use STUN only + console.log('TURN server not configured, using STUN only'); + } else { + console.warn(`Failed to fetch TURN credentials: ${response.status}`); + } + } catch (error) { + console.warn('Failed to fetch TURN credentials, continuing without TURN:', error); + } } /** * Create WebRTC peer connection */ function createWebRTCConnection() { + // Build ICE servers list + const iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }]; + + // Add TURN server if credentials are available + if (state.turnCredentials && state.turnCredentials.uris && state.turnCredentials.uris.length > 0) { + iceServers.push({ + urls: state.turnCredentials.uris, + username: state.turnCredentials.username, + credential: state.turnCredentials.password + }); + console.log(`TURN server configured with ${state.turnCredentials.uris.length} URIs`); + } + const config = { - iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] + iceServers: iceServers }; state.pc = new RTCPeerConnection(config); diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx index 8bd9d20..4d9c0f0 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/BrowserCall.tsx @@ -95,9 +95,9 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar return ( <> -
+
{/* Main content - 2/3 width when panel visible, full width otherwise */} -
+
@@ -141,7 +141,7 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
{/* Show transcript panel */} -
+
+
{/* Main content - 2/3 width */}
@@ -254,7 +254,7 @@ export default function WorkflowRunPage() {
{/* Transcript panel - 1/3 width */} -
+