Files

476 lines
17 KiB
Python

"""
OneDrive API endpoints
"""
import logging
from datetime import datetime, timedelta
from typing import Annotated, Optional
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.env_utils import update_env_file
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
@router.post("/onedrive/exchange-token")
@require_login
async def exchange_onedrive_token(
request: Request,
client_id: Annotated[str, Form(...)],
client_secret: Annotated[str, Form(...)],
redirect_uri: Annotated[str, Form(...)],
code: Annotated[str, Form(...)],
tenant_id: Annotated[str, Form(...)],
):
"""
Exchange an authorization code for a refresh token.
This is done on the server to avoid exposing client secret in the browser.
"""
# 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,
}
# Use shared OAuth helper (handles secure logging and error handling)
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"],
"access_token": token_data.get("access_token", ""),
"expires_in": token_data.get("expires_in", 3600),
}
@router.get("/onedrive/test-token")
@require_login
async def test_onedrive_token(request: Request):
"""
Test if the configured OneDrive token is valid and return expiration information.
"""
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
):
logger.warning("OneDrive credentials 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",
}
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
response = await client.post(token_url, data=refresh_data)
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,
}
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
update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
# Persist the rotated refresh token to the database
try:
from app.database import SessionLocal
_db = SessionLocal()
try:
save_setting_to_db(
_db,
"onedrive_refresh_token",
new_refresh_token,
changed_by="onedrive_token_rotation",
)
notify_settings_updated()
finally:
_db.close()
except Exception as _e:
logger.warning(f"Failed to persist rotated OneDrive refresh token to database: {_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}"}
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
user_response = await client.get(user_info_url, headers=headers)
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}",
}
# 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",
}
logger.info(f"Successfully connected to OneDrive as {email}")
return {
"status": "success",
"message": "OneDrive connection successful",
"account": email,
"account_name": display_name,
"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)}"}
@router.post("/onedrive/list-folders")
@require_login
async def list_onedrive_folders(
request: Request,
access_token: Annotated[str, Form(...)],
path: Annotated[str, Form()] = "",
):
"""
List folders in a OneDrive account for the directory selector.
Accepts an OAuth access token (short-lived) and a path to list.
Returns a flat list of folder entries under the given path.
"""
try:
folder_path = path.strip().strip("/")
headers = {
"Authorization": f"Bearer {access_token}",
}
# Build the Graph API URL for listing children
if not folder_path or folder_path == "root":
url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
else:
url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children"
# Only request folders and minimal fields
params = {
"$filter": "folder ne null",
"$select": "name,id,parentReference,folder",
"$top": "200",
}
response = requests.get(
url,
headers=headers,
params=params,
timeout=settings.http_request_timeout,
)
if response.status_code == 401:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token is invalid or expired. Please re-authorize.",
)
if response.status_code != 200:
logger.error(f"OneDrive list children failed: {response.status_code} {response.text}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to list OneDrive folders: {response.text}",
)
data = response.json()
folders = []
for item in data.get("value", []):
if "folder" in item:
parent_path = ""
if item.get("parentReference", {}).get("path"):
# parentReference.path looks like /drive/root:/some/path
raw_parent = item["parentReference"]["path"]
prefix = "/drive/root:"
if raw_parent.startswith(prefix):
parent_path = raw_parent[len(prefix) :]
elif raw_parent == "/drive/root":
parent_path = ""
item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}"
folders.append(
{
"name": item["name"],
"path": item_path,
"id": item.get("id", ""),
"child_count": item.get("folder", {}).get("childCount", 0),
}
)
# Sort folders alphabetically
folders.sort(key=lambda f: f["name"].lower())
return {
"folders": folders,
"path": f"/{folder_path}" if folder_path else "/",
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error listing OneDrive folders: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list folders: {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 ''}")
if hours > 0:
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")
async def save_onedrive_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
_admin: AdminUser,
db: Session = Depends(get_db),
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
):
"""
Saves to database (primary) and .env file (best-effort).
"""
try:
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
)
# Build settings dictionary mapped to database/memory keys
onedrive_settings = {
"onedrive_refresh_token": refresh_token,
"onedrive_client_id": client_id,
"onedrive_client_secret": client_secret,
"onedrive_tenant_id": tenant_id,
"onedrive_folder_path": folder_path,
}
# Filter out None values
onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None}
# Best-effort .env file write using the new utility
env_settings = {k.upper(): v for k, v in onedrive_settings.items()}
update_env_file(env_settings)
# Update in-memory settings and persist to database dynamically
for key, value in onedrive_settings.items():
setattr(settings, key, value)
save_setting_to_db(db, key, value, changed_by=changed_by)
notify_settings_updated()
logger.info("Successfully saved OneDrive settings")
return {"status": "success", "message": "OneDrive settings have been saved"}
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)}",
)
@router.post("/onedrive/update-settings")
@require_login
async def update_onedrive_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
client_id: Annotated[Optional[str], Form()] = None,
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Update OneDrive settings in memory and persist to database
"""
try:
logger.info("Updating OneDrive settings in memory and database")
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
)
# Update settings in memory and persist to database
if refresh_token:
settings.onedrive_refresh_token = refresh_token
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory and database")
if client_id:
settings.onedrive_client_id = client_id
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
logger.info("Updated ONEDRIVE_CLIENT_ID in memory and database")
if client_secret:
settings.onedrive_client_secret = client_secret
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory and database")
if tenant_id:
settings.onedrive_tenant_id = tenant_id
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
logger.info("Updated ONEDRIVE_TENANT_ID in memory and database")
if folder_path:
settings.onedrive_folder_path = folder_path
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory and database")
notify_settings_updated()
# Test the token to make sure it works
try:
from app.tasks.upload_to_onedrive import 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 and database",
}
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)}",
)
@router.get("/onedrive/get-full-config")
@require_login
async def get_onedrive_full_config(request: Request):
"""
Get the full OneDrive configuration for sharing with worker nodes
"""
try:
# Create a configuration object with all OneDrive settings
config = {
"client_id": settings.onedrive_client_id or "",
"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",
}
# 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}
except Exception as e:
logger.exception("Error getting OneDrive configuration")
return {"status": "error", "message": str(e)}