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>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-08 08:29:38 +00:00
parent 551b23a80c
commit d2eb9846d3
6 changed files with 350 additions and 452 deletions
+12 -11
View File
@@ -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)
+66 -90
View File
@@ -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)}"
)
+115 -138
View File
@@ -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)}"
)
+103 -127
View File
@@ -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)}
+32 -60
View File
@@ -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)
+22 -26
View File
@@ -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)}"
)