diff --git a/app/__init__.py b/app/__init__.py index e69de29b..aec18d9b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,3 @@ +""" +Document processor application package. +""" diff --git a/app/api.py b/app/api.py deleted file mode 100644 index 20f37645..00000000 --- a/app/api.py +++ /dev/null @@ -1,986 +0,0 @@ -# app/api.py -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 -from app.models import FileRecord -from app.config import settings -from app.tasks.process_document import process_document -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(): - db = SessionLocal() - try: - yield db - finally: - db.close() - -@router.get("/whoami") -async def whoami(request: Request): - """ - Returns user info if logged in, else 401. - """ - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401, detail="Not logged in") - - email = user.get("email") - if not email: - raise HTTPException(status_code=400, detail="User has no email in session") - - # Generate Gravatar URL from email - email_hash = md5(email.strip().lower().encode()).hexdigest() - gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon" - - return { - "email": email, - "picture": gravatar_url - } - -@router.get("/files") -@require_login -def list_files_api(request: Request, db: Session = Depends(get_db)): - """ - Returns a JSON list of all FileRecord entries. - Protected by `@require_login`, so only logged-in sessions can access. - - Example response: - [ - { - "id": 123, - "filehash": "abc123...", - "original_filename": "example.pdf", - "local_filename": "/workdir/tmp/.pdf", - "file_size": 1048576, - "mime_type": "application/pdf", - "created_at": "2025-05-01T12:34:56.789000" - }, - ... - ] - """ - files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all() - # Return a simple list of dicts - result = [] - for f in files: - result.append({ - "id": f.id, - "filehash": f.filehash, - "original_filename": f.original_filename, - "local_filename": f.local_filename, - "file_size": f.file_size, - "mime_type": f.mime_type, - "created_at": f.created_at.isoformat() if f.created_at else None - }) - return result - -@router.delete("/files/{file_id}") -@require_login -def delete_file_record(request: Request, file_id: int, db: Session = Depends(get_db)): - """ - Delete a file record from the database. - This only removes the database entry, not the actual file. - """ - # Check if file deletion is allowed - if not settings.allow_file_delete: - raise HTTPException( - status_code=403, - detail="File deletion is disabled in the configuration" - ) - - try: - # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() - - if not file_record: - raise HTTPException( - status_code=404, - detail=f"File record with ID {file_id} not found" - ) - - # Log the deletion - logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}") - - # Delete the record - db.delete(file_record) - db.commit() - - return { - "status": "success", - "message": f"File record {file_id} deleted successfully" - } - - except HTTPException: - raise - except Exception as e: - db.rollback() - logger.exception(f"Error deleting file record {file_id}: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Error deleting file record: {str(e)}" - ) - -# API endpoints -@router.get("/diagnostic/settings") -@require_login -async def diagnostic_settings(request: Request, current_user: dict = Depends(get_current_user)): - """ - API endpoint to dump settings to the log and view basic config information - This endpoint doesn't expose sensitive information like passwords or tokens - """ - from app.utils.config_validator import dump_all_settings, get_settings_for_display - # Dump full settings to log for admin to see - dump_all_settings() - - # Return safe subset of settings for API response - safe_settings = { - "workdir": settings.workdir, - "external_hostname": settings.external_hostname, - "configured_services": { - "email": bool(getattr(settings, 'email_host', None)), - "s3": bool(getattr(settings, 's3_bucket_name', None)), - "dropbox": bool(getattr(settings, 'dropbox_refresh_token', None)), - "nextcloud": bool(getattr(settings, 'nextcloud_upload_url', None)), - "sftp": bool(getattr(settings, 'sftp_host', None)), - "paperless": bool(getattr(settings, 'paperless_host', None)), - "google_drive": bool(getattr(settings, 'google_drive_credentials_json', None)), - "uptime_kuma": bool(getattr(settings, 'uptime_kuma_url', None)), - "auth": bool(getattr(settings, 'authentik_config_url', None)), - }, - "imap_enabled": bool(getattr(settings, 'imap1_host', None) or getattr(settings, 'imap2_host', None)), - } - - return { - "status": "success", - "settings": safe_settings, - "message": "Full settings have been dumped to application logs" - } - -@router.post("/process/") -@require_login -def process(file_path: str): - """API Endpoint to start document processing.""" - if not os.path.isabs(file_path): - file_path = os.path.join(settings.workdir, file_path) - - if not os.path.exists(file_path): - raise HTTPException( - status_code=400, detail=f"File {file_path} not found." - ) - - task = process_document.delay(file_path) - return {"task_id": task.id, "status": "queued"} - -@router.post("/send_to_dropbox/") -@require_login -def send_to_dropbox(file_path: str): - """Send a document to Dropbox.""" - 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_dropbox.delay(file_path) - return {"task_id": task.id, "status": "queued"} - -@router.post("/send_to_paperless/") -@require_login -def send_to_paperless(file_path: str): - """Send a document to Paperless-ngx.""" - 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_paperless.delay(file_path) - return {"task_id": task.id, "status": "queued"} - -@router.post("/send_to_nextcloud/") -@require_login -def send_to_nextcloud(file_path: str): - """Send a document to NextCloud.""" - 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_nextcloud.delay(file_path) - return {"task_id": task.id, "status": "queued"} - -@router.post("/send_to_google_drive/") -@require_login -def send_to_google_drive(file_path: str): - """Send a document to Google Drive.""" - 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_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): - """Call the aggregator task that sends this file to all configured destinations.""" - 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 = send_to_all_destinations.delay(file_path) - return {"task_id": task.id, "status": "queued", "file_path": file_path} - -@router.post("/processall") -@require_login -def process_all_pdfs_in_workdir(): - """Finds all .pdf files in and enqueues them for processing.""" - target_dir = settings.workdir - if not os.path.exists(target_dir): - raise HTTPException( - status_code=400, detail=f"Directory {target_dir} does not exist." - ) - - pdf_files = [] - for filename in os.listdir(target_dir): - if filename.lower().endswith(".pdf"): - pdf_files.append(filename) - - if not pdf_files: - return {"message": "No PDF files found in that directory."} - - task_ids = [] - for pdf in pdf_files: - file_path = os.path.join(target_dir, pdf) - task = process_document.delay(file_path) - task_ids.append(task.id) - - return { - "message": f"Enqueued {len(pdf_files)} PDFs to upload_to_s3", - "pdf_files": pdf_files, - "task_ids": task_ids - } - -@router.post("/ui-upload") -@require_login -async def ui_upload(request: Request, file: UploadFile = File(...)): - """Endpoint to accept a user-uploaded file and enqueue it for processing.""" - import uuid - import os.path - - workdir = settings.workdir - - # Extract just the filename without any path components to prevent path traversal - safe_filename = os.path.basename(file.filename) - - # Generate a unique filename with UUID to prevent overwriting and filename conflicts - unique_id = str(uuid.uuid4()) - # Keep the original extension if present - if "." in safe_filename: - file_extension = safe_filename.rsplit(".", 1)[1] - target_filename = f"{unique_id}.{file_extension}" - else: - target_filename = unique_id - - # Store both the safe original name and the unique name - target_path = os.path.join(workdir, target_filename) - - try: - with open(target_path, "wb") as f: - content = await file.read() - f.write(content) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Failed to save file: {e}" - ) - - # Log the mapping between original and safe filename - print(f"Saved uploaded file '{safe_filename}' as '{target_filename}'") - - task = process_document.delay(target_path) - return { - "task_id": task.id, - "status": "queued", - "original_filename": safe_filename, - "stored_filename": target_filename - } - -@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) - } - -@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/api/user.py b/app/api/user.py index c0fdef01..4aebb9e2 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -10,8 +10,7 @@ logger = logging.getLogger(__name__) router = APIRouter() -@router.get("/whoami") -async def whoami(request: Request): +async def whoami_handler(request: Request): """ Returns user info if logged in, else 401. """ @@ -26,8 +25,18 @@ async def whoami(request: Request): # Generate Gravatar URL from email email_hash = md5(email.strip().lower().encode()).hexdigest() gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?d=identicon" + + # Add the gravatar URL to the user object instead of creating a new response + user_response = user.copy() # Create a copy to avoid modifying the session + user_response["picture"] = gravatar_url + + return user_response - return { - "email": email, - "picture": gravatar_url - } +# Register the same handler under two different paths +@router.get("/whoami") +async def whoami(request: Request): + return await whoami_handler(request) + +@router.get("/auth/whoami") +async def auth_whoami(request: Request): + return await whoami_handler(request) diff --git a/app/auth.py b/app/auth.py index 52ce075a..f597bccd 100644 --- a/app/auth.py +++ b/app/auth.py @@ -71,4 +71,4 @@ if AUTH_ENABLED: async def private_page(request: Request): """A protected endpoint that requires login.""" user = request.session.get("user") # e.g. {"email": "...", ...} - return {"message": f"This is a protected page. Hello {user['email']}!"} + return {"message": "This is a protected page.", "user": user} diff --git a/app/frontend.py b/app/frontend.py index dc396ea1..f63e3f80 100644 --- a/app/frontend.py +++ b/app/frontend.py @@ -1,223 +1,10 @@ -# app/frontend.py -from fastapi import APIRouter, Request, Depends, HTTPException -from fastapi.templating import Jinja2Templates -from pathlib import Path -from sqlalchemy.orm import Session -from datetime import datetime -import logging -from fastapi.responses import FileResponse # Add this import +""" +Frontend routes for the application. +This module is now a re-export of the modularized view routers. +""" +# Import and re-export the router from the views package +from app.views import router -from app.auth import require_login -from app.database import SessionLocal -from app.config import settings - -router = APIRouter() - -# Set up Jinja2 templates -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: - yield db - finally: - db.close() - -@router.get("/files") -@require_login -def files_page(request: Request): - """ - Return the 'files.html' template. - The actual file data is fetched via XHR from /api/files in the template. - """ - return templates.TemplateResponse("files.html", {"request": request}) - -@router.get("/", include_in_schema=False) -async def serve_index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) - -@router.get("/about", include_in_schema=False) -async def serve_about(request: Request): - return templates.TemplateResponse("about.html", {"request": request}) - -@router.get("/upload", include_in_schema=False) -@require_login -async def serve_upload(request: Request): - return templates.TemplateResponse("upload.html", {"request": request}) - -@router.get("/favicon.ico", include_in_schema=False) -def favicon(): - # Return the favicon file as a FileResponse instead of the path string - favicon_path = Path(__file__).parent.parent / "frontend" / "static" / "favicon.ico" - if not favicon_path.exists(): - # If favicon doesn't exist, return a 404 - raise HTTPException(status_code=404, detail="Favicon not found") - return FileResponse(favicon_path) - -@router.get("/status") -@require_login -async def status_dashboard(request: Request): - """ - Status dashboard showing all configured integration targets - """ - from app.utils.config_validator import get_provider_status - - # Get provider status - providers = get_provider_status() - - return templates.TemplateResponse( - "status_dashboard.html", - { - "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") - } - ) - -@router.get("/env") -@require_login -async def env_debug(request: Request): - """ - Debug endpoint to view environment variables and settings - Uses actual debug setting from config - """ - # Use the actual debug setting from configuration - debug_enabled = settings.debug - - # Get settings data - from app.utils.config_validator import get_settings_for_display - settings_data = get_settings_for_display(show_values=debug_enabled) - - return templates.TemplateResponse( - "env_debug.html", - { - "request": request, - "settings": settings_data, - "debug_enabled": debug_enabled, - "app_version": settings.version - } - ) - -@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" - } - ) - -@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 - } - ) +# Keep the original router name for compatibility +# This allows existing imports in main.py to continue working diff --git a/app/main.py b/app/main.py index 579267c9..943e6508 100644 --- a/app/main.py +++ b/app/main.py @@ -15,9 +15,9 @@ from app.database import init_db from app.config import settings from app.utils.config_validator import check_all_configs -# Import both the traditional and new routers -from app.frontend import router as frontend_router_original -from app.api import router as api_router # Updated import for the refactored API router +# Import the routers - now using views directly instead of frontend +from app.views import router as frontend_router +from app.api import router as api_router from app.auth import router as auth_router # Load configuration from .env for the session key @@ -95,8 +95,8 @@ def test_500(): raise RuntimeError("Testing forced 500 error!") # Include the routers -app.include_router(frontend_router_original) +app.include_router(frontend_router) app.include_router(auth_router) -app.include_router(api_router, prefix="/api") # Use the refactored API router +app.include_router(api_router, prefix="/api") diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 00000000..784d3638 --- /dev/null +++ b/app/views/__init__.py @@ -0,0 +1,22 @@ +""" +Frontend views/pages for the application. +This module combines all the individual view routers into a single router. +""" +from fastapi import APIRouter + +# Import routers from view modules +from app.views.general import router as general_router +from app.views.files import router as files_router +from app.views.status import router as status_router +from app.views.dropbox import router as dropbox_router +from app.views.onedrive import router as onedrive_router + +# Create a combined router +router = APIRouter() + +# Include all view routers +router.include_router(general_router) +router.include_router(files_router) +router.include_router(status_router) +router.include_router(dropbox_router) +router.include_router(onedrive_router) diff --git a/app/views/base.py b/app/views/base.py new file mode 100644 index 00000000..247515f3 --- /dev/null +++ b/app/views/base.py @@ -0,0 +1,29 @@ +""" +Base setup for views, containing shared functionality and imports. +""" +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.templating import Jinja2Templates +from pathlib import Path +from sqlalchemy.orm import Session +import logging + +from app.auth import require_login +from app.database import SessionLocal +from app.config import settings + +# Set up Jinja2 templates +templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates" +templates = Jinja2Templates(directory=str(templates_dir)) + +# Set up logging +logger = logging.getLogger(__name__) + +def get_db(): + """ + Dependency to get a database session. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/views/dropbox.py b/app/views/dropbox.py new file mode 100644 index 00000000..a1a5b88d --- /dev/null +++ b/app/views/dropbox.py @@ -0,0 +1,65 @@ +""" +Dropbox integration views for setup and OAuth callback. +""" +from fastapi import Request + +from app.views.base import APIRouter, templates, require_login, settings + +router = APIRouter() + +@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/views/files.py b/app/views/files.py new file mode 100644 index 00000000..96fd7fae --- /dev/null +++ b/app/views/files.py @@ -0,0 +1,39 @@ +""" +File management views for displaying and managing files. +""" +from fastapi import Request, Depends +from sqlalchemy.orm import Session + +from app.views.base import APIRouter, templates, require_login, get_db, logger + +router = APIRouter() + +@router.get("/files") +@require_login +def files_page(request: Request, db: Session = Depends(get_db)): + """ + Return the 'files.html' template with files from the database + """ + try: + # Import the model here to avoid circular imports + from app.models import FileRecord + + # Fetch all files from the database + files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all() + + # Debug output + logger.info(f"Retrieved {len(files)} files from database") + + return templates.TemplateResponse("files.html", { + "request": request, + "files": files + }) + except Exception as e: + # Log any errors + logger.error(f"Error retrieving files: {str(e)}") + # Return error message to template + return templates.TemplateResponse("files.html", { + "request": request, + "files": [], + "error": str(e) + }) diff --git a/app/views/general.py b/app/views/general.py new file mode 100644 index 00000000..5bfc4ad5 --- /dev/null +++ b/app/views/general.py @@ -0,0 +1,35 @@ +""" +General routes for the application homepage and basic pages. +""" +from fastapi import Request, HTTPException +from fastapi.responses import FileResponse +from pathlib import Path + +from app.views.base import APIRouter, templates, require_login + +router = APIRouter() + +@router.get("/", include_in_schema=False) +async def serve_index(request: Request): + """Serve the index/home page.""" + return templates.TemplateResponse("index.html", {"request": request}) + +@router.get("/about", include_in_schema=False) +async def serve_about(request: Request): + """Serve the about page.""" + return templates.TemplateResponse("about.html", {"request": request}) + +@router.get("/upload", include_in_schema=False) +@require_login +async def serve_upload(request: Request): + """Serve the upload page.""" + return templates.TemplateResponse("upload.html", {"request": request}) + +@router.get("/favicon.ico", include_in_schema=False) +def favicon(): + """Serve the favicon.""" + favicon_path = Path(__file__).parent.parent.parent / "frontend" / "static" / "favicon.ico" + if not favicon_path.exists(): + # If favicon doesn't exist, return a 404 + raise HTTPException(status_code=404, detail="Favicon not found") + return FileResponse(favicon_path) diff --git a/app/views/onedrive.py b/app/views/onedrive.py new file mode 100644 index 00000000..a07c4087 --- /dev/null +++ b/app/views/onedrive.py @@ -0,0 +1,68 @@ +""" +OneDrive integration views for setup and OAuth callback. +""" +from fastapi import Request + +from app.views.base import APIRouter, templates, require_login, settings + +router = APIRouter() + +@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/views/status.py b/app/views/status.py new file mode 100644 index 00000000..a1a93cc9 --- /dev/null +++ b/app/views/status.py @@ -0,0 +1,55 @@ +""" +Status and configuration views for the application. +""" +from fastapi import Request +from datetime import datetime + +from app.views.base import APIRouter, templates, require_login, settings + +router = APIRouter() + +@router.get("/status") +@require_login +async def status_dashboard(request: Request): + """ + Status dashboard showing all configured integration targets + """ + from app.utils.config_validator import get_provider_status + + # Get provider status + providers = get_provider_status() + + return templates.TemplateResponse( + "status_dashboard.html", + { + "request": request, + "providers": providers, + "app_version": settings.version, + "debug_enabled": getattr(settings, 'debug', False), + "last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + ) + +@router.get("/env") +@require_login +async def env_debug(request: Request): + """ + Debug endpoint to view environment variables and settings + Uses actual debug setting from config + """ + # Use the actual debug setting from configuration + debug_enabled = settings.debug + + # Get settings data + from app.utils.config_validator import get_settings_for_display + settings_data = get_settings_for_display(show_values=debug_enabled) + + return templates.TemplateResponse( + "env_debug.html", + { + "request": request, + "settings": settings_data, + "debug_enabled": debug_enabled, + "app_version": settings.version + } + ) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index c2313199..f6ff80b5 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -15,6 +15,7 @@ Configuration is primarily done through environment variables specified in a `.e | `WORKDIR` | Working directory for the application. | `/workdir` | | `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` | | `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docunova.example.com` | +| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` | ### IMAP Configuration @@ -184,6 +185,7 @@ REDIS_URL=redis://redis:6379/0 WORKDIR=/workdir GOTENBERG_URL=http://gotenberg:3000 EXTERNAL_HOSTNAME=docunova.example.com +ALLOW_FILE_DELETE=true # IMAP settings IMAP1_HOST=mail.example.com diff --git a/docs/DropboxSetup.md b/docs/DropboxSetup.md index 69dcf0f4..b2a0a94e 100644 --- a/docs/DropboxSetup.md +++ b/docs/DropboxSetup.md @@ -4,7 +4,7 @@ This guide explains how to set up the Dropbox integration for DocuNova. ## Required Configuration Parameters -| **Variable** | **Description** | +| **Variable** | **Description** | |-------------------------|--------------------------------------------------| | `DROPBOX_APP_KEY` | Dropbox API app key | | `DROPBOX_APP_SECRET` | Dropbox API app secret | @@ -13,7 +13,29 @@ This guide explains how to set up the Dropbox integration for DocuNova. For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md). -## Step-by-Step Setup Instructions +## Setup Methods + +You can set up Dropbox integration in two ways: + +1. **Using the Built-in Setup Wizard (Recommended)**: An interactive setup experience available at `/dropbox-setup` in the web interface +2. **Manual Setup**: Following the step-by-step instructions in this document + +## Using the Setup Wizard + +The easiest way to set up Dropbox integration is to use the built-in setup wizard: + +1. Navigate to the `/dropbox-setup` page in your DocuNova instance +2. Follow the on-screen instructions to create a Dropbox app +3. Enter your App Key and App Secret in the wizard +4. Optionally specify a custom folder path for uploads +5. Click "Start Authentication Flow" to begin the authorization process +6. Complete the Dropbox authentication process +7. The system will automatically exchange the authorization code for a refresh token +8. Copy the generated environment variables for your worker nodes + +The wizard handles all the token exchange steps and provides you with the exact configuration needed for your environment. + +## Step-by-Step Manual Setup Instructions ### 1. Create a Dropbox App @@ -73,4 +95,26 @@ For a complete list of configuration options, see the [Configuration Guide](Conf 1. Set `DROPBOX_APP_KEY`, `DROPBOX_APP_SECRET`, and `DROPBOX_REFRESH_TOKEN` with your values 2. Set `DROPBOX_FOLDER` to the path where files should be uploaded (e.g., `/Documents/Uploads`) -The system will use the refresh token to automatically generate short-lived access tokens when needed, so you shouldn't need to worry about token expiration. +## Token Management + +The system will use the refresh token to automatically generate short-lived access tokens when needed. Refresh tokens typically don't expire unless revoked. + +### Testing Your Token + +You can test if your token is working correctly: + +1. Navigate to the `/dropbox-setup` page in your DocuNova instance +2. Click the "Test Token" button to verify your credentials +3. If the test fails, click "Refresh Token" to obtain a new refresh token + +## Troubleshooting + +If you encounter issues with Dropbox integration: + +1. **Authentication Errors**: Make sure your App Key and App Secret are correct +2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token +3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations +4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow +5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again + +For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md). diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 985bdce6..15a09a38 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -1,46 +1,73 @@ // frontend/static/js/common.js -(async function checkAuth() { - try { - const resp = await fetch("/api/whoami"); - if (resp.ok) { - const data = await resp.json(); - const authSection = document.getElementById("authSection"); - if (!authSection) return; - authSection.innerHTML = ''; - - const img = document.createElement('img'); - img.src = data.picture; - img.alt = 'User Avatar'; - img.className = 'inline-block h-8 w-8 rounded-full mr-2'; - - const textNode = document.createTextNode('Logged in as '); - const strong = document.createElement('strong'); - strong.textContent = data.email; - - const logoutLink = document.createElement('a'); - logoutLink.href = '/logout'; - logoutLink.className = 'ml-4 text-blue-600 hover:text-blue-800'; - logoutLink.textContent = 'Logout'; - - authSection.appendChild(img); - authSection.appendChild(textNode); - authSection.appendChild(strong); - authSection.appendChild(logoutLink); - } else { - const authSection = document.getElementById("authSection"); - if (authSection) { - authSection.innerHTML = - `Login`; - } - } - } catch (error) { - // Fallback if whoami endpoint fails - const authSection = document.getElementById("authSection"); +// Check authentication status and update the auth section +(async function() { + console.log('Checking authentication status...'); + try { + const response = await fetch('/api/auth/whoami'); + const data = await response.json(); + + + const authSection = document.getElementById("authSection"); + const mobileAuthSection = document.getElementById("mobileAuthSection"); + + // If we have an email, user is authenticated (the whoami endpoint would have thrown 401 otherwise) + if (data.email) { + // Get the display name (prefer name, fall back to preferred_username, then email) + const displayName = data.name || data.preferred_username || data.email; + + // User is logged in + let authHTML = ` +
+ Avatar + ${displayName} + + + +
+ `; + if (authSection) { - authSection.innerHTML = - `Login`; + authSection.innerHTML = authHTML; + } + + if (mobileAuthSection) { + mobileAuthSection.innerHTML = ` +
+
+ Avatar + ${displayName} +
+ + Logout + +
+ `; + } + } else { + // User is not logged in (this shouldn't happen with current setup, but keeping as fallback) + if (authSection) { + authSection.innerHTML = `Login`; + } + + if (mobileAuthSection) { + mobileAuthSection.innerHTML = `Login`; } } - })(); - \ No newline at end of file + } catch (error) { + console.error('Authentication check failed:', error); + // Fallback if whoami endpoint fails + const authSection = document.getElementById("authSection"); + const mobileAuthSection = document.getElementById("mobileAuthSection"); + + if (authSection) { + authSection.innerHTML = `Login`; + } + + if (mobileAuthSection) { + mobileAuthSection.innerHTML = `Login`; + } + } +})(); + +// Other common functionality can be added here diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 1853c9f7..9a958df7 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -7,6 +7,8 @@ + + {% block head_css %} @@ -38,26 +40,58 @@ - - @@ -74,8 +108,6 @@ - - diff --git a/frontend/templates/files copy.html b/frontend/templates/files copy.html new file mode 100644 index 00000000..50c4e56c --- /dev/null +++ b/frontend/templates/files copy.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% block title %}File Records{% endblock %} + +{% block head_extra %} + + + + + + +{% endblock %} + +{% block content %} +
+

File Records

+ +
+ + + +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/frontend/templates/files.html b/frontend/templates/files.html index 43c09983..afe8f4bb 100644 --- a/frontend/templates/files.html +++ b/frontend/templates/files.html @@ -2,169 +2,239 @@ {% block title %}File Records{% endblock %} {% block head_extra %} - - - + + + + + {% endblock %} {% block content %} -
+

File Records

- -
- -