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