mirror of https://github.com/kortix-ai/suna.git
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""
|
|
Google Slides Integration API
|
|
|
|
Consolidated API router that handles both OAuth flow and presentation conversion endpoints.
|
|
Combines functionality from google_oauth_router.py and presentation_tools_api.py.
|
|
|
|
Endpoints:
|
|
- OAuth: GET /google/auth-url, GET /google/callback, POST /google/disconnect
|
|
- Presentations: POST /presentation-tools/convert-and-upload-to-slides
|
|
"""
|
|
|
|
import os
|
|
import httpx
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from utils.auth_utils import verify_and_get_user_id_from_jwt
|
|
from utils.logger import logger
|
|
from services.supabase import DBConnection
|
|
from .google_slides_service import GoogleSlidesService, OAuthTokenService
|
|
|
|
|
|
# ================== PYDANTIC MODELS ==================
|
|
|
|
class AuthURLResponse(BaseModel):
|
|
auth_url: str
|
|
message: str
|
|
|
|
|
|
# UNUSED: AuthStatusResponse - no auth status endpoint exists
|
|
# class AuthStatusResponse(BaseModel):
|
|
# is_authenticated: bool
|
|
# message: str
|
|
|
|
|
|
# UNUSED: CallbackResponse - callback returns redirect, not JSON
|
|
# class CallbackResponse(BaseModel):
|
|
# success: bool
|
|
# message: str
|
|
# redirect_url: Optional[str] = None
|
|
|
|
|
|
class ConvertToSlidesRequest(BaseModel):
|
|
presentation_path: str = Field(..., description="Path to the presentation in sandbox (e.g., /workspace/presentations/my-pres)")
|
|
sandbox_url: str = Field(..., description="Sandbox URL to fetch the PPTX from")
|
|
|
|
|
|
class ConvertToSlidesResponse(BaseModel):
|
|
success: bool
|
|
message: str
|
|
pptx_url: Optional[str] = None
|
|
google_slides_url: Optional[str] = None
|
|
google_slides_file_id: Optional[str] = None
|
|
is_api_enabled: bool = None
|
|
|
|
|
|
# ================== DEPENDENCY INJECTION ==================
|
|
|
|
async def get_db_connection() -> DBConnection:
|
|
"""Get database connection."""
|
|
db = DBConnection()
|
|
await db.initialize()
|
|
return db
|
|
|
|
|
|
async def get_oauth_service(db: DBConnection = Depends(get_db_connection)) -> OAuthTokenService:
|
|
"""Get OAuth token service with database connection."""
|
|
return OAuthTokenService(db)
|
|
|
|
|
|
async def get_google_slides_service(oauth_service: OAuthTokenService = Depends(get_oauth_service)) -> GoogleSlidesService:
|
|
"""Get Google Slides service with OAuth token service."""
|
|
return GoogleSlidesService(oauth_service)
|
|
|
|
|
|
# ================== ROUTER SETUP ==================
|
|
|
|
# Create a main router that will include sub-routers for different prefixes
|
|
main_router = APIRouter()
|
|
|
|
# Create sub-routers for different endpoint groups
|
|
oauth_router = APIRouter(prefix="/google", tags=["google-oauth"])
|
|
presentation_router = APIRouter(prefix="/presentation-tools", tags=["presentation-tools"])
|
|
|
|
|
|
# ================== OAUTH ENDPOINTS ==================
|
|
|
|
@oauth_router.get("/auth-url", response_model=AuthURLResponse)
|
|
async def get_google_auth_url(
|
|
user_id: str = Depends(verify_and_get_user_id_from_jwt),
|
|
return_url: Optional[str] = Query(None, description="URL to redirect to after OAuth"),
|
|
google_service: GoogleSlidesService = Depends(get_google_slides_service)
|
|
):
|
|
"""
|
|
Generate Google OAuth consent URL for the authenticated user
|
|
|
|
Args:
|
|
user_id: User identifier from JWT authentication
|
|
return_url: Optional URL to redirect to after successful OAuth
|
|
google_service: Injected Google Slides service
|
|
|
|
Returns:
|
|
OAuth consent URL that user should visit
|
|
"""
|
|
try:
|
|
auth_url = google_service.get_auth_url(user_id, return_url)
|
|
|
|
return AuthURLResponse(
|
|
auth_url=auth_url,
|
|
message="Visit this URL to authenticate with Google"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate auth URL: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@oauth_router.get("/callback")
|
|
async def google_oauth_callback(
|
|
code: Optional[str] = Query(None, description="Authorization code from Google"),
|
|
state: Optional[str] = Query(None, description="State parameter (contains user_id)"),
|
|
error: Optional[str] = Query(None, description="Error from Google OAuth"),
|
|
google_service: GoogleSlidesService = Depends(get_google_slides_service)
|
|
):
|
|
"""
|
|
Handle Google OAuth callback
|
|
|
|
This endpoint receives the authorization code from Google and exchanges it for tokens.
|
|
Redirects to the frontend with success/error status.
|
|
"""
|
|
# Get frontend URL from environment (supports different environments)
|
|
frontend_url = os.getenv("FRONTEND_URL")
|
|
|
|
if error:
|
|
logger.error(f"Google OAuth error: {error}")
|
|
return RedirectResponse(url=f"{frontend_url}?google_auth=error&error={error}")
|
|
|
|
if not code or not state:
|
|
raise HTTPException(status_code=400, detail="Missing authorization code or state")
|
|
|
|
# Extract user_id from state (will be updated when service is modified)
|
|
user_id = state.split(':')[0] # State format: user_id:uuid or user_id:uuid:return_url
|
|
|
|
try:
|
|
tokens, return_url = await google_service.handle_oauth_callback(code, state)
|
|
|
|
logger.info(f"Successfully authenticated user {user_id} with Google")
|
|
|
|
# Use the return URL from state if available, otherwise default to frontend root
|
|
if return_url:
|
|
# Ensure return_url is from the same domain for security
|
|
if return_url.startswith(frontend_url):
|
|
redirect_url = f"{return_url}?google_auth=success"
|
|
else:
|
|
logger.warning(f"Return URL {return_url} not from same domain as {frontend_url}, using default")
|
|
redirect_url = f"{frontend_url}?google_auth=success"
|
|
else:
|
|
redirect_url = f"{frontend_url}?google_auth=success"
|
|
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to exchange authorization code: {e}", exc_info=True)
|
|
return RedirectResponse(url=f"{frontend_url}?google_auth=error&error=auth_failed")
|
|
|
|
|
|
# UNUSED: Disconnect endpoint - frontend never calls this
|
|
# @oauth_router.post("/disconnect")
|
|
# async def disconnect_google(
|
|
# user_id: str = Depends(verify_and_get_user_id_from_jwt),
|
|
# google_service: GoogleSlidesService = Depends(get_google_slides_service)
|
|
# ):
|
|
# """
|
|
# Disconnect authenticated user from Google (remove stored tokens)
|
|
#
|
|
# Args:
|
|
# user_id: User identifier from JWT authentication
|
|
# google_service: Injected Google Slides service
|
|
# """
|
|
# try:
|
|
# result = await google_service.disconnect_user(user_id)
|
|
# return result
|
|
#
|
|
# except Exception as e:
|
|
# logger.error(f"Failed to disconnect user: {e}")
|
|
# raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ================== PRESENTATION ENDPOINTS ==================
|
|
|
|
@presentation_router.post("/convert-and-upload-to-slides", response_model=ConvertToSlidesResponse)
|
|
async def convert_and_upload_to_google_slides(
|
|
request: ConvertToSlidesRequest,
|
|
user_id: str = Depends(verify_and_get_user_id_from_jwt),
|
|
google_service: GoogleSlidesService = Depends(get_google_slides_service)
|
|
):
|
|
"""
|
|
Convert HTML presentation to PPTX and upload to Google Slides
|
|
|
|
Flow:
|
|
1. Call sandbox to convert HTML → PPTX
|
|
2. Download PPTX from sandbox
|
|
3. Upload PPTX to Google Slides using authenticated user's credentials
|
|
4. Return both local and Google Slides URLs
|
|
"""
|
|
|
|
try:
|
|
logger.info(f"Starting presentation conversion and upload for user {user_id}")
|
|
|
|
# Step 1: Check if user is authenticated with Google
|
|
if not await google_service.is_user_authenticated(user_id):
|
|
return ConvertToSlidesResponse(
|
|
success=False,
|
|
message="User not authenticated with Google. Please authenticate first.",
|
|
pptx_url=None,
|
|
is_api_enabled=False
|
|
)
|
|
|
|
# Step 2: Call sandbox to convert HTML to PPTX
|
|
logger.debug(f"Converting presentation at {request.presentation_path}")
|
|
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
convert_response = await client.post(
|
|
f"{request.sandbox_url}/presentation/convert-to-pptx",
|
|
json={
|
|
"presentation_path": request.presentation_path,
|
|
"download": True, # Get PPTX content directly, don't store locally
|
|
"upload_to_google_slides": False, # We'll handle Google upload from main backend
|
|
}
|
|
)
|
|
|
|
if not convert_response.is_success:
|
|
try:
|
|
error_detail = convert_response.json().get("detail", "Unknown error")
|
|
except:
|
|
error_detail = convert_response.text
|
|
raise HTTPException(
|
|
status_code=convert_response.status_code,
|
|
detail=f"PPTX conversion failed: {error_detail}"
|
|
)
|
|
|
|
# When download=True, we get the PPTX file content directly
|
|
pptx_content = convert_response.content
|
|
|
|
# Extract filename from Content-Disposition header
|
|
filename = "presentation.pptx" # default
|
|
content_disposition = convert_response.headers.get("Content-Disposition", "")
|
|
if "filename=" in content_disposition:
|
|
filename = content_disposition.split('filename="')[1].split('"')[0]
|
|
|
|
logger.debug(f"PPTX conversion successful: {filename}")
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pptx", delete=False) as temp_file:
|
|
temp_pptx_path = Path(temp_file.name)
|
|
temp_file.write(pptx_content)
|
|
temp_file.flush() # Ensure data is written to disk
|
|
|
|
logger.debug(f"Saved PPTX to temporary file: {temp_pptx_path} (size: {len(pptx_content)} bytes)")
|
|
|
|
try:
|
|
# Step 3: Upload PPTX to Google Slides
|
|
logger.debug("Uploading PPTX to Google Slides")
|
|
|
|
# Extract presentation name from filename (remove timestamp and extension)
|
|
presentation_name = filename.replace(".pptx", "").split("_")[0] if "_" in filename else filename.replace(".pptx", "")
|
|
|
|
upload_result = await google_service.upload_pptx_to_slides(
|
|
pptx_file_path=temp_pptx_path,
|
|
user_id=user_id,
|
|
presentation_name=presentation_name
|
|
)
|
|
logger.info(f"Successfully uploaded to Google Slides: {upload_result['web_view_link']}")
|
|
|
|
return ConvertToSlidesResponse(
|
|
success=True,
|
|
message=f"Successfully converted and uploaded to Google Slides",
|
|
pptx_url=None, # No local URL since we generated content live
|
|
google_slides_url=upload_result["web_view_link"],
|
|
google_slides_file_id=upload_result["file_id"]
|
|
)
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
try:
|
|
temp_pptx_path.unlink()
|
|
except:
|
|
pass # Ignore cleanup errors
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions as-is
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in convert_and_upload_to_google_slides: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
|
|
|
|
|
# ================== ROUTER ASSEMBLY ==================
|
|
|
|
# Include both sub-routers in the main router
|
|
main_router.include_router(oauth_router)
|
|
main_router.include_router(presentation_router)
|
|
|
|
# Export the main router as 'router' for backward compatibility
|
|
router = main_router
|