From 94889e9e41ae73194e7c4d9bdb8fbae4cd763dcf Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Thu, 3 Apr 2025 10:35:55 +0200 Subject: [PATCH 1/2] feat: implement Dropbox integration with OAuth setup and error handling --- app/api.py | 293 +++++++++++++ app/frontend.py | 57 +++ app/tasks/process_document.py | 2 +- app/tasks/refine_text_with_gpt.py | 2 +- app/tasks/upload_to_dropbox.py | 51 ++- app/utils/config_validator.py | 9 +- docs/setup-guides/dropbox-setup.md | 0 frontend/templates/base.html | 1 + frontend/templates/dropbox.html | 406 ++++++++++++++++++ frontend/templates/dropbox_callback.html | 227 ++++++++++ .../templates/dropbox_callback_error.html | 40 ++ requirements.txt | 2 +- 12 files changed, 1079 insertions(+), 11 deletions(-) delete mode 100644 docs/setup-guides/dropbox-setup.md create mode 100644 frontend/templates/dropbox.html create mode 100644 frontend/templates/dropbox_callback.html create mode 100644 frontend/templates/dropbox_callback_error.html diff --git a/app/api.py b/app/api.py index 17c45f98..7dc90c7f 100644 --- a/app/api.py +++ b/app/api.py @@ -644,4 +644,297 @@ async def get_onedrive_full_config(request: Request): "message": str(e) } +@router.post("/dropbox/exchange-token") +@require_login +async def exchange_dropbox_token( + request: Request, + client_id: str = Form(...), + client_secret: str = Form(...), + redirect_uri: str = Form(...), + code: str = Form(...), + folder_path: str = Form(None) +): + """ + Exchange an authorization code for a refresh token from Dropbox. + This is done on the server to avoid exposing client secret in the browser. + """ + try: + logger.info("Starting Dropbox token exchange process") + + # Prepare the token request + token_url = "https://api.dropboxapi.com/oauth2/token" + + payload = { + 'client_id': client_id, + 'client_secret': client_secret, + 'code': code, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code' + } + + # 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 Dropbox 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"Dropbox returned success but no refresh token found in response: {token_data.keys()}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Dropbox 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 Dropbox 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"], + "access_token": token_data["access_token"], + "expires_in": token_data.get("expires_in", 14400) + } + + except HTTPException: + # Re-raise HTTP exceptions as they already have appropriate status codes + raise + except Exception as e: + logger.exception(f"Unexpected error during Dropbox token exchange: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to exchange token: {str(e)}" + ) + +@router.post("/dropbox/update-settings") +@require_login +async def update_dropbox_settings( + request: Request, + app_key: str = Form(None), + app_secret: str = Form(None), + refresh_token: str = Form(...), + folder_path: str = Form(None) +): + """ + Update Dropbox settings in memory + """ + try: + logger.info("Updating Dropbox settings in memory") + + # Update settings in memory + if refresh_token: + settings.dropbox_refresh_token = refresh_token + logger.info("Updated DROPBOX_REFRESH_TOKEN in memory") + + if app_key: + settings.dropbox_app_key = app_key + logger.info("Updated DROPBOX_APP_KEY in memory") + + if app_secret: + settings.dropbox_app_secret = app_secret + logger.info("Updated DROPBOX_APP_SECRET in memory") + + if folder_path: + settings.dropbox_folder = folder_path + logger.info("Updated DROPBOX_FOLDER in memory") + + # Test token validity would be here, but we'll skip it for now + + return { + "status": "success", + "message": "Dropbox settings have been updated in memory" + } + + 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)}" + ) + +@router.get("/dropbox/test-token") +@require_login +async def test_dropbox_token(request: Request): + """ + Test if the configured Dropbox refresh token is valid. + """ + try: + from app.tasks.upload_to_dropbox import get_dropbox_client + + logger.info("Testing Dropbox token validity") + if not settings.dropbox_refresh_token: + logger.warning("No Dropbox refresh token configured") + return { + "status": "error", + "message": "No Dropbox refresh token is configured" + } + + # Check if app key and app secret are configured + if not settings.dropbox_app_key or not settings.dropbox_app_secret: + logger.warning("Dropbox app key or app secret is missing") + return { + "status": "error", + "message": "Dropbox app key or app secret is missing", + "missing_config": True + } + + # Try to get a client using the configured refresh token + try: + dbx = get_dropbox_client() + # Test connection by getting account info + account = dbx.users_get_current_account() + logger.info(f"Successfully connected to Dropbox as {account.name.display_name}") + + return { + "status": "success", + "message": f"Token is valid! Connected as {account.name.display_name}", + "account": account.name.display_name, + "email": account.email + } + except Exception as e: + error_msg = str(e) + logger.error(f"Dropbox token test failed: {error_msg}") + + # Determine if this is an authentication error + is_auth_error = "auth" in error_msg.lower() or "invalid" in error_msg.lower() + + return { + "status": "error", + "message": f"Token validation failed: {error_msg}", + "is_auth_error": is_auth_error, + "needs_reauth": is_auth_error + } + + except Exception as e: + logger.exception("Unexpected error testing Dropbox token") + return { + "status": "error", + "message": f"Unexpected error: {str(e)}" + } + +@router.post("/dropbox/save-settings") +@require_login +async def save_dropbox_settings( + request: Request, + app_key: str = Form(None), + app_secret: str = Form(None), + refresh_token: str = Form(...), + folder_path: str = Form(None) +): + """ + Save Dropbox 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 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: + line = line.rstrip() + is_updated = False + for key, value in dropbox_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 dropbox_settings.items(): + if key not in updated: + new_env_lines.append(f"{key}={value}") + + # Write the updated .env file + with open(env_path, "w") as f: + f.write("\n".join(new_env_lines) + "\n") + + # Update the settings in memory + if refresh_token: + settings.dropbox_refresh_token = refresh_token + if app_key: + settings.dropbox_app_key = app_key + if app_secret: + settings.dropbox_app_secret = app_secret + if folder_path: + settings.dropbox_folder = folder_path + + logger.info("Successfully updated Dropbox settings") + + return { + "status": "success", + "message": "Dropbox settings have been saved" + } + + 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)}" + ) + diff --git a/app/frontend.py b/app/frontend.py index 30ebf69e..b38befc1 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -159,3 +159,60 @@ async def onedrive_callback(request: Request, code: str = None, error: str = Non } ) +@router.get("/dropbox-setup") +@require_login +async def dropbox_setup_page(request: Request): + """ + Setup page for the Dropbox integration. + Shows configuration status and setup instructions. + """ + # Check Dropbox configuration + is_configured = bool(settings.dropbox_app_key and + settings.dropbox_app_secret and + settings.dropbox_refresh_token) + + return templates.TemplateResponse( + "dropbox.html", + { + "request": request, + "is_configured": is_configured, + "app_key_value": settings.dropbox_app_key or "", + "app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "", + "refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "", + "folder_path": settings.dropbox_folder or "/Documents/Uploads" # Default folder path + } + ) + +@router.get("/dropbox-callback") +@require_login +async def dropbox_callback(request: Request, code: str = None, error: str = None): + """ + Callback endpoint for Dropbox OAuth flow. + Automatically exchanges the code for a token and saves it to the configuration. + """ + if error: + return templates.TemplateResponse( + "dropbox_callback_error.html", + {"request": request, "error": error} + ) + + if not code: + return templates.TemplateResponse( + "dropbox_callback_error.html", + {"request": request, "error": "No authorization code received from Dropbox"} + ) + + # Display the processing page with automatic token exchange + # Note: We provide empty strings for app_key_value and app_secret_value + # to prevent overriding what's in sessionStorage + return templates.TemplateResponse( + "dropbox_callback.html", + { + "request": request, + "code": code, + "app_key_value": "", # The callback will prioritize sessionStorage values + "app_secret_value": "", # The callback will prioritize sessionStorage values + "folder_path": "" # The callback will prioritize sessionStorage values + } + ) + diff --git a/app/tasks/process_document.py b/app/tasks/process_document.py index 95658aa5..880264d2 100644 --- a/app/tasks/process_document.py +++ b/app/tasks/process_document.py @@ -26,7 +26,7 @@ def process_document(original_local_file: str): 2. If not found, insert a new DB row and continue with the pipeline: - Copy file to /workdir/tmp - Check for embedded text. If present, run local GPT extraction - - Otherwise, queue Textract-based OCR + - Otherwise, queue Azure Document Intelligence processing """ if not os.path.exists(original_local_file): diff --git a/app/tasks/refine_text_with_gpt.py b/app/tasks/refine_text_with_gpt.py index b01cce3a..599c568a 100644 --- a/app/tasks/refine_text_with_gpt.py +++ b/app/tasks/refine_text_with_gpt.py @@ -30,5 +30,5 @@ def refine_text_with_gpt(filename: str, raw_text: str): from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt extract_metadata_with_gpt.delay(filename, cleaned_text) - return {"s3_file": filename, "cleaned_text": cleaned_text} + return {"filename": filename, "cleaned_text": cleaned_text} diff --git a/app/tasks/upload_to_dropbox.py b/app/tasks/upload_to_dropbox.py index 24497a95..03415953 100644 --- a/app/tasks/upload_to_dropbox.py +++ b/app/tasks/upload_to_dropbox.py @@ -56,6 +56,49 @@ def get_dropbox_access_token(): logger.error(error_msg) raise Exception(error_msg) +def get_dropbox_client(): + """ + Create and return an authenticated Dropbox client using the configured refresh token. + + Returns: + dropbox.Dropbox: Authenticated Dropbox client instance + + Raises: + ValueError: If required Dropbox configuration is missing + AuthError: If authentication with Dropbox fails + """ + app_key = settings.dropbox_app_key + app_secret = settings.dropbox_app_secret + refresh_token = settings.dropbox_refresh_token + + # Validate configuration + if not app_key or not app_secret: + raise ValueError("Dropbox app key or app secret is not configured") + + if not refresh_token: + raise ValueError("Dropbox refresh token is not configured") + + # Create a Dropbox client with refresh token + try: + dbx = dropbox.Dropbox( + app_key=app_key, + app_secret=app_secret, + oauth2_refresh_token=refresh_token + ) + + # Test the connection + dbx.users_get_current_account() + logger.info("Successfully authenticated with Dropbox") + return dbx + + except AuthError as auth_error: + logger.error(f"Dropbox authentication failed: {str(auth_error)}") + raise + + except Exception as e: + logger.error(f"Error creating Dropbox client: {str(e)}") + raise + @celery.task(base=BaseTaskWithRetry) def upload_to_dropbox(file_path: str): """ @@ -76,12 +119,8 @@ def upload_to_dropbox(file_path: str): filename = os.path.basename(file_path) try: - # Get access token from refresh token - access_token = get_dropbox_access_token() - if not access_token: - return {"status": "Failed", "reason": "Could not obtain access token"} - - dbx = dropbox.Dropbox(access_token) + # Get the Dropbox client + dbx = get_dropbox_client() # Calculate remote path based on local file structure remote_base = settings.dropbox_folder or "" diff --git a/app/utils/config_validator.py b/app/utils/config_validator.py index 6eb453d2..6bc87ea3 100644 --- a/app/utils/config_validator.py +++ b/app/utils/config_validator.py @@ -144,10 +144,15 @@ def get_provider_status(): # Check Dropbox configuration providers["Dropbox"] = { "name": "Dropbox", - "configured": bool(getattr(settings, 'dropbox_refresh_token', None)), + "configured": bool(getattr(settings, 'dropbox_app_key', None) and + getattr(settings, 'dropbox_app_secret', None) and + getattr(settings, 'dropbox_refresh_token', None)), "enabled": True, "details": { - "folder": getattr(settings, 'dropbox_folder', 'Not set') + "folder": getattr(settings, 'dropbox_folder', 'Not set'), + "app_key": getattr(settings, 'dropbox_app_key', 'Not set'), + "app_secret": getattr(settings, 'dropbox_app_secret', None) and "Configured" or "Not set", + "refresh_token": getattr(settings, 'dropbox_refresh_token', None) and "Configured" or "Not set" } } diff --git a/docs/setup-guides/dropbox-setup.md b/docs/setup-guides/dropbox-setup.md deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 78918304..0bfa04f0 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -59,6 +59,7 @@ >
OneDrive Setup + Dropbox Setup
diff --git a/frontend/templates/dropbox.html b/frontend/templates/dropbox.html new file mode 100644 index 00000000..5f82a80e --- /dev/null +++ b/frontend/templates/dropbox.html @@ -0,0 +1,406 @@ +{% extends "base.html" %} +{% block title %}Dropbox Setup{% endblock %} + +{% block content %} +
+
+

