diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 826a06bd..57e32d09 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -49,7 +49,9 @@ async def exchange_dropbox_token( } # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload) + token_data = exchange_oauth_token( + provider_name="Dropbox", token_url=token_url, payload=payload + ) # Return just what's needed by the frontend return { @@ -77,13 +79,19 @@ async def update_dropbox_settings( 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" + 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.dropbox_refresh_token = refresh_token - save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by) + save_setting_to_db( + db, "dropbox_refresh_token", refresh_token, changed_by=changed_by + ) logger.info("Updated DROPBOX_REFRESH_TOKEN in memory and database") if app_key: @@ -93,7 +101,9 @@ async def update_dropbox_settings( if app_secret: settings.dropbox_app_secret = app_secret - save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by) + save_setting_to_db( + db, "dropbox_app_secret", app_secret, changed_by=changed_by + ) logger.info("Updated DROPBOX_APP_SECRET in memory and database") if folder_path: @@ -103,12 +113,16 @@ async def update_dropbox_settings( notify_settings_updated() - return {"status": "success", "message": "Dropbox settings have been updated in memory and saved to database"} + return { + "status": "success", + "message": "Dropbox settings have been updated in memory and saved to database", + } except Exception as e: logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update Dropbox settings: {str(e)}", ) @@ -121,9 +135,16 @@ async def test_dropbox_token(request: Request): try: logger.info("Testing Dropbox token validity") - if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret: + if ( + not settings.dropbox_refresh_token + or not settings.dropbox_app_key + or not settings.dropbox_app_secret + ): logger.warning("Dropbox credentials not fully configured") - return {"status": "error", "message": "Dropbox credentials are not fully configured"} + return { + "status": "error", + "message": "Dropbox credentials are not fully configured", + } # Check token validity by getting current account info headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"} @@ -146,11 +167,19 @@ async def test_dropbox_token(request: Request): "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: - logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}") - return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + logger.error( + f"Failed to refresh Dropbox token: {refresh_response.text}" + ) + return { + "status": "error", + "message": "Refresh token has expired or is invalid", + "needs_reauth": True, + } token_info = refresh_response.json() access_token = token_info.get("access_token") @@ -164,7 +193,9 @@ async def test_dropbox_token(request: Request): ) 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 { "status": "error", "message": f"Token validation failed with status {response.status_code}: {response.text}", @@ -176,7 +207,10 @@ async def test_dropbox_token(request: Request): account_name = account_info.get("name", {}).get("display_name", "Unknown user") # Dropbox refresh tokens don't expire, but we should note that in our response - token_info = {"expires_in_human": "Never expires (perpetual token)", "is_perpetual": True} + token_info = { + "expires_in_human": "Never expires (perpetual token)", + "is_perpetual": True, + } logger.info(f"Successfully connected to Dropbox as {account_email}") @@ -201,65 +235,22 @@ async def save_dropbox_settings( app_key: Annotated[Optional[str], Form()] = None, app_secret: Annotated[Optional[str], Form()] = None, folder_path: Annotated[Optional[str], Form()] = None, + db: Session = Depends(get_db), ): """ - Save Dropbox settings to the .env file + Save Dropbox settings to database (primary) and .env file (best-effort). """ try: - # Get the path to the .env file - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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" + ) - if not os.path.exists(env_path): - logger.error(f".env file not found at {env_path}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" - ) - - logger.info(f"Updating Dropbox settings in {env_path}") - - # Read the current .env file - with open(env_path, "r") as f: - env_lines = f.readlines() - - # Define settings to update - dropbox_settings = { - "DROPBOX_REFRESH_TOKEN": refresh_token, - } - - # Only update these if provided - if app_key: - dropbox_settings["DROPBOX_APP_KEY"] = app_key - if app_secret: - dropbox_settings["DROPBOX_APP_SECRET"] = app_secret - if folder_path: - dropbox_settings["DROPBOX_FOLDER"] = folder_path - - # Process each line and update or add settings - updated = set() - new_env_lines = [] - for line in env_lines: - stripped_line = line.rstrip() - is_updated = False - for key, value in dropbox_settings.items(): - if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): - # Uncomment if commented out - check the original stripped line - new_env_lines.append(f"{key}={value}") - updated.add(key) - is_updated = True - break - if not is_updated: - new_env_lines.append(stripped_line) - - # Add any settings that weren't updated (they weren't in the file) - for key, value in dropbox_settings.items(): - if key not in updated: - new_env_lines.append(f"{key}={value}") - - # Write the updated .env file - with open(env_path, "w") as f: - f.write("\n".join(new_env_lines) + "\n") - - # Update the settings in memory + # Update settings in memory if refresh_token: settings.dropbox_refresh_token = refresh_token if app_key: @@ -269,14 +260,78 @@ async def save_dropbox_settings( if folder_path: settings.dropbox_folder = folder_path - logger.info("Successfully updated Dropbox settings") + # Persist to database (primary storage) + if refresh_token: + save_setting_to_db( + db, "dropbox_refresh_token", refresh_token, changed_by=changed_by + ) + if app_key: + save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by) + if app_secret: + save_setting_to_db( + db, "dropbox_app_secret", app_secret, changed_by=changed_by + ) + if folder_path: + save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by) + # Best-effort .env file write + try: + env_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env" + ) + if not os.path.exists(env_path): + logger.warning( + f".env file not found at {env_path}, skipping file write" + ) + else: + logger.info(f"Updating Dropbox settings in {env_path}") + + with open(env_path, "r") as f: + env_lines = f.readlines() + + dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token} + if app_key: + dropbox_settings["DROPBOX_APP_KEY"] = app_key + if app_secret: + dropbox_settings["DROPBOX_APP_SECRET"] = app_secret + if folder_path: + dropbox_settings["DROPBOX_FOLDER"] = folder_path + + updated = set() + new_env_lines = [] + for line in env_lines: + stripped_line = line.rstrip() + is_updated = False + for key, value in dropbox_settings.items(): + if stripped_line.startswith( + f"{key}=" + ) or stripped_line.startswith(f"# {key}="): + new_env_lines.append(f"{key}={value}") + updated.add(key) + is_updated = True + break + if not is_updated: + new_env_lines.append(stripped_line) + + for key, value in dropbox_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") + + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") + + logger.info("Successfully updated Dropbox settings in .env file") + except Exception as env_err: + logger.warning(f"Failed to write .env file (non-fatal): {env_err}") + + notify_settings_updated() + + logger.info("Successfully saved Dropbox settings") return {"status": "success", "message": "Dropbox settings have been saved"} - except HTTPException: - raise except Exception as e: logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Dropbox settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save Dropbox settings: {str(e)}", ) diff --git a/app/api/google_drive.py b/app/api/google_drive.py index b626a470..54a6028e 100644 --- a/app/api/google_drive.py +++ b/app/api/google_drive.py @@ -7,11 +7,15 @@ import os from datetime import datetime from typing import Annotated, Optional -from fastapi import APIRouter, Form, HTTPException, Request, status +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.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__) @@ -45,7 +49,9 @@ async def exchange_google_drive_token( } # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token(provider_name="Google Drive", token_url=token_url, payload=payload) + token_data = exchange_oauth_token( + provider_name="Google Drive", token_url=token_url, payload=payload + ) # Return just what's needed by the frontend return { @@ -64,38 +70,73 @@ async def update_google_drive_settings( client_secret: Annotated[Optional[str], Form()] = None, folder_id: Annotated[Optional[str], Form()] = None, use_oauth: Annotated[str, Form()] = "true", + db: Session = Depends(get_db), ): """ - Update Google Drive settings in memory + Update Google Drive settings in memory and persist to database """ try: - logger.info("Updating Google Drive settings in memory") + logger.info("Updating Google Drive 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" + ) # Convert use_oauth string to boolean use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") - # Update settings in memory + # Update settings in memory and persist to database if refresh_token: settings.google_drive_refresh_token = refresh_token - logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory") + save_setting_to_db( + db, "google_drive_refresh_token", refresh_token, changed_by=changed_by + ) + logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory and database") if client_id: settings.google_drive_client_id = client_id - logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory") + save_setting_to_db( + db, "google_drive_client_id", client_id, changed_by=changed_by + ) + logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory and database") if client_secret: settings.google_drive_client_secret = client_secret - logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory") + save_setting_to_db( + db, "google_drive_client_secret", client_secret, changed_by=changed_by + ) + logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory and database") if folder_id: settings.google_drive_folder_id = folder_id - logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory") + save_setting_to_db( + db, "google_drive_folder_id", folder_id, changed_by=changed_by + ) + logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory and database") # Set the OAuth flag settings.google_drive_use_oauth = use_oauth_bool - logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory to {use_oauth_bool}") + save_setting_to_db( + db, + "google_drive_use_oauth", + str(use_oauth_bool).lower(), + changed_by=changed_by, + ) + logger.info( + f"Updated GOOGLE_DRIVE_USE_OAUTH in memory and database to {use_oauth_bool}" + ) - return {"status": "success", "message": "Google Drive settings have been updated in memory"} + notify_settings_updated() + + return { + "status": "success", + "message": "Google Drive settings have been updated in memory and database", + } except Exception as e: logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}") @@ -113,7 +154,8 @@ async def test_google_drive_token(request: Request): Tests both OAuth and service account approaches based on configuration. """ 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") @@ -125,7 +167,10 @@ async def test_google_drive_token(request: Request): and settings.google_drive_refresh_token ): logger.warning("Google Drive OAuth credentials not fully configured") - return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + return { + "status": "error", + "message": "Google Drive OAuth credentials are not fully configured", + } try: # Test OAuth connection @@ -187,8 +232,13 @@ async def test_google_drive_token(request: Request): else: # Test service account connection if not settings.google_drive_credentials_json: - logger.warning("Google Drive service account credentials not configured") - return {"status": "error", "message": "Google Drive service account credentials are not configured"} + logger.warning( + "Google Drive service account credentials not configured" + ) + return { + "status": "error", + "message": "Google Drive service account credentials are not configured", + } try: service = get_google_drive_service() @@ -203,7 +253,9 @@ async def test_google_drive_token(request: Request): else: 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 { "status": "success", @@ -214,7 +266,10 @@ async def test_google_drive_token(request: Request): except Exception as e: error_msg = str(e) logger.error(f"Google Drive service account test failed: {error_msg}") - return {"status": "error", "message": f"Service account validation failed: {error_msg}"} + return { + "status": "error", + "message": f"Service account validation failed: {error_msg}", + } except Exception as e: logger.exception("Unexpected error testing Google Drive token") @@ -246,7 +301,10 @@ async def get_google_drive_token_info(request: Request): and settings.google_drive_refresh_token ): logger.warning("Google Drive OAuth credentials not fully configured") - return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"} + return { + "status": "error", + "message": "Google Drive OAuth credentials are not fully configured", + } try: # Get credentials and access token @@ -333,17 +391,29 @@ async def save_dropbox_settings( client_secret: Annotated[Optional[str], Form()] = None, folder_id: Annotated[Optional[str], Form()] = None, use_oauth: Annotated[str, Form()] = "true", + db: Session = Depends(get_db), ): """ - Save Google Drive settings to the .env file + Save Google Drive settings to the .env file (best-effort) and persist to database. """ try: # 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 use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t") + 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" + ) + # Define settings to update drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()} @@ -376,7 +446,9 @@ async def save_dropbox_settings( stripped_line = line.rstrip() is_updated = False for key, value in drive_settings.items(): - if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): + if stripped_line.startswith( + f"{key}=" + ) or stripped_line.startswith(f"# {key}="): # Uncomment if commented out - check the original stripped line new_env_lines.append(f"{key}={value}") updated.add(key) @@ -396,7 +468,9 @@ async def save_dropbox_settings( logger.info("Successfully updated Google Drive settings in .env file") except Exception as e: - logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update") + logger.warning( + f"Failed to update .env file: {str(e)}, but will continue with in-memory update" + ) else: logger.warning( f".env file not found at {env_path}, skipping file update but continuing with in-memory update" @@ -415,7 +489,33 @@ async def save_dropbox_settings( # Set OAuth flag settings.google_drive_use_oauth = use_oauth_bool - logger.info("Successfully updated Google Drive settings in memory") + # Persist to database + save_setting_to_db( + db, + "google_drive_use_oauth", + str(use_oauth_bool).lower(), + changed_by=changed_by, + ) + if refresh_token: + save_setting_to_db( + db, "google_drive_refresh_token", refresh_token, changed_by=changed_by + ) + if client_id: + save_setting_to_db( + db, "google_drive_client_id", client_id, changed_by=changed_by + ) + if client_secret: + save_setting_to_db( + db, "google_drive_client_secret", client_secret, changed_by=changed_by + ) + if folder_id: + save_setting_to_db( + db, "google_drive_folder_id", folder_id, changed_by=changed_by + ) + + notify_settings_updated() + + logger.info("Successfully updated Google Drive settings in memory and database") return { "status": "success", @@ -426,5 +526,6 @@ async def save_dropbox_settings( except Exception as e: logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Google Drive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save Google Drive settings: {str(e)}", ) diff --git a/app/api/onedrive.py b/app/api/onedrive.py index 894a1a44..0329b406 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -8,11 +8,15 @@ from datetime import datetime, timedelta from typing import Annotated, Optional import requests -from fastapi import APIRouter, Form, HTTPException, Request, status +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.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__) @@ -47,10 +51,15 @@ async def exchange_onedrive_token( } # Use shared OAuth helper (handles secure logging and error handling) - token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload) + token_data = exchange_oauth_token( + provider_name="OneDrive", token_url=token_url, payload=payload + ) # Return just what's needed by the frontend - return {"refresh_token": token_data["refresh_token"], "expires_in": token_data.get("expires_in", 3600)} + return { + "refresh_token": token_data["refresh_token"], + "expires_in": token_data.get("expires_in", 3600), + } @router.get("/onedrive/test-token") @@ -68,7 +77,10 @@ async def test_onedrive_token(request: Request): or not settings.onedrive_client_secret ): logger.warning("OneDrive credentials not fully configured") - return {"status": "error", "message": "OneDrive credentials are not fully configured"} + return { + "status": "error", + "message": "OneDrive credentials are not fully configured", + } # Refresh token to get a new access token and expiration info tenant_id = settings.onedrive_tenant_id or "common" @@ -82,27 +94,39 @@ async def test_onedrive_token(request: Request): "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: logger.error(f"Failed to refresh OneDrive token: {response.text}") - return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True} + return { + "status": "error", + "message": "Refresh token has expired or is invalid", + "needs_reauth": True, + } token_data = response.json() access_token = token_data.get("access_token") - expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified + 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") + logger.info( + "Received new refresh token from Microsoft - will update configuration" + ) # Update refresh token in memory settings.onedrive_refresh_token = new_refresh_token # Also try to update .env file if it exists try: - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + env_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env" + ) if os.path.exists(env_path): with open(env_path, "r") as f: env_lines = f.readlines() @@ -112,13 +136,17 @@ async def test_onedrive_token(request: Request): for line in env_lines: 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 else: updated_lines.append(line) 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: f.writelines(updated_lines) @@ -128,14 +156,38 @@ async def test_onedrive_token(request: Request): except Exception as e: logger.warning(f"Failed to update refresh token in .env file: {e}") + # 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}"} - 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: - 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 { "status": "error", "message": f"Token validation failed with status {user_response.status_code}: {user_response.text}", @@ -203,65 +255,72 @@ async def save_onedrive_settings( 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), ): """ - Save OneDrive settings to the .env file + Saves to database (primary) and .env file (best-effort). """ try: - # Get the path to the .env file - env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + 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" + ) - if not os.path.exists(env_path): - logger.error(f".env file not found at {env_path}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update" + # Best-effort .env file write + try: + env_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env" ) + if not os.path.exists(env_path): + logger.warning( + f".env file not found at {env_path}, skipping file write" + ) + else: + logger.info(f"Updating OneDrive settings in {env_path}") - logger.info(f"Updating OneDrive settings in {env_path}") + with open(env_path, "r") as f: + env_lines = f.readlines() - # Read the current .env file - with open(env_path, "r") as f: - env_lines = f.readlines() + onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token} + if client_id: + onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id + if client_secret: + onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret + if tenant_id: + onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id + if folder_path: + onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path - # Define settings to update - onedrive_settings = { - "ONEDRIVE_REFRESH_TOKEN": refresh_token, - } + updated = set() + new_env_lines = [] + for line in env_lines: + stripped_line = line.rstrip() + is_updated = False + for key, value in onedrive_settings.items(): + if stripped_line.startswith( + f"{key}=" + ) or stripped_line.startswith(f"# {key}="): + new_env_lines.append(f"{key}={value}") + updated.add(key) + is_updated = True + break + if not is_updated: + new_env_lines.append(stripped_line) - # Only update these if provided - if client_id: - onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id - if client_secret: - onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret - if tenant_id: - onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id - if folder_path: - onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path + for key, value in onedrive_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") - # Process each line and update or add settings - updated = set() - new_env_lines = [] - for line in env_lines: - stripped_line = line.rstrip() - is_updated = False - for key, value in onedrive_settings.items(): - if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="): - # Uncomment if commented out - check the original stripped line - new_env_lines.append(f"{key}={value}") - updated.add(key) - is_updated = True - break - if not is_updated: - new_env_lines.append(stripped_line) + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") - # Add any settings that weren't updated (they weren't in the file) - for key, value in onedrive_settings.items(): - if key not in updated: - new_env_lines.append(f"{key}={value}") - - # Write the updated .env file - with open(env_path, "w") as f: - f.write("\n".join(new_env_lines) + "\n") + logger.info("Successfully updated OneDrive settings in .env file") + except Exception as env_err: + logger.warning(f"Failed to write .env file (non-fatal): {env_err}") # Update the settings in memory if refresh_token: @@ -275,16 +334,38 @@ async def save_onedrive_settings( if folder_path: settings.onedrive_folder_path = folder_path - logger.info("Successfully updated OneDrive settings") + # Persist to database (primary) + if refresh_token: + save_setting_to_db( + db, "onedrive_refresh_token", refresh_token, changed_by=changed_by + ) + if client_id: + save_setting_to_db( + db, "onedrive_client_id", client_id, changed_by=changed_by + ) + if client_secret: + save_setting_to_db( + db, "onedrive_client_secret", client_secret, changed_by=changed_by + ) + if tenant_id: + save_setting_to_db( + db, "onedrive_tenant_id", tenant_id, changed_by=changed_by + ) + if folder_path: + save_setting_to_db( + db, "onedrive_folder_path", folder_path, changed_by=changed_by + ) + notify_settings_updated() + + logger.info("Successfully saved OneDrive settings") return {"status": "success", "message": "OneDrive settings have been saved"} - except HTTPException: - raise except Exception as e: logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save OneDrive settings: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save OneDrive settings: {str(e)}", ) @@ -297,33 +378,60 @@ async def update_onedrive_settings( 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 (without modifying .env file) + Update OneDrive settings in memory and persist to database """ try: - logger.info("Updating OneDrive settings in memory") + logger.info("Updating OneDrive settings in memory and database") - # Update settings in memory + 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 - logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory") + 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 - logger.info("Updated ONEDRIVE_CLIENT_ID in memory") + 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 - logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory") + 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 - logger.info("Updated ONEDRIVE_TENANT_ID in memory") + 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 - logger.info("Updated ONEDRIVE_FOLDER_PATH in memory") + 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: @@ -333,14 +441,21 @@ async def update_onedrive_settings( 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": "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 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)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update OneDrive settings: {str(e)}", ) diff --git a/app/api/settings.py b/app/api/settings.py index dd82bbb4..a22ac742 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -450,3 +450,61 @@ async def rollback_setting_to_history( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to roll back setting: {key}", ) + + +@router.get("/export-env") +async def export_env_settings( + request: Request, + db: DbSession, + admin: AdminUser, + source: str = "db", +): + """ + Export current settings as a ``.env`` file. + + Query params: + - ``source=db`` (default) – only settings explicitly saved to the database. + - ``source=effective`` – full runtime configuration (DB > ENV > defaults) for + every key defined in SETTING_METADATA. + + Returns a downloadable plain-text file suitable for bootstrapping another + installation. All values — including sensitive ones — are included; only + admins can access this endpoint. + """ + from fastapi.responses import Response as FastAPIResponse + + from app.utils.settings_service import get_settings_for_export + + if source not in ("db", "effective"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="source must be 'db' or 'effective'", + ) + + try: + export_data = get_settings_for_export(db, source=source) + lines = [ + "# DocuElevate configuration export", + f"# Source: {source}", + "# Generated by DocuElevate Settings Export", + "# WARNING: This file contains sensitive values. Handle with care.", + "", + ] + for env_key, value in export_data.items(): + lines.append(f"{env_key}={value}") + lines.append("") # trailing newline + content = "\n".join(lines) + + return FastAPIResponse( + content=content, + media_type="text/plain", + headers={ + "Content-Disposition": f'attachment; filename="docuelevate-{source}.env"' + }, + ) + except Exception as e: + logger.error(f"Error exporting settings: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to export settings", + ) diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 3b6fc85a..b821bcee 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -1287,3 +1287,38 @@ def rollback_setting( ) db.rollback() return False + + +def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]: + """ + Collect settings for export as environment variables. + + Args: + db: Database session + source: ``"db"`` to export only database-persisted settings (default); + ``"effective"`` to export the full current runtime configuration + (DB overrides ENV overrides application defaults) for every key + listed in SETTING_METADATA. + + Returns: + Ordered dict mapping uppercase ENV variable names to their string values. + Sensitive values are included (the caller is responsible for access control). + """ + if source == "effective": + from app.config import settings as app_settings + + db_settings = get_all_settings_from_db(db) + result = {} + for key in sorted(SETTING_METADATA.keys()): + # DB wins, then live settings object (ENV/default) + if key in db_settings and db_settings[key] is not None: + value = db_settings[key] + else: + value = getattr(app_settings, key, None) + if value is not None: + result[key.upper()] = str(value) + return result + else: + # DB only + db_settings = get_all_settings_from_db(db) + return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None} diff --git a/app/views/wizard.py b/app/views/wizard.py index 27696c8c..3105e7c4 100644 --- a/app/views/wizard.py +++ b/app/views/wizard.py @@ -10,6 +10,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.utils.settings_service import save_setting_to_db +from app.utils.settings_sync import notify_settings_updated from app.utils.setup_wizard import get_wizard_steps from app.views.base import APIRouter, get_db, templates @@ -18,7 +19,7 @@ router = APIRouter() @router.get("/setup") -async def setup_wizard(request: Request, step: int = 1): +async def setup_wizard(request: Request, step: int = 1, db: Session = Depends(get_db)): """ Setup wizard for first-time configuration. @@ -39,7 +40,38 @@ async def setup_wizard(request: Request, step: int = 1): current_settings = wizard_steps.get(step, []) # Get step category (all settings in a step should have same category) - step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration" + step_category = ( + current_settings[0].get("wizard_category", "Configuration") + if current_settings + else "Configuration" + ) + + # Enrich settings with current live values + from app.config import settings as app_settings + from app.utils.settings_service import get_setting_from_db + + enriched_settings = [] + for s in current_settings: + key = s["key"] + db_val = get_setting_from_db(db, key) + env_val = getattr(app_settings, key, None) + # Determine current_value and source + if db_val is not None: + current_value = db_val + value_source = "db" + elif env_val is not None and str(env_val).strip(): + current_value = str(env_val) + value_source = "env" + elif s.get("default") is not None: + current_value = s["default"] + value_source = "default" + else: + current_value = "" + value_source = "none" + enriched_settings.append( + {**s, "current_value": current_value, "value_source": value_source} + ) + current_settings = enriched_settings return templates.TemplateResponse( "setup_wizard.html", @@ -50,12 +82,15 @@ async def setup_wizard(request: Request, step: int = 1): "settings": current_settings, "step_category": step_category, "progress_percent": int((step / max_step) * 100), + "setup_skipped": bool(get_setting_from_db(db, "_setup_wizard_skipped")), }, ) @router.post("/setup") -async def setup_wizard_save(request: Request, step: int = Form(...), db: Session = Depends(get_db)): +async def setup_wizard_save( + request: Request, step: int = Form(...), db: Session = Depends(get_db) +): """ Save settings from the current wizard step. """ @@ -87,6 +122,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session logger.info(f"Setup wizard step {step}: Saved {saved_count} settings") + if saved_count > 0: + notify_settings_updated() + # Determine next step max_step = max(wizard_steps.keys()) next_step = step + 1 @@ -100,7 +138,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session except Exception as e: logger.error(f"Error saving wizard settings: {e}") - return RedirectResponse(url=f"/setup?step={step}&error=save_failed", status_code=303) + return RedirectResponse( + url=f"/setup?step={step}&error=save_failed", status_code=303 + ) @router.get("/setup/skip") @@ -122,3 +162,25 @@ async def setup_wizard_skip(request: Request): except Exception as e: logger.error(f"Error skipping setup wizard: {e}") return RedirectResponse(url="/", status_code=303) + + +@router.get("/setup/undo-skip") +async def setup_wizard_undo_skip(request: Request, db: Session = Depends(get_db)): + """ + Undo a previously skipped setup wizard. + + Removes the skip marker from the database so the wizard will be + presented again on next visit to the home page. Redirects to + step 1 of the wizard immediately. + """ + try: + from app.utils.settings_service import delete_setting_from_db + + delete_setting_from_db( + db, "_setup_wizard_skipped", changed_by="wizard_undo_skip" + ) + logger.info("Setup wizard skip marker removed; redirecting to wizard") + return RedirectResponse(url="/setup?step=1", status_code=303) + except Exception as e: + logger.error(f"Error undoing setup wizard skip: {e}") + return RedirectResponse(url="/settings", status_code=303) diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index 495193dd..36afbd72 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -20,10 +20,36 @@ This is a convenience feature to view and edit application settings through the web interface.
- - Audit Log - +📋 Settings Precedence Order:
diff --git a/frontend/templates/setup_wizard.html b/frontend/templates/setup_wizard.html index ed284dda..08d63037 100644 --- a/frontend/templates/setup_wizard.html +++ b/frontend/templates/setup_wizard.html @@ -134,13 +134,21 @@ type="{% if setting.sensitive %}password{% else %}text{% endif %}" id="{{ setting.key }}" name="{{ setting.key }}" - value="{{ setting.default if setting.default else '' }}" + value="{{ setting.current_value if setting.current_value else '' }}" class="wizard-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="{{ setting.description }}" {% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %} /> {% endif %} + {% if setting.value_source == 'db' %} + DB + {% elif setting.value_source == 'env' %} + ENV + {% elif setting.value_source == 'default' %} + DEFAULT + {% endif %} + {% if setting.key == 'admin_password' %}@@ -159,7 +167,12 @@
Fields marked with * are required.
+ {% if setup_skipped %} ++ + You previously skipped the setup wizard. Click here to re-run it. +
+ {% endif %}