From d2eb9846d3cc3c666377dbe0cb385b78a31d1691 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 08:29:38 +0000 Subject: [PATCH] style: fix linting issues and remove unused imports - Remove unused imports from all modified files - Fix flake8 violations (unused variables, f-strings without placeholders) - Apply Black formatting consistently - Shorten long line in google_drive.py Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/common.py | 23 +-- app/api/dropbox.py | 156 +++++++++----------- app/api/google_drive.py | 253 +++++++++++++++----------------- app/api/onedrive.py | 230 +++++++++++++---------------- app/tasks/upload_with_rclone.py | 92 ++++-------- app/utils/oauth_helper.py | 48 +++--- 6 files changed, 350 insertions(+), 452 deletions(-) diff --git a/app/api/common.py b/app/api/common.py index 591bac88..23a008c5 100644 --- a/app/api/common.py +++ b/app/api/common.py @@ -1,11 +1,11 @@ """ Common utilities for API routes """ + import logging import os from pathlib import Path -from sqlalchemy.orm import Session -from fastapi import Depends, HTTPException, status +from fastapi import HTTPException, status from app.database import SessionLocal from app.config import settings @@ -13,6 +13,7 @@ from app.config import settings # Set up logging logger = logging.getLogger(__name__) + def get_db(): """Database dependency injection for routes""" db = SessionLocal() @@ -21,22 +22,23 @@ def get_db(): finally: db.close() + def resolve_file_path(file_path: str, subfolder: str = None) -> str: """ Resolves a file path to an absolute path with path traversal protection. If the path is not absolute, it will be joined with the workdir path. Optionally, can include a subfolder like 'processed'. - + Security: Validates that the resolved path stays within the workdir to prevent path traversal attacks (e.g., ../../etc/passwd). - + Args: file_path: The file path to resolve subfolder: Optional subfolder within workdir - + Returns: The validated absolute file path - + Raises: HTTPException: If the path attempts to escape the workdir """ @@ -45,13 +47,13 @@ def resolve_file_path(file_path: str, subfolder: str = None) -> str: base_dir = Path(settings.workdir) / subfolder else: base_dir = Path(settings.workdir) - + # Resolve the file path if not os.path.isabs(file_path): resolved_path = (base_dir / file_path).resolve() else: resolved_path = Path(file_path).resolve() - + # Ensure the resolved path is within the base directory (path traversal protection) try: resolved_path.relative_to(base_dir.resolve()) @@ -59,8 +61,7 @@ def resolve_file_path(file_path: str, subfolder: str = None) -> str: # Path is outside the base directory - potential path traversal attack logger.warning(f"Path traversal attempt detected: {file_path} -> {resolved_path}") raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid file path: path traversal not allowed" + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file path: path traversal not allowed" ) - + return str(resolved_path) diff --git a/app/api/dropbox.py b/app/api/dropbox.py index b1b2a5f0..6ebaea92 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -1,13 +1,11 @@ """ Dropbox API endpoints """ + from fastapi import APIRouter, Request, HTTPException, status, Form import logging import os import requests -import json -from datetime import datetime, timedelta -from typing import Optional from app.auth import require_login from app.config import settings @@ -18,6 +16,7 @@ logger = logging.getLogger(__name__) router = APIRouter() + @router.post("/dropbox/exchange-token") @require_login async def exchange_dropbox_token( @@ -26,7 +25,7 @@ async def exchange_dropbox_token( client_secret: str = Form(...), redirect_uri: str = Form(...), code: str = Form(...), - folder_path: str = Form(None) + folder_path: str = Form(None), ): """ Exchange an authorization code for a refresh token from Dropbox. @@ -34,29 +33,26 @@ async def exchange_dropbox_token( """ # Prepare the token request token_url = "https://api.dropboxapi.com/oauth2/token" - + payload = { - 'client_id': client_id, - 'client_secret': client_secret, - 'code': code, - 'redirect_uri': redirect_uri, - 'grant_type': 'authorization_code' + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", } - + # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token( - provider_name="Dropbox", - token_url=token_url, - payload=payload - ) - + token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload) + # Return just what's needed by the frontend return { "refresh_token": token_data["refresh_token"], "access_token": token_data["access_token"], - "expires_in": token_data.get("expires_in", 14400) + "expires_in": token_data.get("expires_in", 14400), } + @router.post("/dropbox/update-settings") @require_login async def update_dropbox_settings( @@ -64,45 +60,42 @@ async def update_dropbox_settings( app_key: str = Form(None), app_secret: str = Form(None), refresh_token: str = Form(...), - folder_path: str = Form(None) + folder_path: str = Form(None), ): """ Update Dropbox settings in memory """ try: logger.info("Updating Dropbox settings in memory") - + # Update settings in memory if refresh_token: settings.dropbox_refresh_token = refresh_token logger.info("Updated DROPBOX_REFRESH_TOKEN in memory") - + if app_key: settings.dropbox_app_key = app_key logger.info("Updated DROPBOX_APP_KEY in memory") - + if app_secret: settings.dropbox_app_secret = app_secret logger.info("Updated DROPBOX_APP_SECRET in memory") - + if folder_path: settings.dropbox_folder = folder_path logger.info("Updated DROPBOX_FOLDER in memory") - + # Test token validity would be here, but we'll skip it for now - - return { - "status": "success", - "message": "Dropbox settings have been updated in memory" - } - + + return {"status": "success", "message": "Dropbox settings have been updated in memory"} + except Exception as e: logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update Dropbox settings: {str(e)}" ) + @router.get("/dropbox/test-token") @require_login async def test_dropbox_token(request: Request): @@ -111,90 +104,78 @@ async def test_dropbox_token(request: Request): """ try: logger.info("Testing Dropbox token validity") - + if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret: logger.warning("Dropbox credentials not fully configured") - return { - "status": "error", - "message": "Dropbox credentials are not fully configured" - } - + return {"status": "error", "message": "Dropbox credentials are not fully configured"} + # Check token validity by getting current account info headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"} response = requests.post( "https://api.dropboxapi.com/2/users/get_current_account", headers=headers, - timeout=settings.http_request_timeout + timeout=settings.http_request_timeout, ) - + # If token is invalid, try refreshing it if response.status_code == 401: logger.info("Dropbox access token invalid or expired, trying to refresh") - + # Get a new access token using the refresh token refresh_url = "https://api.dropbox.com/oauth2/token" refresh_data = { "grant_type": "refresh_token", "refresh_token": settings.dropbox_refresh_token, "client_id": settings.dropbox_app_key, - "client_secret": settings.dropbox_app_secret + "client_secret": settings.dropbox_app_secret, } - + refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout) - + if refresh_response.status_code != 200: logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}") - return { - "status": "error", - "message": "Refresh token has expired or is invalid", - "needs_reauth": True - } - + return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + token_info = refresh_response.json() access_token = token_info.get("access_token") - + # Try again with the new access token headers = {"Authorization": f"Bearer {access_token}"} response = requests.post( "https://api.dropboxapi.com/2/users/get_current_account", headers=headers, - timeout=settings.http_request_timeout + timeout=settings.http_request_timeout, ) - + if response.status_code != 200: logger.error(f"Dropbox token test failed: {response.status_code} {response.text}") return { "status": "error", - "message": f"Token validation failed with status {response.status_code}: {response.text}" + "message": f"Token validation failed with status {response.status_code}: {response.text}", } - + # Get account info account_info = response.json() account_email = account_info.get("email", "Unknown account") account_name = account_info.get("name", {}).get("display_name", "Unknown user") - + # Dropbox refresh tokens don't expire, but we should note that in our response - token_info = { - "expires_in_human": "Never expires (perpetual token)", - "is_perpetual": True - } - + token_info = {"expires_in_human": "Never expires (perpetual token)", "is_perpetual": True} + logger.info(f"Successfully connected to Dropbox as {account_email}") - + return { "status": "success", - "message": f"Dropbox connection successful", + "message": "Dropbox connection successful", "account": account_email, "account_name": account_name, - "token_info": token_info + "token_info": token_info, } - + except Exception as e: logger.exception(f"Unexpected error testing Dropbox token: {str(e)}") - return { - "status": "error", - "message": f"Connection error: {str(e)}" - } + return {"status": "error", "message": f"Connection error: {str(e)}"} + @router.post("/dropbox/save-settings") @require_login @@ -203,7 +184,7 @@ async def save_dropbox_settings( app_key: str = Form(None), app_secret: str = Form(None), refresh_token: str = Form(...), - folder_path: str = Form(None) + folder_path: str = Form(None), ): """ Save Dropbox settings to the .env file @@ -211,25 +192,24 @@ async def save_dropbox_settings( try: # Get the path to the .env file env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") - + if not os.path.exists(env_path): logger.error(f".env file not found at {env_path}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not find .env file to update" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" ) - + logger.info(f"Updating Dropbox settings in {env_path}") - + # Read the current .env file with open(env_path, "r") as f: env_lines = f.readlines() - + # Define settings to update dropbox_settings = { "DROPBOX_REFRESH_TOKEN": refresh_token, } - + # Only update these if provided if app_key: dropbox_settings["DROPBOX_APP_KEY"] = app_key @@ -237,7 +217,7 @@ async def save_dropbox_settings( dropbox_settings["DROPBOX_APP_SECRET"] = app_secret if folder_path: dropbox_settings["DROPBOX_FOLDER"] = folder_path - + # Process each line and update or add settings updated = set() new_env_lines = [] @@ -254,16 +234,16 @@ async def save_dropbox_settings( break if not is_updated: new_env_lines.append(line) - + # Add any settings that weren't updated (they weren't in the file) for key, value in dropbox_settings.items(): if key not in updated: new_env_lines.append(f"{key}={value}") - + # Write the updated .env file with open(env_path, "w") as f: f.write("\n".join(new_env_lines) + "\n") - + # Update the settings in memory if refresh_token: settings.dropbox_refresh_token = refresh_token @@ -273,19 +253,15 @@ async def save_dropbox_settings( settings.dropbox_app_secret = app_secret if folder_path: settings.dropbox_folder = folder_path - + logger.info("Successfully updated Dropbox settings") - - return { - "status": "success", - "message": "Dropbox settings have been saved" - } - + + return {"status": "success", "message": "Dropbox settings have been saved"} + except HTTPException: raise except Exception as e: logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to save Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Dropbox settings: {str(e)}" ) diff --git a/app/api/google_drive.py b/app/api/google_drive.py index d6626896..35f09257 100644 --- a/app/api/google_drive.py +++ b/app/api/google_drive.py @@ -1,13 +1,12 @@ """ Google Drive API endpoints """ + from fastapi import APIRouter, Request, HTTPException, status, Form import logging import os -import requests -import json from typing import Optional -from datetime import datetime, timedelta +from datetime import datetime from app.auth import require_login from app.config import settings @@ -18,6 +17,7 @@ logger = logging.getLogger(__name__) router = APIRouter() + @router.post("/google-drive/exchange-token") @require_login async def exchange_google_drive_token( @@ -26,7 +26,7 @@ async def exchange_google_drive_token( client_secret: str = Form(...), redirect_uri: str = Form(...), code: str = Form(...), - folder_id: Optional[str] = Form(None) + folder_id: Optional[str] = Form(None), ): """ Exchange an authorization code for refresh and access tokens from Google. @@ -34,29 +34,26 @@ async def exchange_google_drive_token( """ # Prepare the token request token_url = "https://oauth2.googleapis.com/token" - + payload = { - 'client_id': client_id, - 'client_secret': client_secret, - 'code': code, - 'redirect_uri': redirect_uri, - 'grant_type': 'authorization_code' + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", } - + # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token( - provider_name="Google Drive", - token_url=token_url, - payload=payload - ) - + token_data = exchange_oauth_token(provider_name="Google Drive", token_url=token_url, payload=payload) + # Return just what's needed by the frontend return { "refresh_token": token_data["refresh_token"], "access_token": token_data["access_token"], - "expires_in": token_data.get("expires_in", 3600) + "expires_in": token_data.get("expires_in", 3600), } + @router.post("/google-drive/update-settings") @require_login async def update_google_drive_settings( @@ -65,50 +62,48 @@ async def update_google_drive_settings( client_secret: str = Form(None), refresh_token: str = Form(...), folder_id: str = Form(None), - use_oauth: str = Form("true") + use_oauth: str = Form("true"), ): """ Update Google Drive settings in memory """ try: logger.info("Updating Google Drive settings in memory") - + # Convert use_oauth string to boolean use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") - + # Update settings in memory if refresh_token: settings.google_drive_refresh_token = refresh_token logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory") - + if client_id: settings.google_drive_client_id = client_id logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory") - + if client_secret: settings.google_drive_client_secret = client_secret logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory") - + if folder_id: settings.google_drive_folder_id = folder_id logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory") - + # Set the OAuth flag settings.google_drive_use_oauth = use_oauth_bool logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory to {use_oauth_bool}") - - return { - "status": "success", - "message": "Google Drive settings have been updated in memory" - } - + + return {"status": "success", "message": "Google Drive settings have been updated in memory"} + except Exception as e: logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update Google Drive settings: {str(e)}" + detail=f"Failed to update Google Drive settings: {str(e)}", ) + @router.get("/google-drive/test-token") @require_login async def test_google_drive_token(request: Request): @@ -118,124 +113,112 @@ async def test_google_drive_token(request: Request): """ try: from app.tasks.upload_to_google_drive import get_drive_service_oauth, get_google_drive_service - + logger.info("Testing Google Drive token validity") - + # Check if OAuth is enabled and configured - if getattr(settings, 'google_drive_use_oauth', False): - if not (settings.google_drive_client_id and - settings.google_drive_client_secret and - settings.google_drive_refresh_token): + if getattr(settings, "google_drive_use_oauth", False): + if not ( + settings.google_drive_client_id + and settings.google_drive_client_secret + and settings.google_drive_refresh_token + ): logger.warning("Google Drive OAuth credentials not fully configured") - return { - "status": "error", - "message": "Google Drive OAuth credentials are not fully configured" - } - + return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + try: # Test OAuth connection service = get_drive_service_oauth() - + # Get credentials for checking token validity import google.oauth2.credentials from google.auth.transport.requests import Request - + credentials = google.oauth2.credentials.Credentials( token=None, refresh_token=settings.google_drive_refresh_token, token_uri="https://oauth2.googleapis.com/token", client_id=settings.google_drive_client_id, - client_secret=settings.google_drive_client_secret + client_secret=settings.google_drive_client_secret, ) - + # Force a refresh to update the token expiration if not credentials.valid: credentials.refresh(Request()) - + # Get token expiration info expiration_info = {} - if hasattr(credentials, 'expiry') and credentials.expiry: + if hasattr(credentials, "expiry") and credentials.expiry: now = datetime.now() expiry = credentials.expiry time_left = expiry - now expiration_info = { "expires_at": expiry.isoformat(), "expires_in_seconds": max(0, int(time_left.total_seconds())), - "expires_in_human": format_time_remaining(time_left) + "expires_in_human": format_time_remaining(time_left), } - + # Test basic API operation about = service.about().get(fields="user").execute() user_email = about.get("user", {}).get("emailAddress", "Unknown") - + logger.info(f"Successfully connected to Google Drive as {user_email}") - + return { "status": "success", "message": f"OAuth token is valid! Connected as {user_email}", "account": user_email, "auth_type": "oauth", - "token_info": expiration_info + "token_info": expiration_info, } except Exception as e: error_msg = str(e) logger.error(f"Google Drive OAuth token test failed: {error_msg}") - + # Check if this is a token-related error if "invalid_grant" in error_msg.lower() or "token" in error_msg.lower(): return { "status": "error", "message": f"OAuth token validation failed: {error_msg}", - "needs_reauth": True + "needs_reauth": True, } - return { - "status": "error", - "message": f"Connection error: {error_msg}" - } + return {"status": "error", "message": f"Connection error: {error_msg}"} else: # Test service account connection if not settings.google_drive_credentials_json: logger.warning("Google Drive service account credentials not configured") - return { - "status": "error", - "message": "Google Drive service account credentials are not configured" - } - + return {"status": "error", "message": "Google Drive service account credentials are not configured"} + try: service = get_google_drive_service() about = service.about().get(fields="user").execute() - + # For service accounts, try to show the delegated user if available user_email = about.get("user", {}).get("emailAddress", "Unknown") - delegated_user = getattr(settings, 'google_drive_delegate_to', None) - + delegated_user = getattr(settings, "google_drive_delegate_to", None) + if delegated_user: user_display = f"{user_email} (delegating as {delegated_user})" else: user_display = user_email - + logger.info(f"Successfully connected to Google Drive using service account as {user_display}") - + return { "status": "success", "message": f"Service account is valid! Connected as {user_display}", "account": user_email, - "auth_type": "service_account" + "auth_type": "service_account", } except Exception as e: error_msg = str(e) logger.error(f"Google Drive service account test failed: {error_msg}") - return { - "status": "error", - "message": f"Service account validation failed: {error_msg}" - } - + return {"status": "error", "message": f"Service account validation failed: {error_msg}"} + except Exception as e: logger.exception("Unexpected error testing Google Drive token") - return { - "status": "error", - "message": f"Unexpected error: {str(e)}" - } + return {"status": "error", "message": f"Unexpected error: {str(e)}"} + @router.get("/google-drive/get-token-info") @require_login @@ -247,94 +230,88 @@ async def get_google_drive_token_info(request: Request): """ try: logger.info("Getting Google Drive token information") - + # Check if OAuth is enabled and configured - if not getattr(settings, 'google_drive_use_oauth', False): + if not getattr(settings, "google_drive_use_oauth", False): logger.warning("OAuth is not enabled, using service account instead") return { "status": "error", - "message": "OAuth is not enabled. Service accounts don't support user-facing features like folder picker." + "message": "OAuth is not enabled. Service accounts don't support user-facing features.", } - - if not (settings.google_drive_client_id and - settings.google_drive_client_secret and - settings.google_drive_refresh_token): + + if not ( + settings.google_drive_client_id + and settings.google_drive_client_secret + and settings.google_drive_refresh_token + ): logger.warning("Google Drive OAuth credentials not fully configured") - return { - "status": "error", - "message": "Google Drive OAuth credentials are not fully configured" - } - + return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + try: # Get credentials and access token import google.oauth2.credentials from google.auth.transport.requests import Request - + credentials = google.oauth2.credentials.Credentials( token=None, refresh_token=settings.google_drive_refresh_token, token_uri="https://oauth2.googleapis.com/token", client_id=settings.google_drive_client_id, - client_secret=settings.google_drive_client_secret + client_secret=settings.google_drive_client_secret, ) - + # Force a refresh to get a fresh access token if not credentials.valid: credentials.refresh(Request()) - + # Get token expiration info expiration_info = {} - if hasattr(credentials, 'expiry') and credentials.expiry: + if hasattr(credentials, "expiry") and credentials.expiry: now = datetime.now() expiry = credentials.expiry time_left = expiry - now expiration_info = { "expires_at": expiry.isoformat(), "expires_in_seconds": max(0, int(time_left.total_seconds())), - "expires_in_human": format_time_remaining(time_left) + "expires_in_human": format_time_remaining(time_left), } - + # Return the token info logger.info("Successfully retrieved Google Drive access token") - + return { "status": "success", "message": "Access token successfully retrieved", "access_token": credentials.token, - "token_info": expiration_info + "token_info": expiration_info, } except Exception as e: error_msg = str(e) logger.error(f"Failed to get Google Drive token: {error_msg}") - + # Check if this is a token-related error if "invalid_grant" in error_msg.lower() or "token" in error_msg.lower(): return { "status": "error", "message": f"OAuth token retrieval failed: {error_msg}", - "needs_reauth": True + "needs_reauth": True, } - return { - "status": "error", - "message": f"Token retrieval error: {error_msg}" - } - + return {"status": "error", "message": f"Token retrieval error: {error_msg}"} + except Exception as e: logger.exception("Unexpected error getting Google Drive token info") - return { - "status": "error", - "message": f"Unexpected error: {str(e)}" - } + return {"status": "error", "message": f"Unexpected error: {str(e)}"} + def format_time_remaining(time_delta): """Format a timedelta into a human-readable string.""" if time_delta.total_seconds() <= 0: return "Expired" - + days = time_delta.days hours, remainder = divmod(time_delta.seconds, 3600) minutes, seconds = divmod(remainder, 60) - + parts = [] if days > 0: parts.append(f"{days} day{'s' if days != 1 else ''}") @@ -342,9 +319,10 @@ def format_time_remaining(time_delta): parts.append(f"{hours} hour{'s' if hours != 1 else ''}") if minutes > 0 and days == 0: # Only show minutes if less than a day parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") - + return ", ".join(parts) + @router.post("/google-drive/save-settings") @require_login async def save_dropbox_settings( @@ -353,7 +331,7 @@ async def save_dropbox_settings( client_secret: str = Form(None), refresh_token: str = Form(...), folder_id: str = Form(None), - use_oauth: str = Form("true") + use_oauth: str = Form("true"), ): """ Save Google Drive settings to the .env file @@ -361,15 +339,13 @@ async def save_dropbox_settings( try: # Get the path to the .env file env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") - + # Convert use_oauth string to boolean use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") - + # Define settings to update - drive_settings = { - "GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower() - } - + drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()} + # Only update these if provided if use_oauth_bool: if refresh_token: @@ -378,20 +354,20 @@ async def save_dropbox_settings( drive_settings["GOOGLE_DRIVE_CLIENT_ID"] = client_id if client_secret: drive_settings["GOOGLE_DRIVE_CLIENT_SECRET"] = client_secret - + # Always include folder ID if provided if folder_id: drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id - + # Try to update the .env file, but don't fail if it doesn't exist (for Docker containers) if os.path.exists(env_path): try: logger.info(f"Updating Google Drive settings in {env_path}") - + # Read the current .env file with open(env_path, "r") as f: env_lines = f.readlines() - + # Process each line and update or add settings updated = set() new_env_lines = [] @@ -408,22 +384,24 @@ async def save_dropbox_settings( break if not is_updated: new_env_lines.append(line) - + # Add any settings that weren't updated (they weren't in the file) for key, value in drive_settings.items(): if key not in updated: new_env_lines.append(f"{key}={value}") - + # Write the updated .env file with open(env_path, "w") as f: f.write("\n".join(new_env_lines) + "\n") - + logger.info("Successfully updated Google Drive settings in .env file") except Exception as e: logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update") else: - logger.warning(f".env file not found at {env_path}, skipping file update but continuing with in-memory update") - + logger.warning( + f".env file not found at {env_path}, skipping file update but continuing with in-memory update" + ) + # Update the settings in memory (this always happens) if refresh_token: settings.google_drive_refresh_token = refresh_token @@ -433,21 +411,20 @@ async def save_dropbox_settings( settings.google_drive_client_secret = client_secret if folder_id: settings.google_drive_folder_id = folder_id - + # Set OAuth flag settings.google_drive_use_oauth = use_oauth_bool - + logger.info("Successfully updated Google Drive settings in memory") - + return { "status": "success", "message": "Google Drive settings have been saved", - "in_memory_only": not os.path.exists(env_path) + "in_memory_only": not os.path.exists(env_path), } - + except Exception as e: logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to save Google Drive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Google Drive settings: {str(e)}" ) diff --git a/app/api/onedrive.py b/app/api/onedrive.py index 1f160b12..a5a38b94 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -1,13 +1,12 @@ """ OneDrive API endpoints """ + from fastapi import APIRouter, Request, HTTPException, status, Form import logging import os import requests -import json from datetime import datetime, timedelta -from typing import Optional from app.auth import require_login from app.config import settings @@ -18,6 +17,7 @@ logger = logging.getLogger(__name__) router = APIRouter() + @router.post("/onedrive/exchange-token") @require_login async def exchange_onedrive_token( @@ -26,7 +26,7 @@ async def exchange_onedrive_token( client_secret: str = Form(...), redirect_uri: str = Form(...), code: str = Form(...), - tenant_id: str = Form(...) + tenant_id: str = Form(...), ): """ Exchange an authorization code for a refresh token. @@ -34,28 +34,22 @@ async def exchange_onedrive_token( """ # Prepare the token request token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" - + payload = { - 'client_id': client_id, - 'scope': 'https://graph.microsoft.com/.default offline_access', - 'code': code, - 'redirect_uri': redirect_uri, - 'grant_type': 'authorization_code', - 'client_secret': client_secret + "client_id": client_id, + "scope": "https://graph.microsoft.com/.default offline_access", + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "client_secret": client_secret, } - + # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token( - provider_name="OneDrive", - token_url=token_url, - payload=payload - ) - + token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload) + # Return just what's needed by the frontend - return { - "refresh_token": token_data["refresh_token"], - "expires_in": token_data.get("expires_in", 3600) - } + return {"refresh_token": token_data["refresh_token"], "expires_in": token_data.get("expires_in", 3600)} + @router.get("/onedrive/test-token") @require_login @@ -65,133 +59,128 @@ async def test_onedrive_token(request: Request): """ try: logger.info("Testing OneDrive token validity") - - if not settings.onedrive_refresh_token or not settings.onedrive_client_id or not settings.onedrive_client_secret: + + if ( + not settings.onedrive_refresh_token + or not settings.onedrive_client_id + or not settings.onedrive_client_secret + ): logger.warning("OneDrive credentials not fully configured") - return { - "status": "error", - "message": "OneDrive credentials are not fully configured" - } - + return {"status": "error", "message": "OneDrive credentials are not fully configured"} + # Refresh token to get a new access token and expiration info tenant_id = settings.onedrive_tenant_id or "common" token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" - + refresh_data = { "client_id": settings.onedrive_client_id, "client_secret": settings.onedrive_client_secret, "refresh_token": settings.onedrive_refresh_token, "grant_type": "refresh_token", - "scope": "offline_access Files.ReadWrite" + "scope": "offline_access Files.ReadWrite", } - + response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout) - + if response.status_code != 200: logger.error(f"Failed to refresh OneDrive token: {response.text}") - return { - "status": "error", - "message": "Refresh token has expired or is invalid", - "needs_reauth": True - } - + return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + token_data = response.json() access_token = token_data.get("access_token") expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified - + # Check if we got a new refresh token (Microsoft sometimes issues a new one) new_refresh_token = token_data.get("refresh_token") if new_refresh_token and new_refresh_token != settings.onedrive_refresh_token: logger.info("Received new refresh token from Microsoft - will update configuration") - + # Update refresh token in memory settings.onedrive_refresh_token = new_refresh_token - + # Also try to update .env file if it exists try: env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") if os.path.exists(env_path): with open(env_path, "r") as f: env_lines = f.readlines() - + updated_lines = [] updated = False - + for line in env_lines: if line.startswith("ONEDRIVE_REFRESH_TOKEN="): updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n") updated = True else: updated_lines.append(line) - + if not updated: updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n") - + with open(env_path, "w") as f: f.writelines(updated_lines) - + logger.info("Updated refresh token in .env file") - + except Exception as e: logger.warning(f"Failed to update refresh token in .env file: {e}") - + # Test the access token by getting user information user_info_url = "https://graph.microsoft.com/v1.0/me" headers = {"Authorization": f"Bearer {access_token}"} - + user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout) - + if user_response.status_code != 200: logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}") return { "status": "error", - "message": f"Token validation failed with status {user_response.status_code}: {user_response.text}" + "message": f"Token validation failed with status {user_response.status_code}: {user_response.text}", } - + # Get user info user_info = user_response.json() display_name = user_info.get("displayName", "Unknown user") email = user_info.get("userPrincipalName", "Unknown email") - + # Calculate expiration time now = datetime.now() expiry_time = now + timedelta(seconds=expires_in) - + # Format expiration info time_left = expiry_time - now token_info = { "expires_at": expiry_time.isoformat(), "expires_in_seconds": expires_in, "expires_in_human": format_time_remaining(time_left), - "refresh_token_validity": "Refresh token is valid for 90 days of inactivity" + "refresh_token_validity": "Refresh token is valid for 90 days of inactivity", } - + logger.info(f"Successfully connected to OneDrive as {email}") - + return { "status": "success", - "message": f"OneDrive connection successful", + "message": "OneDrive connection successful", "account": email, "account_name": display_name, - "token_info": token_info + "token_info": token_info, } - + except Exception as e: logger.exception(f"Unexpected error testing OneDrive token: {str(e)}") - return { - "status": "error", - "message": f"Connection error: {str(e)}" - } + return {"status": "error", "message": f"Connection error: {str(e)}"} + def format_time_remaining(time_delta): """Format a timedelta into a human-readable string.""" if time_delta.total_seconds() <= 0: return "Expired" - + days = time_delta.days hours, remainder = divmod(time_delta.seconds, 3600) minutes, seconds = divmod(remainder, 60) - + parts = [] if days > 0: parts.append(f"{days} day{'s' if days != 1 else ''}") @@ -199,9 +188,10 @@ def format_time_remaining(time_delta): parts.append(f"{hours} hour{'s' if hours != 1 else ''}") if minutes > 0 and days == 0: # Only show minutes if less than a day parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") - + return ", ".join(parts) + @router.post("/onedrive/save-settings") @require_login async def save_onedrive_settings( @@ -210,7 +200,7 @@ async def save_onedrive_settings( client_secret: str = Form(None), refresh_token: str = Form(...), tenant_id: str = Form("common"), - folder_path: str = Form(None) + folder_path: str = Form(None), ): """ Save OneDrive settings to the .env file @@ -218,25 +208,24 @@ async def save_onedrive_settings( try: # Get the path to the .env file env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") - + if not os.path.exists(env_path): logger.error(f".env file not found at {env_path}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not find .env file to update" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" ) - + logger.info(f"Updating OneDrive settings in {env_path}") - + # Read the current .env file with open(env_path, "r") as f: env_lines = f.readlines() - + # Define settings to update onedrive_settings = { "ONEDRIVE_REFRESH_TOKEN": refresh_token, } - + # Only update these if provided if client_id: onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id @@ -246,7 +235,7 @@ async def save_onedrive_settings( onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id if folder_path: onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path - + # Process each line and update or add settings updated = set() new_env_lines = [] @@ -263,16 +252,16 @@ async def save_onedrive_settings( break if not is_updated: new_env_lines.append(line) - + # Add any settings that weren't updated (they weren't in the file) for key, value in onedrive_settings.items(): if key not in updated: new_env_lines.append(f"{key}={value}") - + # Write the updated .env file with open(env_path, "w") as f: f.write("\n".join(new_env_lines) + "\n") - + # Update the settings in memory if refresh_token: settings.onedrive_refresh_token = refresh_token @@ -284,23 +273,20 @@ async def save_onedrive_settings( settings.onedrive_tenant_id = tenant_id if folder_path: settings.onedrive_folder_path = folder_path - + logger.info("Successfully updated OneDrive settings") - - return { - "status": "success", - "message": "OneDrive settings have been saved" - } - + + return {"status": "success", "message": "OneDrive settings have been saved"} + except HTTPException: raise except Exception as e: logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to save OneDrive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save OneDrive settings: {str(e)}" ) + @router.post("/onedrive/update-settings") @require_login async def update_onedrive_settings( @@ -309,59 +295,54 @@ async def update_onedrive_settings( client_secret: str = Form(None), refresh_token: str = Form(...), tenant_id: str = Form("common"), - folder_path: str = Form(None) + folder_path: str = Form(None), ): """ Update OneDrive settings in memory (without modifying .env file) """ try: logger.info("Updating OneDrive settings in memory") - + # Update settings in memory if refresh_token: settings.onedrive_refresh_token = refresh_token logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory") - + if client_id: settings.onedrive_client_id = client_id logger.info("Updated ONEDRIVE_CLIENT_ID in memory") - + if client_secret: settings.onedrive_client_secret = client_secret logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory") - + if tenant_id: settings.onedrive_tenant_id = tenant_id logger.info("Updated ONEDRIVE_TENANT_ID in memory") - + if folder_path: settings.onedrive_folder_path = folder_path logger.info("Updated ONEDRIVE_FOLDER_PATH in memory") - + # Test the token to make sure it works try: from app.tasks.upload_to_onedrive import get_onedrive_token - access_token = get_onedrive_token() + + get_onedrive_token() # Test that token can be retrieved logger.info("Successfully tested OneDrive token") except Exception as e: logger.error(f"Token test failed after updating settings: {str(e)}") - return { - "status": "warning", - "message": "Settings updated but token test failed: " + str(e) - } - - return { - "status": "success", - "message": "OneDrive settings have been updated in memory" - } - + return {"status": "warning", "message": "Settings updated but token test failed: " + str(e)} + + return {"status": "success", "message": "OneDrive settings have been updated in memory"} + except Exception as e: logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to update OneDrive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update OneDrive settings: {str(e)}" ) + @router.get("/onedrive/get-full-config") @require_login async def get_onedrive_full_config(request: Request): @@ -375,26 +356,21 @@ async def get_onedrive_full_config(request: Request): "client_secret": settings.onedrive_client_secret or "", "tenant_id": settings.onedrive_tenant_id or "common", "refresh_token": settings.onedrive_refresh_token or "", - "folder_path": settings.onedrive_folder_path or "Documents/Uploads" + "folder_path": settings.onedrive_folder_path or "Documents/Uploads", } - + # Generate environment variable format - env_format = "\n".join([ - f"ONEDRIVE_CLIENT_ID={config['client_id']}", - f"ONEDRIVE_CLIENT_SECRET={config['client_secret']}", - f"ONEDRIVE_TENANT_ID={config['tenant_id']}", - f"ONEDRIVE_REFRESH_TOKEN={config['refresh_token']}", - f"ONEDRIVE_FOLDER_PATH={config['folder_path']}" - ]) - - return { - "status": "success", - "config": config, - "env_format": env_format - } + env_format = "\n".join( + [ + f"ONEDRIVE_CLIENT_ID={config['client_id']}", + f"ONEDRIVE_CLIENT_SECRET={config['client_secret']}", + f"ONEDRIVE_TENANT_ID={config['tenant_id']}", + f"ONEDRIVE_REFRESH_TOKEN={config['refresh_token']}", + f"ONEDRIVE_FOLDER_PATH={config['folder_path']}", + ] + ) + + return {"status": "success", "config": config, "env_format": env_format} except Exception as e: logger.exception("Error getting OneDrive configuration") - return { - "status": "error", - "message": str(e) - } + return {"status": "error", "message": str(e)} diff --git a/app/tasks/upload_with_rclone.py b/app/tasks/upload_with_rclone.py index 7cf2c245..c718c9eb 100644 --- a/app/tasks/upload_with_rclone.py +++ b/app/tasks/upload_with_rclone.py @@ -2,21 +2,19 @@ import os import subprocess -import json -import tempfile import logging -from pathlib import Path from app.config import settings from app.tasks.retry_config import BaseTaskWithRetry from app.celery_app import celery logger = logging.getLogger(__name__) + @celery.task(base=BaseTaskWithRetry) def upload_with_rclone(file_path: str, destination: str): """ Uploads a file using rclone to the specified destination. - + Args: file_path: Path to the file to upload destination: Rclone destination in format "remote:path/to/folder" @@ -24,85 +22,63 @@ def upload_with_rclone(file_path: str, destination: str): """ if not os.path.exists(file_path): raise FileNotFoundError(f"File not found: {file_path}") - + # Extract filename filename = os.path.basename(file_path) - + # Validate destination format to prevent command injection if ":" not in destination: raise ValueError(f"Invalid destination format: {destination}. Expected format: remote:path") - + # Split and validate destination components remote, remote_path = destination.split(":", 1) - + # Validate remote name (alphanumeric, underscore, hyphen only) - if not remote or not all(c.isalnum() or c in ('_', '-') for c in remote): + if not remote or not all(c.isalnum() or c in ("_", "-") for c in remote): raise ValueError(f"Invalid remote name: {remote}") - + # Check if rclone is installed and config exists rclone_config_path = os.path.join(settings.workdir, "rclone.conf") if not os.path.exists(rclone_config_path): error_msg = f"Rclone configuration not found at {rclone_config_path}" logger.error(error_msg) raise ValueError(error_msg) - + try: # Ensure the remote path exists (create folders if needed) - mkdir_cmd = [ - "rclone", - "mkdir", - "--config", rclone_config_path, - destination - ] - + mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, destination] + subprocess.run(mkdir_cmd, check=True, capture_output=True) - + # Construct the upload command - upload_cmd = [ - "rclone", - "copy", - "--config", rclone_config_path, - file_path, - destination, - "--progress" - ] - + upload_cmd = ["rclone", "copy", "--config", rclone_config_path, file_path, destination, "--progress"] + # Execute the upload command result = subprocess.run(upload_cmd, check=True, capture_output=True, text=True) - + # Check if upload was successful if result.returncode == 0: # Try to get a public link if possible try: - link_cmd = [ - "rclone", - "link", - "--config", rclone_config_path, - f"{destination}/{filename}" - ] + link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"] link_result = subprocess.run(link_cmd, capture_output=True, text=True) public_url = link_result.stdout.strip() if link_result.returncode == 0 else None except (subprocess.SubprocessError, OSError) as e: logger.warning(f"Failed to get public link for {filename}: {str(e)}") public_url = None - + logger.info(f"Successfully uploaded {filename} to {destination}") - return { - "status": "Completed", - "file": file_path, - "destination": destination, - "public_url": public_url - } + return {"status": "Completed", "file": file_path, "destination": destination, "public_url": public_url} else: error_msg = f"Failed to upload {filename} to {destination}: {result.stderr}" logger.error(error_msg) raise RuntimeError(error_msg) - + except subprocess.CalledProcessError as e: error_msg = f"Rclone error: {e.stderr.decode('utf-8') if hasattr(e.stderr, 'decode') else e.stderr}" logger.error(error_msg) raise RuntimeError(error_msg) from e - + except (OSError, ValueError) as e: error_msg = f"Error uploading {filename} to {destination}: {str(e)}" logger.error(error_msg) @@ -120,55 +96,51 @@ def send_to_all_rclone_destinations(file_path: str): # Extract filename filename = os.path.basename(file_path) - + # Path to rclone config rclone_config_path = os.path.join(settings.workdir, "rclone.conf") if not os.path.exists(rclone_config_path): error_msg = f"Rclone configuration not found at {rclone_config_path}" logger.error(error_msg) raise ValueError(error_msg) - + # Get list of configured destinations from rclone try: remotes_cmd = ["rclone", "listremotes", "--config", rclone_config_path] result = subprocess.run(remotes_cmd, check=True, capture_output=True, text=True) - + if result.returncode == 0: # Process the list of remotes remotes = [r.strip() for r in result.stdout.splitlines() if r.strip()] - + # Target directories for each remote (from settings) remote_paths = {} for remote in remotes: - remote_name = remote.rstrip(':') + remote_name = remote.rstrip(":") path_setting_name = f"rclone_{remote_name}_path" if hasattr(settings, path_setting_name) and getattr(settings, path_setting_name): remote_paths[remote] = getattr(settings, path_setting_name) else: # Default to root of remote if not specified remote_paths[remote] = "" - + # Queue upload tasks for each configured destination results = {} for remote, path in remote_paths.items(): full_destination = f"{remote}{path}" - if path and not path.endswith('/'): - full_destination += '/' - + if path and not path.endswith("/"): + full_destination += "/" + logger.info(f"Queueing {file_path} for upload to {full_destination}") task = upload_with_rclone.delay(file_path, full_destination) results[f"rclone_{remote.rstrip(':')}_task_id"] = task.id - - return { - "status": "Queued", - "file_path": file_path, - "tasks": results - } + + return {"status": "Queued", "file_path": file_path, "tasks": results} else: error_msg = f"Failed to list rclone remotes: {result.stderr}" logger.error(error_msg) raise RuntimeError(error_msg) - + except (subprocess.SubprocessError, OSError) as e: error_msg = f"Error setting up rclone uploads for {filename}: {str(e)}" logger.error(error_msg) diff --git a/app/utils/oauth_helper.py b/app/utils/oauth_helper.py index aef6853b..2aad1ed7 100644 --- a/app/utils/oauth_helper.py +++ b/app/utils/oauth_helper.py @@ -2,8 +2,9 @@ OAuth helper utilities for token exchange operations. Shared across multiple OAuth providers to reduce code duplication. """ + import logging -from typing import Dict, Any, Optional +from typing import Dict, Any import requests from fastapi import HTTPException, status @@ -13,35 +14,32 @@ logger = logging.getLogger(__name__) def exchange_oauth_token( - provider_name: str, - token_url: str, - payload: Dict[str, str], - timeout: int = None + provider_name: str, token_url: str, payload: Dict[str, str], timeout: int = None ) -> Dict[str, Any]: """ Exchange an authorization code for tokens from an OAuth provider. - + This function handles the common OAuth token exchange flow across multiple providers (OneDrive, Google Drive, Dropbox) with proper error handling and secure logging. - + Args: provider_name: Name of the OAuth provider (for logging) token_url: OAuth token endpoint URL payload: Request payload containing client credentials and auth code timeout: Request timeout in seconds (defaults to settings.http_request_timeout) - + Returns: Dict containing the token response from the provider - + Raises: HTTPException: If token exchange fails or response is invalid """ if timeout is None: timeout = settings.http_request_timeout - + try: logger.info(f"Starting {provider_name} token exchange process") - + # SECURITY: Never log sensitive data - only log non-sensitive metadata safe_info = { "provider": provider_name, @@ -49,14 +47,14 @@ def exchange_oauth_token( "grant_type": payload.get("grant_type", "unknown"), } logger.info(f"Token exchange request: {safe_info}") - + # Make the token request logger.info(f"Sending POST request to {provider_name} for token exchange") response = requests.post(token_url, data=payload, timeout=timeout) - + # Check if the request was successful logger.info(f"Token exchange response status: {response.status_code}") - + if response.status_code != 200: # Log the error response for debugging (without sensitive data) try: @@ -68,28 +66,27 @@ def exchange_oauth_token( except Exception as json_err: logger.error(f"Failed to parse error response as JSON: {str(json_err)}") error_detail = {"error": "Unknown error", "status_code": response.status_code} - + raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Token exchange failed: {error_detail}" + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Token exchange failed: {error_detail}" ) - + # Parse the token response token_data = response.json() - + # Validate the token response if "refresh_token" not in token_data: logger.error(f"{provider_name} returned success but no refresh_token found in response") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"{provider_name} OAuth server returned success but no refresh token was included" + detail=f"{provider_name} OAuth server returned success but no refresh token was included", ) - + # Log success with non-sensitive metadata only logger.info(f"Successfully exchanged authorization code for {provider_name} tokens") - + return token_data - + except HTTPException: # Re-raise HTTP exceptions as they already have appropriate status codes raise @@ -97,11 +94,10 @@ def exchange_oauth_token( logger.exception(f"Network error during {provider_name} token exchange: {str(e)}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Failed to connect to {provider_name} OAuth service: {str(e)}" + detail=f"Failed to connect to {provider_name} OAuth service: {str(e)}", ) except Exception as e: logger.exception(f"Unexpected error during {provider_name} token exchange: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to exchange token: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to exchange token: {str(e)}" )