Dropbox Integration Setup

+

+ Configure the Dropbox integration for DocuNova using our setup wizard. +

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

+ Note: You need to create a Dropbox app and obtain API credentials. The setup wizard will guide you through this process. +

+
+
+
+
+ +
+

Quick Setup Guide

+ +
+

Step 1: Create a Dropbox App

+
    +
  1. Go to the Dropbox Developer Apps Console
  2. +
  3. Click "Create app"
  4. +
  5. Select "Scoped access" for API
  6. +
  7. Choose "Full Dropbox" access (or "App folder" for more restricted access)
  8. +
  9. Give your app a name (e.g., "DocuNova")
  10. +
  11. Click "Create app"
  12. +
+
+ +
+

Step 2: Configure App Permissions

+
    +
  1. In your app's settings page, go to the "Permissions" tab
  2. +
  3. Enable the following permissions: +
      +
    • files.content.write (to upload files)
    • +
    • files.content.read (if you need to read file content)
    • +
    +
  4. +
  5. Click "Submit" to save changes
  6. +
+
+ +
+

Step 3: Set OAuth 2 Redirect URI

+
    +
  1. In your app's settings page, go to the "OAuth 2" section
  2. +
  3. Add a redirect URI: {{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback
  4. +
  5. Click "Add" to save the redirect URI
  6. +
+
+
+ +
+

Complete Setup with Wizard

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

Enter the folder path where files should be uploaded (e.g., /Documents/Uploads)

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

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

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

Configuration for Worker Nodes

+

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

+ +
+
DROPBOX_APP_KEY={{ app_key_value }}
+DROPBOX_APP_SECRET={{ app_secret_value|default('YOUR_APP_SECRET', true) }}
+DROPBOX_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
+DROPBOX_FOLDER={{ 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 + Dropbox Setup Documentation. +

+
+
+
+ + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/frontend/templates/dropbox_callback.html b/frontend/templates/dropbox_callback.html new file mode 100644 index 00000000..5787120e --- /dev/null +++ b/frontend/templates/dropbox_callback.html @@ -0,0 +1,227 @@ +{% extends "base.html" %} +{% block title %}Dropbox Authorization Processing{% endblock %} + +{% block content %} +
+
+
+ + + +

Processing Authorization

+

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

+
+ +
+
+
+ +
+

Exchanging authorization code for refresh token...

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

Authorization Failed

+

Sorry, we couldn't complete the Dropbox authorization.

+
+ +
+
+
+
+ +
+
+

Error details:

+
+

{{ error }}

+
+
+
+
+
+ + +
+
+{% endblock %} diff --git a/requirements.txt b/requirements.txt index 9eb9a356..9394c34a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ openai # GPT integration for metadata extraction pymupdf # PDF processing, text extraction, and detection (imported as 'fitz') PyPDF2 # PDF processing for page counting requests # HTTP client -dropbox # Dropbox integration +dropbox>=11.36.0 # Dropbox integration azure-ai-documentintelligence # Azure OCR service authlib # Authentication python-dotenv # Environment variables From af1a76a813bb4399e6f3f0709a1d7dec433ad794 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Thu, 3 Apr 2025 12:44:17 +0200 Subject: [PATCH 2/2] feat: refactor OneDrive integration to use session storage for credentials and enhance error handling feat: updated System Status Dashboard --- app/frontend.py | 1 + app/utils/config_validator.py | 240 ++++++++++---- frontend/templates/base.html | 29 -- frontend/templates/onedrive.html | 153 ++++++++- frontend/templates/onedrive_callback.html | 48 ++- frontend/templates/status_dashboard.html | 367 +++++++++++++++++++++- 6 files changed, 701 insertions(+), 137 deletions(-) diff --git a/app/frontend.py b/app/frontend.py index b38befc1..35954cb7 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -70,6 +70,7 @@ async def status_dashboard(request: Request): { "request": request, "providers": providers, + "app_version": settings.version, # Add app version to the context "debug_enabled": getattr(settings, 'debug', False), "last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S") } diff --git a/app/utils/config_validator.py b/app/utils/config_validator.py index 6bc87ea3..767a96aa 100644 --- a/app/utils/config_validator.py +++ b/app/utils/config_validator.py @@ -137,125 +137,223 @@ def validate_storage_configs(): return issues +def mask_sensitive_value(value): + """Helper function to mask sensitive values consistently""" + if not value: + return "Not set" + + if isinstance(value, str): + if len(value) > 10: + visible_start = max(1, len(value) // 3) + visible_end = max(1, len(value) // 4) + return f"{value[:visible_start]}{'*' * (len(value) - visible_start - visible_end)}{value[-visible_end:]}" + else: + return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" if len(value) > 4 else "****" + elif not isinstance(value, (bool, int, float)): + return "**Configured Value**" + return str(value) + def get_provider_status(): """Returns status information for all configured providers""" providers = {} - # Check Dropbox configuration + # Add Dropbox configuration - alphabetically ordered providers providers["Dropbox"] = { "name": "Dropbox", + "icon": "dropbox", "configured": bool(getattr(settings, 'dropbox_app_key', None) and getattr(settings, 'dropbox_app_secret', None) and getattr(settings, 'dropbox_refresh_token', None)), "enabled": True, + "description": "Upload files to Dropbox cloud storage", "details": { "folder": getattr(settings, 'dropbox_folder', 'Not set'), "app_key": getattr(settings, 'dropbox_app_key', 'Not set'), - "app_secret": getattr(settings, 'dropbox_app_secret', None) and "Configured" or "Not set", - "refresh_token": getattr(settings, 'dropbox_refresh_token', None) and "Configured" or "Not set" + "app_secret": mask_sensitive_value(getattr(settings, 'dropbox_app_secret', None)), + "refresh_token": mask_sensitive_value(getattr(settings, 'dropbox_refresh_token', None)) } } - # Check Paperless configuration - providers["Paperless-ngx"] = { - "name": "Paperless-ngx", - "configured": bool(getattr(settings, 'paperless_host', None) and - getattr(settings, 'paperless_ngx_api_token', None)), + # Add Email configuration + providers["Email"] = { + "name": "Email", + "icon": "mail", + "configured": bool(getattr(settings, 'email_host', None) and + getattr(settings, 'email_default_recipient', None)), "enabled": True, + "description": "Send documents via email", "details": { - "host": getattr(settings, 'paperless_host', 'Not set') + "host": getattr(settings, 'email_host', 'Not set'), + "port": getattr(settings, 'email_port', 'Not set'), + "username": getattr(settings, 'email_username', 'Not set'), + "password": mask_sensitive_value(getattr(settings, 'email_password', None)), + "use_tls": getattr(settings, 'email_use_tls', 'Not set'), + "sender": getattr(settings, 'email_sender', 'Not set'), + "default_recipient": getattr(settings, 'email_default_recipient', 'Not set') } } - # Check NextCloud configuration - providers["NextCloud"] = { - "name": "NextCloud", - "configured": bool(getattr(settings, 'nextcloud_upload_url', None) and - getattr(settings, 'nextcloud_username', None) and - getattr(settings, 'nextcloud_password', None)), + # Add FTP configuration to providers + providers["FTP Storage"] = { + "name": "FTP Storage", + "icon": "ftp", + "configured": bool(getattr(settings, 'ftp_host', None) and + getattr(settings, 'ftp_username', None) and + getattr(settings, 'ftp_password', None)), "enabled": True, + "description": "Upload files to FTP server", "details": { - "url": getattr(settings, 'nextcloud_upload_url', 'Not set'), - "folder": getattr(settings, 'nextcloud_folder', 'Not set') - } - } - - # Check SFTP configuration - providers["SFTP Storage"] = { - "name": "SFTP Storage", - "configured": bool(getattr(settings, 'sftp_host', None) and - getattr(settings, 'sftp_username', None) and - (getattr(settings, 'sftp_password', None) or - getattr(settings, 'sftp_private_key', None))), - "enabled": True, - "details": { - "host": getattr(settings, 'sftp_host', 'Not set'), - "folder": getattr(settings, 'sftp_folder', 'Not set') - } - } - - # Check S3 configuration - providers["S3 Storage"] = { - "name": "S3 Storage", - "configured": bool(getattr(settings, 's3_bucket_name', None) and - getattr(settings, 'aws_access_key_id', None) and - getattr(settings, 'aws_secret_access_key', None)), - "enabled": True, - "details": { - "bucket": getattr(settings, 's3_bucket_name', 'Not set'), - "region": getattr(settings, 'aws_region', 'Not set') + "host": getattr(settings, 'ftp_host', 'Not set'), + "port": getattr(settings, 'ftp_port', 'Not set'), + "username": getattr(settings, 'ftp_username', 'Not set'), + "password": mask_sensitive_value(getattr(settings, 'ftp_password', None)), + "folder": getattr(settings, 'ftp_folder', 'Not set'), + "tls": getattr(settings, 'ftp_use_tls', True), + "allow_plaintext": getattr(settings, 'ftp_allow_plaintext', True) } } # Check Google Drive configuration providers["Google Drive"] = { "name": "Google Drive", + "icon": "google", "configured": bool(getattr(settings, 'google_drive_credentials_json', None) and getattr(settings, 'google_drive_folder_id', None)), "enabled": True, + "description": "Store documents in Google Drive", "details": { + "credentials_json": mask_sensitive_value(getattr(settings, 'google_drive_credentials_json', None)), "folder_id": getattr(settings, 'google_drive_folder_id', 'Not set'), "delegate": getattr(settings, 'google_drive_delegate_to', 'Not set') } } + # Check NextCloud configuration + nextcloud_url = getattr(settings, 'nextcloud_upload_url', 'Not set') + # Extract base URL from WebDAV URL (remove the /remote.php part and everything after it) + if nextcloud_url != 'Not set' and '/remote.php' in nextcloud_url: + nextcloud_base_url = nextcloud_url.split('/remote.php')[0] + else: + nextcloud_base_url = nextcloud_url + + providers["NextCloud"] = { + "name": "NextCloud", + "icon": "cloud", + "configured": bool(getattr(settings, 'nextcloud_upload_url', None) and + getattr(settings, 'nextcloud_username', None) and + getattr(settings, 'nextcloud_password', None)), + "enabled": True, + "description": "Store documents in NextCloud", + "details": { + "url": getattr(settings, 'nextcloud_upload_url', 'Not set'), + "base_url": nextcloud_base_url, + "username": getattr(settings, 'nextcloud_username', 'Not set'), + "password": mask_sensitive_value(getattr(settings, 'nextcloud_password', None)), + "folder": getattr(settings, 'nextcloud_folder', 'Not set') + } + } + # Check OneDrive configuration providers["OneDrive"] = { "name": "OneDrive", + "icon": "microsoft", "configured": bool(getattr(settings, 'onedrive_client_id', None) and getattr(settings, 'onedrive_client_secret', None) and getattr(settings, 'onedrive_refresh_token', None)), "enabled": True, + "description": "Store documents in Microsoft OneDrive", "details": { + "client_id": getattr(settings, 'onedrive_client_id', 'Not set'), + "client_secret": mask_sensitive_value(getattr(settings, 'onedrive_client_secret', None)), + "tenant_id": getattr(settings, 'onedrive_tenant_id', 'Not set'), + "refresh_token": mask_sensitive_value(getattr(settings, 'onedrive_refresh_token', None)), "folder": getattr(settings, 'onedrive_folder_path', 'Not set') } } + + # Check Paperless configuration + providers["Paperless-ngx"] = { + "name": "Paperless-ngx", + "icon": "file-text", + "configured": bool(getattr(settings, 'paperless_host', None) and + getattr(settings, 'paperless_ngx_api_token', None)), + "enabled": True, + "description": "Document management system for digital archives", + "details": { + "host": getattr(settings, 'paperless_host', 'Not set'), + "api_token": mask_sensitive_value(getattr(settings, 'paperless_ngx_api_token', None)) + } + } + + # Check S3 configuration + providers["S3 Storage"] = { + "name": "S3 Storage", + "icon": "database", + "configured": bool(getattr(settings, 's3_bucket_name', None) and + getattr(settings, 'aws_access_key_id', None) and + getattr(settings, 'aws_secret_access_key', None)), + "enabled": True, + "description": "Store documents in S3-compatible object storage", + "details": { + "bucket": getattr(settings, 's3_bucket_name', 'Not set'), + "region": getattr(settings, 'aws_region', 'Not set'), + "access_key_id": getattr(settings, 'aws_access_key_id', 'Not set'), + "secret_access_key": mask_sensitive_value(getattr(settings, 'aws_secret_access_key', None)), + "folder_prefix": getattr(settings, 's3_folder_prefix', 'Not set'), + "storage_class": getattr(settings, 's3_storage_class', 'Not set'), + "acl": getattr(settings, 's3_acl', 'Not set') + } + } + + # Check SFTP configuration + providers["SFTP Storage"] = { + "name": "SFTP Storage", + "icon": "server", + "configured": bool(getattr(settings, 'sftp_host', None) and + getattr(settings, 'sftp_username', None) and + (getattr(settings, 'sftp_password', None) or + getattr(settings, 'sftp_private_key', None))), + "enabled": True, + "description": "Upload files to SFTP server", + "details": { + "host": getattr(settings, 'sftp_host', 'Not set'), + "port": getattr(settings, 'sftp_port', 'Not set'), + "username": getattr(settings, 'sftp_username', 'Not set'), + "password": mask_sensitive_value(getattr(settings, 'sftp_password', None)), + "private_key": getattr(settings, 'sftp_private_key', 'Not set'), + "private_key_passphrase": mask_sensitive_value(getattr(settings, 'sftp_private_key_passphrase', None)), + "folder": getattr(settings, 'sftp_folder', 'Not set') + } + } + + # Add Uptime Kuma configuration + providers["Uptime Kuma"] = { + "name": "Uptime Kuma", + "icon": "activity", + "configured": bool(getattr(settings, 'uptime_kuma_url', None)), + "enabled": True, + "description": "Server monitoring and status page", + "details": { + "url": getattr(settings, 'uptime_kuma_url', 'Not set'), + "ping_interval": getattr(settings, 'uptime_kuma_ping_interval', 'Not set') + } + } # Check WebDAV configuration providers["WebDAV"] = { "name": "WebDAV", + "icon": "globe", "configured": bool(getattr(settings, 'webdav_url', None) and getattr(settings, 'webdav_username', None) and getattr(settings, 'webdav_password', None)), "enabled": True, + "description": "Store documents on WebDAV servers", "details": { "url": getattr(settings, 'webdav_url', 'Not set'), - "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) + "username": getattr(settings, 'webdav_username', 'Not set'), + "password": mask_sensitive_value(getattr(settings, 'webdav_password', None)), + "folder": getattr(settings, 'webdav_folder', 'Not set'), + "verify_ssl": getattr(settings, 'webdav_verify_ssl', 'Not set') } } @@ -270,7 +368,12 @@ def dump_all_settings(): # Mask sensitive values in logs if key.lower().find('password') >= 0 or key.lower().find('secret') >= 0 or key.lower().find('token') >= 0 or key.lower().find('key') >= 0: if value: - value = "********" + if isinstance(value, str) and len(value) > 10: + visible_start = max(1, len(value) // 3) + visible_end = max(1, len(value) // 4) + value = f"{value[:visible_start]}{'*' * (len(value) - visible_start - visible_end)}{value[-visible_end:]}" + else: + value = f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" if isinstance(value, str) and len(value) > 4 else "****" logger.info(f"{key}: {value}") logger.info("--- END OF SETTINGS DUMP ---") @@ -439,17 +542,26 @@ def get_settings_for_display(show_values=False): # List of patterns that indicate sensitive values sensitive_patterns = [ 'password', 'secret', 'token', 'api_key', 'private_key', - 'credentials', 'access_key', 'auth' + 'credentials', 'access_key', 'ai_key' ] # Check if this is a sensitive value that should be masked - is_sensitive = any(pattern in key.lower() for pattern in sensitive_patterns) + is_sensitive = any( + pattern in key.lower() for pattern in sensitive_patterns + ) + + # Special handling for "auth" to avoid matching prefixes like "authentik" + if not is_sensitive and "auth" in key.lower(): + # Only mark as sensitive if "auth" is a standalone word or at the end + # This avoids matching "authentik" as sensitive + parts = key.lower().split('_') + is_sensitive = any(part == "auth" for part in parts) or key.lower().endswith("auth") # Mask sensitive values regardless of debug mode # Other values are only hidden if debug mode is off AND show_values is False if (is_sensitive or not show_values) and value: if is_sensitive: - value = "********" + value = mask_sensitive_value(value) # Check if the setting is configured (has a non-None value) # For boolean settings, consider them configured even if False diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 0bfa04f0..9122034c 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -36,35 +36,6 @@ Upload Files Status - - -
- - - -
- About diff --git a/frontend/templates/onedrive.html b/frontend/templates/onedrive.html index cdd8ff9f..d3d6d604 100644 --- a/frontend/templates/onedrive.html +++ b/frontend/templates/onedrive.html @@ -112,6 +112,12 @@

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

+
+ + +

Enter the folder path where files should be uploaded (e.g., Documents/Uploads)

+
+
+ + + {% endblock %} @@ -216,25 +244,78 @@ document.addEventListener('DOMContentLoaded', function() { const tokenStatus = document.getElementById('token-status'); const clientSecretInput = document.getElementById('client-secret'); + // Modal elements + const resultModal = document.getElementById('resultModal'); + const modalTitle = document.getElementById('modalTitle'); + const modalMessage = document.getElementById('modalMessage'); + const modalIcon = document.getElementById('modalIcon'); + const modalClose = document.getElementById('modalClose'); + + // Modal functions + function showModal(status, title, message) { + modalTitle.textContent = title; + modalMessage.textContent = message; + + // Set the appropriate icon + if (status === 'success') { + modalIcon.innerHTML = ` + + + + `; + modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4'; + } else { + modalIcon.innerHTML = ` + + + + `; + modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4'; + } + + resultModal.classList.remove('hidden'); + } + + function hideModal() { + resultModal.classList.add('hidden'); + } + + // Close modal when clicking the close button + modalClose.addEventListener('click', hideModal); + + // Close modal when clicking outside of it + resultModal.addEventListener('click', function(e) { + if (e.target === resultModal) { + hideModal(); + } + }); + // Start Authentication Flow button click startAuthFlowBtn.addEventListener('click', function() { const clientId = document.getElementById('client-id').value.trim(); const clientSecret = clientSecretInput.value.trim(); const redirectUri = window.location.origin + "/onedrive-callback"; const tenantId = document.getElementById('tenant-id').value.trim() || 'common'; + const folderPath = document.getElementById('folder-path') ? document.getElementById('folder-path').value.trim() : ''; if (!clientId) { - alert('Please enter your Client ID'); + showModal('error', 'Validation Error', 'Please enter your Client ID'); return; } if (!clientSecret) { - alert('Please enter your Client Secret'); + showModal('error', 'Validation Error', 'Please enter your Client Secret'); return; } - // Save client secret to session storage temporarily + // Save all entered values to session storage temporarily + sessionStorage.setItem('onedrive_client_id', clientId); sessionStorage.setItem('onedrive_client_secret', clientSecret); + sessionStorage.setItem('onedrive_tenant_id', tenantId); + + if (folderPath) { + sessionStorage.setItem('onedrive_folder_path', folderPath); + } // Generate the authorization URL with .default scope const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&response_mode=query&scope=${encodeURIComponent('https://graph.microsoft.com/.default offline_access')}&prompt=consent`; @@ -253,19 +334,25 @@ document.addEventListener('DOMContentLoaded', function() { .then(response => response.json()) .then(data => { if (data.status === 'success') { - alert('Token is valid! Successfully connected to OneDrive.'); + showModal('success', 'Token Valid', 'Successfully connected to OneDrive!'); } else { if (data.message && data.message.includes('invalid_grant')) { - if (confirm('Your OneDrive token has expired or is invalid. Would you like to obtain a new token now?')) { + showModal('error', 'Token Invalid', 'Your OneDrive token has expired or is invalid. Please obtain a new token.'); + // Give option to start new auth flow + modalClose.textContent = "Get New Token"; + modalClose.addEventListener('click', function onGetNewToken() { startAuthFlowBtn.click(); - } + // Remove this special event handler after use + modalClose.removeEventListener('click', onGetNewToken); + modalClose.textContent = "Close"; + }, { once: true }); } else { - alert('Token validation failed: ' + data.message); + showModal('error', 'Token Test Failed', data.message); } } }) .catch(error => { - alert('Error testing token: ' + error.message); + showModal('error', 'Connection Error', 'Error testing token: ' + error.message); }) .finally(() => { testTokenBtn.innerHTML = 'Test Token'; @@ -277,9 +364,31 @@ document.addEventListener('DOMContentLoaded', function() { // Refresh Token button click if (refreshTokenBtn) { refreshTokenBtn.addEventListener('click', function() { - if (confirm('This will start a new authentication flow to obtain a fresh token from Microsoft. Continue?')) { + showModal('info', 'Confirm', 'This will start a new authentication flow to obtain a fresh token from Microsoft. Continue?'); + modalClose.textContent = "Cancel"; + + // Add a confirm button + const confirmBtn = document.createElement('button'); + confirmBtn.className = 'ml-2 px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300'; + confirmBtn.textContent = 'Continue'; + confirmBtn.addEventListener('click', function() { + hideModal(); startAuthFlowBtn.click(); - } + }); + + // Add to modal + modalClose.parentNode.appendChild(confirmBtn); + + // Make sure to remove the confirm button when modal is closed + const removeConfirmBtn = function() { + if (confirmBtn.parentNode) { + confirmBtn.parentNode.removeChild(confirmBtn); + } + modalClose.textContent = "Close"; + modalClose.removeEventListener('click', removeConfirmBtn); + }; + + modalClose.addEventListener('click', removeConfirmBtn, { once: true }); }); } @@ -300,16 +409,32 @@ document.addEventListener('DOMContentLoaded', function() { }) .catch(err => { console.error('Failed to copy: ', err); - alert('Failed to copy text to clipboard'); + showModal('error', 'Copy Failed', 'Failed to copy text to clipboard'); }); }); } - // Try to retrieve client secret from session storage (if coming back from auth) + // Try to retrieve values from session storage (if coming back from auth or browser refresh) if (clientSecretInput && !clientSecretInput.value && sessionStorage.getItem('onedrive_client_secret')) { clientSecretInput.value = sessionStorage.getItem('onedrive_client_secret'); - // Clear it after use - sessionStorage.removeItem('onedrive_client_secret'); + } + + // Also check for client ID in session storage + const clientIdInput = document.getElementById('client-id'); + if (clientIdInput && !clientIdInput.value && sessionStorage.getItem('onedrive_client_id')) { + clientIdInput.value = sessionStorage.getItem('onedrive_client_id'); + } + + // Check for tenant ID in session storage + const tenantIdInput = document.getElementById('tenant-id'); + if (tenantIdInput && !tenantIdInput.value && sessionStorage.getItem('onedrive_tenant_id')) { + tenantIdInput.value = sessionStorage.getItem('onedrive_tenant_id'); + } + + // Check for folder path in session storage + const folderPathInput = document.getElementById('folder-path'); + if (folderPathInput && !folderPathInput.value && sessionStorage.getItem('onedrive_folder_path')) { + folderPathInput.value = sessionStorage.getItem('onedrive_folder_path'); } // If token is not configured but we have a client ID, show the token status section diff --git a/frontend/templates/onedrive_callback.html b/frontend/templates/onedrive_callback.html index 4f827487..468d2ba9 100644 --- a/frontend/templates/onedrive_callback.html +++ b/frontend/templates/onedrive_callback.html @@ -92,24 +92,34 @@ +{% endblock %}