mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: allow turn credentials fetching from embed agent
This commit is contained in:
parent
a235d21d69
commit
6ccc6492ee
5 changed files with 160 additions and 15 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,4 +13,5 @@ prd/
|
|||
.vercel
|
||||
|
||||
venv/
|
||||
.venv/
|
||||
.venv/
|
||||
.playwright-mcp
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -95,9 +95,9 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
{/* Main content - 2/3 width when panel visible, full width otherwise */}
|
||||
<div className="w-2/3 h-full">
|
||||
<div className="w-2/3 h-full overflow-y-auto">
|
||||
<div className="flex justify-center items-center h-full px-8">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader>
|
||||
|
|
@ -141,7 +141,7 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
|
|||
</div>
|
||||
|
||||
{/* Show transcript panel */}
|
||||
<div className="w-1/3 h-full shrink-0">
|
||||
<div className="w-1/3 h-full shrink-0 overflow-hidden">
|
||||
<RealtimeFeedback
|
||||
mode="live"
|
||||
messages={feedbackMessages}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export default function WorkflowRunPage() {
|
|||
}
|
||||
else if (workflowRun?.is_completed) {
|
||||
returnValue = (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
{/* Main content - 2/3 width */}
|
||||
<div className="w-2/3 h-full flex items-center justify-center overflow-y-auto">
|
||||
<div className="w-full max-w-4xl space-y-6 p-6">
|
||||
|
|
@ -254,7 +254,7 @@ export default function WorkflowRunPage() {
|
|||
</div>
|
||||
|
||||
{/* Transcript panel - 1/3 width */}
|
||||
<div className="w-1/3 h-full shrink-0">
|
||||
<div className="w-1/3 h-full shrink-0 overflow-hidden">
|
||||
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue