diff --git a/.env.demo b/.env.demo index 40575af9..b79dc7aa 100644 --- a/.env.demo +++ b/.env.demo @@ -104,6 +104,8 @@ FTP_PORT=21 FTP_USERNAME=ftp_user FTP_PASSWORD=your_secure_ftp_password FTP_FOLDER=/Documents/Uploads +FTP_USE_TLS=True +FTP_ALLOW_PLAINTEXT=True # SFTP SFTP_HOST=sftp.example.com diff --git a/app/api.py b/app/api.py index 48dae391..17c45f98 100644 --- a/app/api.py +++ b/app/api.py @@ -1,9 +1,11 @@ # app/api.py -from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File +from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File, Form from hashlib import md5 from sqlalchemy.orm import Session from typing import List import os +import requests +import logging from app.auth import require_login, get_current_user from app.database import SessionLocal @@ -14,8 +16,12 @@ from app.tasks.upload_to_dropbox import upload_to_dropbox from app.tasks.upload_to_paperless import upload_to_paperless from app.tasks.upload_to_nextcloud import upload_to_nextcloud from app.tasks.upload_to_google_drive import upload_to_google_drive +from app.tasks.upload_to_onedrive import upload_to_onedrive from app.tasks.send_to_all import send_to_all_destinations +# Set up logging +logger = logging.getLogger(__name__) + router = APIRouter() def get_db(): @@ -186,6 +192,19 @@ def send_to_google_drive(file_path: str): task = upload_to_google_drive.delay(file_path) return {"task_id": task.id, "status": "queued"} +@router.post("/send_to_onedrive/") +@require_login +def send_to_onedrive_endpoint(file_path: str): + """Send a document to OneDrive.""" + if not os.path.isabs(file_path): + file_path = os.path.join(settings.workdir, 'processed', file_path) + if not os.path.exists(file_path): + raise HTTPException( + status_code=400, detail=f"File {file_path} not found." + ) + task = upload_to_onedrive.delay(file_path) + return {"task_id": task.id, "status": "queued"} + @router.post("/send_to_all_destinations/") @require_login def send_to_all_destinations_endpoint(file_path: str): @@ -276,5 +295,353 @@ async def ui_upload(request: Request, file: UploadFile = File(...)): "stored_filename": target_filename } -# Note: The api/router.py is now a submodule organization, -# but we're keeping this file for compatibility until we've fully migrated +@router.post("/onedrive/exchange-token") +@require_login +async def exchange_onedrive_token( + request: Request, + client_id: str = Form(...), + client_secret: str = Form(...), + redirect_uri: str = Form(...), + code: str = Form(...), + tenant_id: str = Form(...) +): + """ + Exchange an authorization code for a refresh token. + This is done on the server to avoid exposing client secret in the browser. + """ + try: + logger.info(f"Starting OneDrive token exchange process with tenant_id: {tenant_id}") + + # Prepare the token request + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + logger.info(f"Using token URL: {token_url}") + + 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 + } + + # Log request details (excluding secret) + safe_payload = payload.copy() + safe_payload['client_secret'] = '[REDACTED]' + safe_payload['code'] = f"{code[:5]}...{code[-5:]}" if len(code) > 10 else '[REDACTED]' + logger.info(f"Token exchange request payload: {safe_payload}") + + # Make the token request + logger.info("Sending POST request to Microsoft for token exchange") + response = requests.post(token_url, data=payload) + + # Check if the request was successful + logger.info(f"Token exchange response status: {response.status_code}") + + if response.status_code != 200: + # Log the error response for debugging + try: + error_json = response.json() + logger.error(f"Token exchange failed with status {response.status_code}: {error_json}") + error_detail = error_json + except Exception as json_err: + logger.error(f"Failed to parse error response as JSON: {str(json_err)}") + logger.error(f"Raw response content: {response.content[:500]}") # Limit log size + error_detail = {"error": "Unknown error", "raw_content_snippet": str(response.content[:100])} + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Token exchange failed: {error_detail}" + ) + + # Return the token response + token_data = response.json() + + # Validate the token response + if "refresh_token" not in token_data: + logger.error(f"Microsoft returned success but no refresh token found in response: {token_data.keys()}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Microsoft OAuth server returned success but no refresh token was included" + ) + + # Calculate token length for logging + refresh_token_length = len(token_data.get("refresh_token", "")) + access_token_length = len(token_data.get("access_token", "")) + + logger.info(f"Successfully exchanged authorization code for OneDrive tokens. " + f"Refresh token length: {refresh_token_length}, " + f"Access token length: {access_token_length}") + + # Return just what's needed by the frontend + return { + "refresh_token": token_data["refresh_token"], + "expires_in": token_data.get("expires_in", 3600) + } + + except HTTPException: + # Re-raise HTTP exceptions as they already have appropriate status codes + raise + except Exception as e: + logger.exception(f"Unexpected error during OneDrive token exchange: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to exchange token: {str(e)}" + ) + +@router.get("/onedrive/test-token") +@require_login +async def test_onedrive_token(request: Request): + """ + Test if the configured OneDrive refresh token is valid. + Provides detailed error information if token is invalid. + """ + try: + from app.tasks.upload_to_onedrive import get_onedrive_token + + logger.info("Testing OneDrive token validity") + if not settings.onedrive_refresh_token: + logger.warning("No OneDrive refresh token configured") + return { + "status": "error", + "message": "No OneDrive refresh token is configured" + } + + # Check if client ID and client secret are configured + if not settings.onedrive_client_id or not settings.onedrive_client_secret: + logger.warning("OneDrive client ID or client secret is missing") + return { + "status": "error", + "message": "OneDrive client ID or client secret is missing", + "missing_config": True + } + + # Try to get an access token using the configured refresh token + try: + access_token = get_onedrive_token() + + # If we got here, token is valid + logger.info("OneDrive token is valid") + return { + "status": "success", + "message": "OneDrive token is valid", + } + except Exception as e: + error_msg = str(e) + logger.error(f"OneDrive token test failed: {error_msg}") + + # Determine if this is an invalid_grant error (expired token) + is_expired = "invalid_grant" in error_msg.lower() + + return { + "status": "error", + "message": f"Token validation failed: {error_msg}", + "is_expired": is_expired, + "needs_reauth": True + } + + except Exception as e: + logger.exception("Unexpected error testing OneDrive token") + return { + "status": "error", + "message": f"Unexpected error: {str(e)}" + } + +@router.post("/onedrive/save-settings") +@require_login +async def save_onedrive_settings( + request: Request, + client_id: str = Form(None), + client_secret: str = Form(None), + refresh_token: str = Form(...), + tenant_id: str = Form("common"), + folder_path: str = Form(None) +): + """ + Save OneDrive settings to the .env file + """ + try: + # Get the path to the .env file + env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env") + + if not os.path.exists(env_path): + logger.error(f".env file not found at {env_path}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not find .env file to update" + ) + + logger.info(f"Updating OneDrive settings in {env_path}") + + # Read the current .env file + with open(env_path, "r") as f: + env_lines = f.readlines() + + # Define settings to update + onedrive_settings = { + "ONEDRIVE_REFRESH_TOKEN": refresh_token, + } + + # Only update these if provided + if client_id: + onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id + 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 + + # Process each line and update or add settings + updated = set() + new_env_lines = [] + for line in env_lines: + line = line.rstrip() + is_updated = False + for key, value in onedrive_settings.items(): + if line.startswith(f"{key}=") or line.startswith(f"# {key}="): + if line.startswith("# "): # Uncomment if commented out + line = line[2:] + new_env_lines.append(f"{key}={value}") + updated.add(key) + is_updated = True + break + if not is_updated: + new_env_lines.append(line) + + # Add any settings that weren't updated (they weren't in the file) + for key, value in onedrive_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") + + # Write the updated .env file + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") + + # Update the settings in memory + if refresh_token: + settings.onedrive_refresh_token = refresh_token + if client_id: + settings.onedrive_client_id = client_id + if client_secret: + settings.onedrive_client_secret = client_secret + if tenant_id: + settings.onedrive_tenant_id = tenant_id + if folder_path: + settings.onedrive_folder_path = folder_path + + logger.info("Successfully updated OneDrive settings") + + return { + "status": "success", + "message": "OneDrive settings have been saved" + } + + 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)}" + ) + +@router.post("/onedrive/update-settings") +@require_login +async def update_onedrive_settings( + request: Request, + client_id: str = Form(None), + client_secret: str = Form(None), + refresh_token: str = Form(...), + tenant_id: str = Form("common"), + folder_path: str = Form(None) +): + """ + Update OneDrive settings in memory (without modifying .env file) + """ + try: + logger.info("Updating OneDrive settings in memory") + + # Update settings in memory + if refresh_token: + settings.onedrive_refresh_token = refresh_token + logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory") + + if client_id: + settings.onedrive_client_id = client_id + logger.info("Updated ONEDRIVE_CLIENT_ID in memory") + + if client_secret: + settings.onedrive_client_secret = client_secret + logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory") + + if tenant_id: + settings.onedrive_tenant_id = tenant_id + logger.info("Updated ONEDRIVE_TENANT_ID in memory") + + if folder_path: + settings.onedrive_folder_path = folder_path + logger.info("Updated ONEDRIVE_FOLDER_PATH in memory") + + # Test the token to make sure it works + try: + from app.tasks.upload_to_onedrive import get_onedrive_token + access_token = get_onedrive_token() + 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" + } + + 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) + } + + diff --git a/app/config.py b/app/config.py index 6bf749db..dba2d608 100644 --- a/app/config.py +++ b/app/config.py @@ -77,6 +77,8 @@ class Settings(BaseSettings): ftp_username: Optional[str] = None ftp_password: Optional[str] = None ftp_folder: Optional[str] = None + ftp_use_tls: bool = True # Default to attempting TLS connection first + ftp_allow_plaintext: bool = True # Default to allowing plaintext fallback # SFTP settings sftp_host: Optional[str] = None diff --git a/app/frontend.py b/app/frontend.py index b4c31867..30ebf69e 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates from pathlib import Path from sqlalchemy.orm import Session from datetime import datetime +import logging from app.auth import require_login from app.database import SessionLocal @@ -15,6 +16,9 @@ router = APIRouter() templates_dir = Path(__file__).parent.parent / "frontend" / "templates" templates = Jinja2Templates(directory=str(templates_dir)) +# Set up logging +logger = logging.getLogger(__name__) + def get_db(): db = SessionLocal() try: @@ -95,5 +99,63 @@ async def env_debug(request: Request): } ) -# Note: The frontend/router.py is now a submodule organization, -# but we're keeping this file for compatibility until we've fully migrated +@router.get("/onedrive-setup") +@require_login +async def onedrive_setup_page(request: Request): + """ + Setup page for the OneDrive integration. + Shows configuration status and setup instructions. + """ + # Check OneDrive configuration + is_configured = bool(settings.onedrive_client_id and + settings.onedrive_client_secret and + settings.onedrive_refresh_token) + + # Get configuration values to display status (hide sensitive values) + return templates.TemplateResponse( + "onedrive.html", + { + "request": request, + "is_configured": is_configured, + "client_id": bool(settings.onedrive_client_id), + "client_id_value": settings.onedrive_client_id or "", # Pass the actual value for the form + "client_secret": bool(settings.onedrive_client_secret), + "client_secret_value": settings.onedrive_client_secret if settings.onedrive_client_secret else "", + "tenant_id": settings.onedrive_tenant_id, + "refresh_token": bool(settings.onedrive_refresh_token), + "refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "", + "folder_path": settings.onedrive_folder_path or "Documents/Uploads" # Default folder path + } + ) + +@router.get("/onedrive-callback") +@require_login +async def onedrive_callback(request: Request, code: str = None, error: str = None): + """ + Callback endpoint for OneDrive OAuth flow. + Now automatically exchanges the code for a token and saves it to the configuration. + """ + if error: + return templates.TemplateResponse( + "onedrive_callback_error.html", + {"request": request, "error": error} + ) + + if not code: + return templates.TemplateResponse( + "onedrive_callback_error.html", + {"request": request, "error": "No authorization code received from Microsoft"} + ) + + # Display the processing page with automatic token exchange + return templates.TemplateResponse( + "onedrive_callback.html", + { + "request": request, + "code": code, + "client_id_value": settings.onedrive_client_id or "", + "client_secret_value": settings.onedrive_client_secret or "", + "tenant_id": settings.onedrive_tenant_id or "common" + } + ) + diff --git a/app/tasks/upload_to_ftp.py b/app/tasks/upload_to_ftp.py index 67f943ce..4bd81964 100644 --- a/app/tasks/upload_to_ftp.py +++ b/app/tasks/upload_to_ftp.py @@ -26,18 +26,66 @@ def upload_to_ftp(file_path: str): raise ValueError(error_msg) try: - # Connect to FTP server - ftp = ftplib.FTP() - ftp.connect( - host=settings.ftp_host, - port=settings.ftp_port or 21 - ) + # First attempt FTPS (FTP with TLS) + use_tls = getattr(settings, 'ftp_use_tls', True) # Default to try TLS + allow_plaintext = getattr(settings, 'ftp_allow_plaintext', True) # Default to allow plaintext fallback - # Login with credentials - ftp.login( - user=settings.ftp_username, - passwd=settings.ftp_password - ) + if use_tls: + try: + logger.info(f"Attempting FTPS connection to {settings.ftp_host}") + ftp = ftplib.FTP_TLS() + ftp.connect( + host=settings.ftp_host, + port=settings.ftp_port or 21 + ) + + # Login with credentials + ftp.login( + user=settings.ftp_username, + passwd=settings.ftp_password + ) + + # Enable data protection - encrypt the data channel + ftp.prot_p() + logger.info("Successfully established FTPS connection with TLS") + except Exception as e: + if not allow_plaintext: + error_msg = f"FTPS connection failed and plaintext FTP is forbidden: {str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + else: + logger.warning(f"FTPS connection failed, falling back to regular FTP: {str(e)}") + # Fall back to regular FTP + ftp = ftplib.FTP() + ftp.connect( + host=settings.ftp_host, + port=settings.ftp_port or 21 + ) + + # Login with credentials + ftp.login( + user=settings.ftp_username, + passwd=settings.ftp_password + ) + else: + # Check if plaintext is allowed when TLS is explicitly disabled + if not allow_plaintext: + error_msg = "Plaintext FTP is forbidden by configuration" + logger.error(error_msg) + raise Exception(error_msg) + + # Directly use regular FTP if TLS is explicitly disabled + ftp = ftplib.FTP() + ftp.connect( + host=settings.ftp_host, + port=settings.ftp_port or 21 + ) + + # Login with credentials + ftp.login( + user=settings.ftp_username, + passwd=settings.ftp_password + ) # Change to target directory if specified if settings.ftp_folder: @@ -80,7 +128,8 @@ def upload_to_ftp(file_path: str): "status": "Completed", "file": file_path, "ftp_host": settings.ftp_host, - "ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename + "ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename, + "used_tls": isinstance(ftp, ftplib.FTP_TLS) } except Exception as e: diff --git a/app/tasks/upload_to_onedrive.py b/app/tasks/upload_to_onedrive.py index 7d926766..73caa6bc 100644 --- a/app/tasks/upload_to_onedrive.py +++ b/app/tasks/upload_to_onedrive.py @@ -21,21 +21,52 @@ def get_onedrive_token(): if not settings.onedrive_client_id or not settings.onedrive_client_secret: raise ValueError("OneDrive client ID and client secret must be configured") + # Log more details about the configuration + tenant = settings.onedrive_tenant_id or "common" + logger.info(f"Using OneDrive tenant: {tenant}") + + # Define scopes consistently + scopes = ["https://graph.microsoft.com/.default"] + # Use refresh token flow (works for both personal and org accounts) if settings.onedrive_refresh_token: - # Use MSAL to get token from refresh token - app = msal.PublicClientApplication(settings.onedrive_client_id) + # Use MSAL's ConfidentialClientApplication instead of PublicClientApplication + app = msal.ConfidentialClientApplication( + client_id=settings.onedrive_client_id, + client_credential=settings.onedrive_client_secret, + authority=f"https://login.microsoftonline.com/{tenant}" + ) # Request new token using refresh token + logger.info("Attempting to acquire token using refresh token") token_response = app.acquire_token_by_refresh_token( refresh_token=settings.onedrive_refresh_token, - scopes=["https://graph.microsoft.com/Files.ReadWrite"] + scopes=scopes ) if "access_token" not in token_response: error = token_response.get("error", "") error_desc = token_response.get("error_description", "Unknown error") + + # Log more details about the error + logger.error(f"Failed to get access token using refresh token") + logger.error(f"Error code: {error}") + logger.error(f"Error description: {error_desc}") + + if error == "invalid_grant": + logger.error("The refresh token appears to be expired or revoked") + logger.error("A new authorization flow is required to obtain a fresh token") + raise ValueError(f"Failed to get access token: {error} - {error_desc}") + + # Check if we received a new refresh token and update it + if "refresh_token" in token_response: + new_refresh_token = token_response["refresh_token"] + logger.info("Received new refresh token from Microsoft") + + # Update the refresh token in memory + settings.onedrive_refresh_token = new_refresh_token + logger.info("Updated refresh token in memory") return token_response["access_token"] @@ -50,7 +81,7 @@ def get_onedrive_token(): # Acquire token for application token_response = app.acquire_token_for_client( - scopes=["https://graph.microsoft.com/.default"] + scopes=scopes ) if "access_token" not in token_response: diff --git a/app/utils/config_validator.py b/app/utils/config_validator.py index 27019e55..6eb453d2 100644 --- a/app/utils/config_validator.py +++ b/app/utils/config_validator.py @@ -238,6 +238,21 @@ def get_provider_status(): "folder": getattr(settings, 'webdav_folder', 'Not set') } } + + # Add FTP configuration to providers + providers["FTP Storage"] = { + "name": "FTP Storage", + "configured": bool(getattr(settings, 'ftp_host', None) and + getattr(settings, 'ftp_username', None) and + getattr(settings, 'ftp_password', None)), + "enabled": True, + "details": { + "host": getattr(settings, 'ftp_host', 'Not set'), + "folder": getattr(settings, 'ftp_folder', 'Not set'), + "tls": getattr(settings, 'ftp_use_tls', True), + "allow_plaintext": getattr(settings, 'ftp_allow_plaintext', True) + } + } return providers @@ -363,7 +378,9 @@ def get_settings_for_display(show_values=False): "ftp_port", "ftp_username", "ftp_password", - "ftp_folder" + "ftp_folder", + "ftp_use_tls", + "ftp_allow_plaintext" ], "S3/AWS": [ "aws_access_key_id", diff --git a/docker-compose.yaml b/docker-compose.yaml index f919a50f..d7faf546 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,6 +4,7 @@ services: context: . dockerfile: Dockerfile container_name: document_api + restart: always # We'll keep the code in /app, but set working_dir to the shared data directory working_dir: /workdir @@ -34,6 +35,7 @@ services: context: . dockerfile: Dockerfile container_name: document_worker + restart: always # same shared working directory working_dir: /workdir @@ -55,6 +57,7 @@ services: gotenberg: image: gotenberg/gotenberg:latest container_name: gotenberg + restart: always redis: diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index bcdfa90a..c2313199 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -102,6 +102,8 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS | `FTP_USERNAME` | FTP authentication username. | | `FTP_PASSWORD` | FTP authentication password. | | `FTP_FOLDER` | Destination folder on FTP server (e.g. `"/Documents/Uploads"`). | +| `FTP_USE_TLS` | Try to use FTPS with TLS encryption first (default: `True`). | +| `FTP_ALLOW_PLAINTEXT` | Allow fallback to plaintext FTP if TLS fails (default: `True`). | ### SFTP @@ -235,6 +237,8 @@ FTP_PORT=21 FTP_USERNAME=username FTP_PASSWORD=password FTP_FOLDER=/Documents/Uploads +FTP_USE_TLS=True +FTP_ALLOW_PLAINTEXT=True # SFTP SFTP_HOST=sftp.example.com diff --git a/docs/OneDriveSetup.md b/docs/OneDriveSetup.md index 2bcb7318..d2b93821 100644 --- a/docs/OneDriveSetup.md +++ b/docs/OneDriveSetup.md @@ -14,6 +14,27 @@ This guide explains how to set up the Microsoft OneDrive integration for DocuNov For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md). +## Setup Methods + +You can set up OneDrive integration in two ways: + +1. **Using the Auth Wizard (Recommended)**: An interactive setup experience available at `/onedrive-setup` in the web interface +2. **Manual Setup**: Following the step-by-step instructions in this document + +## Using the Auth Wizard + +The easiest way to set up OneDrive integration is to use the built-in auth wizard: + +1. Register an application in Azure AD (see steps below) +2. Navigate to the OneDrive Setup page at `/onedrive-setup` +3. Enter your Client ID and other required information +4. Click "Start Authentication Flow" +5. Complete the Microsoft authentication process +6. The system will automatically exchange the authorization code for a refresh token +7. Copy the generated environment variables for your worker nodes + +The auth wizard handles all the token exchange steps and provides you with the exact configuration needed for your environment. + ## Types of Microsoft Accounts There are two main types of Microsoft accounts that can be used with OneDrive integration: @@ -34,7 +55,9 @@ The setup process differs slightly based on which account type you're using. 5. For "Supported account types", select the appropriate option: - For personal accounts: "Accounts in any organizational directory and personal Microsoft accounts" - For corporate accounts only: "Accounts in this organizational directory only" -6. For Redirect URI, select "Web" and enter a URL you can access (e.g., `http://localhost:8000/auth/callback`) +6. For Redirect URI, select "Web" and enter your callback URL: + - For auth wizard: `https://your-domain.com/onedrive-callback` + - For manual setup: any URL you can access (e.g., `http://localhost:8000/auth/callback`) 7. Click "Register" ### 2. Get Application (client) ID @@ -50,6 +73,21 @@ The setup process differs slightly based on which account type you're using. 4. Click "Add" and immediately copy the secret value (it will only be shown once) 5. Set this value as `ONEDRIVE_CLIENT_SECRET` +### 4. Configure API permissions + +1. In your application page, go to "API permissions" +2. Click "Add a permission" +3. Select "Microsoft Graph" > "Delegated permissions" +4. Search for and add the following permission: + - `Files.ReadWrite` (Allows the app to read and write files that the user has access to) + - `offline_access` (Required for refresh tokens) +5. If using application permissions (for service accounts), add: + - `Files.ReadWrite.All` (Required for app-only access) +6. Click "Add permissions" +7. For organizational accounts, you may need an admin to "Grant admin consent" + +> **Important Scope Change**: The system now uses the `.default` scope during authentication, which requests all permissions that have been granted to the application. This ensures that the app has all required permissions at once, rather than requesting each scope individually. + ## For Personal Microsoft Accounts If you're using a personal Microsoft account (@outlook.com, @hotmail.com, or personal accounts linked to other emails): @@ -58,34 +96,31 @@ If you're using a personal Microsoft account (@outlook.com, @hotmail.com, or per - Set `ONEDRIVE_TENANT_ID=common` in your configuration -### 2. Configure API permissions +### 2. Generate a Refresh Token -1. In your application page, go to "API permissions" -2. Click "Add a permission" -3. Select "Microsoft Graph" > "Delegated permissions" -4. Search for and add the following permissions: - - `Files.ReadWrite` (Allows the app to read and write files that the user has access to) - - `offline_access` (Needed for refresh tokens) -5. Click "Add permissions" - -### 3. Generate a Refresh Token +#### Using the Auth Wizard (Recommended) +1. Navigate to the OneDrive Setup page at `/onedrive-setup` +2. Enter your Client ID and leave Tenant ID as "common" +3. Click "Start Authentication Flow" and follow the prompts +4. The wizard will handle token exchange automatically +#### Manual Method 1. Use the following URL (replace CLIENT_ID and REDIRECT_URI with your values): ``` - https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=offline_access%20Files.ReadWrite + https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=https://graph.microsoft.com/.default offline_access&prompt=consent ``` 2. Open this URL in your browser 3. Sign in with your personal Microsoft account 4. After authentication, you'll be redirected to your redirect URI with a code parameter in the URL 5. Copy the code value from the URL (everything after "code=") -### 4. Exchange Code for Refresh Token +### 3. Exchange Code for Refresh Token (Manual Method Only) 1. Use the following command to exchange the code for tokens: ```bash curl -X POST https://login.microsoftonline.com/common/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "client_id=YOUR_CLIENT_ID&scope=offline_access Files.ReadWrite&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET" + -d "client_id=YOUR_CLIENT_ID&scope=https://graph.microsoft.com/.default offline_access&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET" ``` 2. From the response JSON, copy the `refresh_token` value 3. Set this as `ONEDRIVE_REFRESH_TOKEN` in your configuration @@ -104,10 +139,11 @@ If you're using a work/school account provided by your organization: **Option A: Access your own OneDrive (Interactive Login)** -This option requires a refresh token just like personal accounts: -1. Follow the same steps as for personal accounts, but use your work email to sign in -2. Make sure to set `ONEDRIVE_TENANT_ID` to your organization's tenant ID instead of "common" -3. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN` +This option requires a refresh token: +1. Use the auth wizard with your tenant ID, or +2. Follow the same manual steps as for personal accounts, but use your work email to sign in +3. Make sure to set `ONEDRIVE_TENANT_ID` to your organization's tenant ID instead of "common" +4. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN` **Option B: Access OneDrive as a system service (App-only access)** @@ -131,8 +167,13 @@ If you encounter errors during authentication: - For corporate accounts, ensure an admin has consented to the permissions 3. **Refresh token expired**: - - If uploads stop working, you may need to generate a new refresh token - - Repeat the process to get a new authorization code and refresh token + - If uploads stop working, you can generate a new refresh token using the auth wizard + - Click on "Refresh Token" in the OneDrive setup page + +4. **Scope issues**: + - If you see permission errors, make sure your app has the correct permissions added + - The `.default` scope is now used, which includes all permissions granted to the app + - Check that both `Files.ReadWrite` and `offline_access` permissions are added to your app ## Configuration Examples diff --git a/frontend/templates/base.html b/frontend/templates/base.html index b9d7002a..78918304 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -31,15 +31,54 @@ -
+ + + +
+ +
@@ -55,6 +94,8 @@ + + diff --git a/frontend/templates/onedrive.html b/frontend/templates/onedrive.html new file mode 100644 index 00000000..cdd8ff9f --- /dev/null +++ b/frontend/templates/onedrive.html @@ -0,0 +1,321 @@ +{% extends "base.html" %} +{% block title %}OneDrive Setup{% endblock %} + +{% block content %} +
+
+

OneDrive Integration Setup

+

+ Configure the Microsoft OneDrive integration for DocuNova using our setup wizard. +

+ + + +
+
+
+ + + +
+
+

+ Note: You only need to create an Azure application and get credentials. The setup wizard will handle the rest automatically. +

+
+
+
+
+ +
+

Quick Setup Guide

+ +
+

Step 1: Register an Azure Application

+
    +
  1. Go to the Azure Portal
  2. +
  3. Navigate to "Azure Active Directory" > "App registrations"
  4. +
  5. Click "New registration"
  6. +
  7. Enter a name for your application (e.g., "DocuNova")
  8. +
  9. For "Supported account types", select: +
    + "Accounts in any organizational directory and personal Microsoft accounts" +
    +
  10. +
  11. For Redirect URI: +
      +
    • Select "Web"
    • +
    • Enter: {{ request.url.scheme }}://{{ request.url.netloc }}/onedrive-callback
    • +
    +
  12. +
  13. Click "Register"
  14. +
+
+ +
+

Step 2: Configure API Permissions

+
    +
  1. In your application page, go to "API permissions"
  2. +
  3. Click "Add a permission"
  4. +
  5. Select "Microsoft Graph" > "Delegated permissions"
  6. +
  7. Search for and add these permissions: +
      +
    • Files.ReadWrite
    • +
    • offline_access
    • +
    +
  8. +
  9. Click "Add permissions"
  10. +
+
+ +
+

Step 3: Get Client ID and Create Secret

+
    +
  1. Copy the "Application (client) ID" from the overview page
  2. +
  3. Go to "Certificates & secrets"
  4. +
  5. Under "Client secrets", click "New client secret"
  6. +
  7. Add a description and select an expiration period
  8. +
  9. Click "Add" and immediately copy the secret value (it will only be shown once)
  10. +
+
+
+ +
+

Complete Setup with Wizard

+ +
+
+ + +
+ +
+ + +
+ +
+ + +

Use "common" for personal accounts or your organization's Tenant ID for corporate accounts

+
+ +
+ +
+ + +
+
+
+
+ {% if is_configured %} + + {% else %} + + {% endif %} +
+
+

+ {% if is_configured %} + OneDrive integration is properly configured! Your refresh token is valid. + {% else %} + OneDrive integration is not completely configured or token may be invalid. + {% endif %} +

+
+
+
+ +
+ + +
+ + +
+

Configuration for Worker Nodes

+

+ Copy these environment variables to configure all worker nodes: +

+ +
+
ONEDRIVE_CLIENT_ID={{ client_id_value }}
+ONEDRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }}
+ONEDRIVE_TENANT_ID={{ tenant_id }}
+ONEDRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
+ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}
+ + +
+ +

+ Add these variables to your .env file or environment configuration. +

+
+
+
+
+ +
+
+
+ + + +
+
+

Need More Information?

+

+ For detailed instructions and troubleshooting, refer to the + OneDrive Setup Documentation. +

+
+
+
+ +
+ + Back to Status + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/frontend/templates/onedrive_callback.html b/frontend/templates/onedrive_callback.html new file mode 100644 index 00000000..4f827487 --- /dev/null +++ b/frontend/templates/onedrive_callback.html @@ -0,0 +1,215 @@ +{% extends "base.html" %} +{% block title %}OneDrive Authorization Processing{% endblock %} + +{% block content %} +
+
+
+ + + +

Processing Authorization

+

Please wait while we complete the OneDrive authorization process...

+
+ +
+
+
+ +
+

Exchanging authorization code for refresh token...

+
+ + + + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/frontend/templates/onedrive_callback_error.html b/frontend/templates/onedrive_callback_error.html new file mode 100644 index 00000000..5d973b5c --- /dev/null +++ b/frontend/templates/onedrive_callback_error.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}OneDrive Authorization Error{% endblock %} + +{% block content %} +
+
+
+ + + +

Authorization Failed

+

Sorry, we couldn't complete the OneDrive authorization.

+
+ +
+
+
+
+ +
+
+

Error details:

+
+

{{ error }}

+
+
+
+
+
+ + +
+
+{% endblock %} diff --git a/frontend/templates/status_dashboard.html b/frontend/templates/status_dashboard.html index b238a664..bcf00494 100644 --- a/frontend/templates/status_dashboard.html +++ b/frontend/templates/status_dashboard.html @@ -43,9 +43,11 @@ {% elif provider.icon == "google" %} {% elif provider.icon == "microsoft" %} - + {% elif provider.icon == "activity" %} + {% elif provider.icon == "ftp" %} + {% else %} {% endif %}