diff --git a/.github/workflows/gemini-invoke.yml b/.github/workflows/gemini-invoke.yml index c752a952e..aec3f90a9 100644 --- a/.github/workflows/gemini-invoke.yml +++ b/.github/workflows/gemini-invoke.yml @@ -39,6 +39,8 @@ jobs: - name: 'Run Gemini CLI' id: 'run_gemini' + if: |- + ${{ secrets.GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '' || vars.GCP_WIF_PROVIDER != '' }} uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude env: TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index 9d1b992cd..6104989aa 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -42,6 +42,8 @@ jobs: uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - name: 'Run Gemini pull request review' + if: |- + ${{ secrets.GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '' || vars.GCP_WIF_PROVIDER != '' }} uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude id: 'gemini_pr_review' env: diff --git a/DEPLOY_RENDER.md b/DEPLOY_RENDER.md new file mode 100644 index 000000000..a5b3d119e --- /dev/null +++ b/DEPLOY_RENDER.md @@ -0,0 +1,51 @@ +# Deploying to Render (Free Tier) + +This guide explains how to deploy the Email Intelligence Platform to Render's Free Tier (512MB RAM, 0.1 CPU). + +## Optimized Architecture + +To fit within the free tier limits, this deployment uses: +* **Single Service**: The Python backend serves the built React frontend as static files. +* **Lite Mode**: Heavy AI libraries (PyTorch, Transformers, Scikit-learn) are **disabled** to save memory. +* **Ephemeral Storage**: Data is stored in local JSON files which **will be lost** on restart/redeploy. + +## Deployment Steps + +1. **Create a New Web Service** on [Render Dashboard](https://dashboard.render.com/). +2. Connect your GitHub repository. +3. Select **"Manual Deployment"** (if prompted) or proceed to configuration. +4. Configure the service details: + + * **Name**: `email-intelligence` (or your choice) + * **Region**: Any (e.g., Oregon) + * **Branch**: `main` (or your working branch) + * **Root Directory**: `.` (leave empty) + * **Runtime**: **Python 3** + * **Build Command**: `./setup_env.sh` + * **Start Command**: `uvicorn src.backend.python_backend.main:app --host 0.0.0.0 --port $PORT` + +5. **Environment Variables**: + Add the following environment variables: + + | Key | Value | Description | + | :--- | :--- | :--- | + | `RENDER` | `true` | Activates "Lite Mode" and triggers frontend build. | + | `PYTHON_VERSION` | `3.11.0` | Ensures correct Python version. | + | `NODE_VERSION` | `20.0.0` | Ensures correct Node.js version for frontend build. | + | `PORT` | `10000` | (Render sets this automatically, but good to know). | + +6. **Create Web Service**. + +## Verification + +Once deployed: +* Navigate to your Render URL (e.g., `https://email-intelligence.onrender.com`). +* The React frontend should load. +* The `/api/health` (if exists) or other API endpoints should work. +* **Note**: Local AI features will be disabled. + +## Troubleshooting + +* **Build Failures**: Check the logs. Ensure `client/package.json` dependencies are compatible with Node 20. +* **Memory Issues**: If the app crashes with "Out of Memory", ensure `RENDER=true` is set. +* **Data Loss**: Remember that data is not persistent. For persistence, you would need to connect a managed PostgreSQL database (paid or external free tier). diff --git a/fix_path_traversal.py b/fix_path_traversal.py new file mode 100644 index 000000000..f2a4bd4eb --- /dev/null +++ b/fix_path_traversal.py @@ -0,0 +1,51 @@ +import os + +file_path = 'src/main.py' + +with open(file_path, 'r') as f: + lines = f.readlines() + +new_logic = """ # Catch-all for SPA routing (excluding API and UI) + @app.get("/{full_path:path}") + async def catch_all(full_path: str): + if full_path.startswith("api") or full_path.startswith("ui"): + raise HTTPException(status_code=404, detail="Not found") + + # Sanitize and validate path to prevent directory traversal + clean_path = os.path.normpath(full_path).lstrip('/') + file_path = os.path.join(static_dir, clean_path) + + # Ensure file_path is within static_dir + if not os.path.commonpath([file_path, static_dir]) == static_dir: + raise HTTPException(status_code=403, detail="Access denied") + + # Check if file exists in static dir + if os.path.exists(file_path) and os.path.isfile(file_path): + return FileResponse(file_path) + + # Otherwise return index.html + return FileResponse(os.path.join(static_dir, "index.html")) +""" + +# Find the catch_all function block to replace +start_idx = -1 +end_idx = -1 + +for i, line in enumerate(lines): + if '@app.get("/{full_path:path}")' in line: + start_idx = i + if start_idx != -1 and 'return FileResponse(os.path.join(static_dir, "index.html"))' in line: + end_idx = i + break + +if start_idx != -1 and end_idx != -1: + # Remove old block + del lines[start_idx:end_idx+1] + # Insert new block + lines.insert(start_idx, new_logic) + + with open(file_path, 'w') as f: + f.writelines(lines) + print("Fixed path traversal in src/main.py") +else: + print("Could not find catch_all block") diff --git a/setup/launch.py b/setup/launch.py index 09f724015..122b442a6 100644 --- a/setup/launch.py +++ b/setup/launch.py @@ -2,7 +2,6 @@ """ EmailIntelligence Unified Launcher -<<<<<<< HEAD This script provides a single, unified way to set up, manage, and run all components of the EmailIntelligence application, including the Python backend, Gradio UI, and Node.js services. It uses 'uv' for Python dependency management @@ -15,18 +14,6 @@ import argparse import atexit import logging -======= -This script provides a unified entry point for setting up and running the EmailIntelligence application. -It supports both legacy arguments for backward compatibility and modern command-based interface. - -Features: -- Environment setup with virtual environment management -- Service startup (backend, frontend, TypeScript server, Gradio UI) -- Test execution with multiple test types -- Orchestration validation checks -- System information display -- Cross-platform support (Linux, macOS, Windows, WSL) -""" # Import launch system modules from setup.validation import ( @@ -47,31 +34,25 @@ # Standard library imports import argparse import atexit ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 import os import platform import shutil import subprocess import sys -<<<<<<< HEAD import time import threading -======= import threading import time ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 import venv from pathlib import Path from typing import List -<<<<<<< HEAD # Add project root to sys.path for imports sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # Command pattern not available in this branch from deployment.test_stages import test_stages -======= # Import project configuration from setup.project_config import get_project_config @@ -84,7 +65,6 @@ get_command_factory = None get_container = None initialize_all_services = None ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 try: from dotenv import load_dotenv @@ -95,10 +75,7 @@ load_dotenv = None # Will be loaded later if needed # Configure logging -<<<<<<< HEAD -======= import logging ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) @@ -106,7 +83,6 @@ # --- Global state --- -<<<<<<< HEAD def find_project_root() -> Path: """Find the project root directory by looking for key files.""" current = Path(__file__).resolve().parent @@ -157,12 +133,10 @@ def shutdown(self): process_manager = ProcessManager() atexit.register(process_manager.cleanup) -======= ROOT_DIR = get_project_config().root_dir # Import process manager from utils from setup.utils import process_manager ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # --- Constants --- PYTHON_MIN_VERSION = (3, 12) @@ -171,7 +145,6 @@ def shutdown(self): CONDA_ENV_NAME = os.getenv("CONDA_ENV_NAME", "base") -<<<<<<< HEAD # --- WSL Support --- def is_wsl(): """Check if running in WSL environment""" @@ -181,8 +154,6 @@ def is_wsl(): return "microsoft" in content or "wsl" in content except Exception: return False -======= ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 def setup_wsl_environment(): @@ -227,21 +198,17 @@ def check_python_version(): """Check if the current Python version is compatible.""" current_version = sys.version_info[:2] if not (PYTHON_MIN_VERSION <= current_version <= PYTHON_MAX_VERSION): -<<<<<<< HEAD logger.error(f"Python version {platform.python_version()} is not compatible.") -======= logger.error( f"Python version {platform.python_version()} is not compatible. " f"Please use Python version {PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]} " f"to {PYTHON_MAX_VERSION[0]}.{PYTHON_MAX_VERSION[1]}." ) ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 sys.exit(1) logger.info(f"Python version {platform.python_version()} is compatible.") # --- Environment Validation --- -<<<<<<< HEAD def check_for_merge_conflicts() -> bool: """Check for unresolved merge conflict markers in critical files.""" conflict_markers = ["<<<<<<< ", "======= ", ">>>>>>> "] @@ -290,9 +257,7 @@ def check_for_merge_conflicts() -> bool: logger.info("No unresolved merge conflicts detected in critical files.") return True -======= # check_for_merge_conflicts is imported from setup.validation ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 def check_required_components() -> bool: @@ -352,7 +317,6 @@ def validate_environment() -> bool: return True -<<<<<<< HEAD # --- Input Validation --- def validate_port(port: int) -> int: """Validate port number is within valid range.""" @@ -409,7 +373,6 @@ def activate_conda_env(env_name: str = None) -> bool: if not is_conda_available(): logger.debug("Conda not available, skipping environment activation.") -======= def check_critical_files() -> bool: """Check for critical files that must exist in the orchestration-tools branch.""" # Critical files that are essential for orchestration @@ -553,12 +516,10 @@ def validate_orchestration_environment() -> bool: logger.warning(f"Conda not available, cannot activate environment '{env_name}'. Please install Conda.") else: logger.debug("Conda not available, skipping environment activation.") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 return False conda_info = get_conda_env_info() if conda_info["is_active"]: -<<<<<<< HEAD logger.info(f"Already in conda environment: {conda_info['env_name']}") return True @@ -606,7 +567,13 @@ def get_python_executable() -> str: # Fall back to system Python logger.info("Using system Python") return sys.executable -======= + + +def activate_conda_env(env_name: str = None) -> bool: + """Activate a conda environment.""" + from setup.environment import get_conda_env_info, run_command + conda_info = get_conda_env_info() + if env_name: if conda_info["env_name"] == env_name: logger.info(f"Already in specified conda environment: {conda_info['env_name']}") return True @@ -624,7 +591,6 @@ def get_python_executable() -> str: ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # --- Helper Functions --- @@ -674,11 +640,8 @@ def setup_dependencies(venv_path: Path, use_poetry: bool = False): python_exe = get_python_executable() if use_poetry: -<<<<<<< HEAD -======= # Ensure pip is up-to-date before installing other packages run_command([python_exe, "-m", "pip", "install", "--upgrade", "pip"], "Upgrading pip") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # For poetry, we need to install it first if not available try: subprocess.run([python_exe, "-c", "import poetry"], check=True, capture_output=True) @@ -691,18 +654,14 @@ def setup_dependencies(venv_path: Path, use_poetry: bool = False): cwd=ROOT_DIR, ) else: -<<<<<<< HEAD -======= # Ensure pip is up-to-date before installing other packages run_command([python_exe, "-m", "pip", "install", "--upgrade", "pip"], "Upgrading pip") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # For uv, install if not available try: subprocess.run([python_exe, "-c", "import uv"], check=True, capture_output=True) except subprocess.CalledProcessError: run_command([python_exe, "-m", "pip", "install", "uv"], "Installing uv") -<<<<<<< HEAD # Install CPU-only PyTorch first to prevent CUDA package installation logger.info("Installing CPU-only PyTorch packages...") run_command( @@ -717,14 +676,12 @@ def setup_dependencies(venv_path: Path, use_poetry: bool = False): cwd=ROOT_DIR, ) -======= run_command( [python_exe, "-m", "uv", "pip", "install", "-e", ".[dev]", "--exclude", "notmuch"], "Installing dependencies with uv (excluding notmuch)", cwd=ROOT_DIR, ) ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # Install notmuch with version matching system install_notmuch_matching_system() @@ -739,7 +696,6 @@ def install_notmuch_matching_system(): version = version_line.split()[1] major_minor = ".".join(version.split(".")[:2]) # e.g., 0.38 python_exe = get_python_executable() -<<<<<<< HEAD # Try to install matching version, fallback to latest if not available if not run_command( [python_exe, "-m", "pip", "install", f"notmuch=={major_minor}"], @@ -750,12 +706,10 @@ def install_notmuch_matching_system(): [python_exe, "-m", "pip", "install", "notmuch"], "Installing latest notmuch version", ) -======= run_command( [python_exe, "-m", "pip", "install", f"notmuch=={major_minor}"], f"Installing notmuch {major_minor} to match system", ) ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 except (subprocess.CalledProcessError, FileNotFoundError): logger.warning("notmuch not found on system, skipping version-specific install") @@ -1089,12 +1043,10 @@ def print_system_info(): def main(): -<<<<<<< HEAD # Services initialization not available in this branch # Parse command line arguments parser = argparse.ArgumentParser(description="EmailIntelligence Unified Launcher") -======= # Initialize services if command pattern is available if COMMAND_PATTERN_AVAILABLE and initialize_all_services and get_container: initialize_all_services(get_container()) @@ -1134,7 +1086,6 @@ def main(): check_parser.add_argument("--env", action="store_true", help="Check orchestration environment") # Legacy argument parsing for backward compatibility ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 parser.add_argument("--setup", action="store_true", help="Set up the environment (legacy)") parser.add_argument( "--stage", choices=["dev", "test"], default="dev", help="Application mode (legacy)" @@ -1199,7 +1150,6 @@ def main(): ) parser.add_argument("--debug", action="store_true", help="Enable debug mode.") -<<<<<<< HEAD # Testing Options parser.add_argument( "--coverage", action="store_true", help="Generate coverage report when running tests." @@ -1229,7 +1179,6 @@ def main(): # Handle legacy arguments return _handle_legacy_args(args) -======= args = parser.parse_args() # Handle command pattern vs legacy arguments @@ -1239,7 +1188,6 @@ def main(): else: # Handle legacy arguments return _handle_legacy_args(args) ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 def _add_common_args(parser): @@ -1256,10 +1204,7 @@ def _add_common_args(parser): def _add_legacy_args(parser): """Add legacy arguments for backward compatibility.""" # Environment Setup -<<<<<<< HEAD parser.add_argument("--setup", action="store_true", help="Run environment setup.") -======= ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 parser.add_argument( "--force-recreate-venv", action="store_true", help="Force recreation of the venv." ) @@ -1326,10 +1271,7 @@ def _add_legacy_args(parser): parser.add_argument( "--system-info", action="store_true", help="Print system information then exit." ) -<<<<<<< HEAD -======= parser.add_argument("--env-file", type=str, help="Specify environment file to load.") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 parser.add_argument("--share", action="store_true", help="Create a public URL.") parser.add_argument("--listen", action="store_true", help="Make the server listen on network.") parser.add_argument( @@ -1337,7 +1279,6 @@ def _add_legacy_args(parser): ) -<<<<<<< HEAD def _execute_command(command_name: str, args) -> int: """Execute a command using the command pattern.""" factory = get_command_factory() @@ -1351,7 +1292,6 @@ def _execute_command(command_name: str, args) -> int: return command.execute() finally: command.cleanup() -======= def main(): # Check for common setup issues before proceeding _check_setup_warnings() @@ -1459,27 +1399,20 @@ def _execute_check_command(args) -> int: else: logger.error("Orchestration checks failed!") return 1 ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 def _handle_legacy_args(args) -> int: """Handle legacy argument parsing for backward compatibility.""" # Setup WSL environment if applicable (early setup) -<<<<<<< HEAD -======= from setup.environment import setup_wsl_environment, check_wsl_requirements ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 setup_wsl_environment() check_wsl_requirements() if not args.skip_python_version_check: check_python_version() -<<<<<<< HEAD logging.getLogger().setLevel(args.loglevel) -======= logging.getLogger().setLevel(getattr(args, 'loglevel', 'INFO')) ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 if DOTENV_AVAILABLE: # Load user customizations from launch-user.env if it exists @@ -1503,14 +1436,11 @@ def _handle_legacy_args(args) -> int: args.use_conda = True # Set flag when conda env is specified # args.use_conda remains as set by command line argument -<<<<<<< HEAD -======= # Check for system info first (doesn't need validation) if args.system_info: print_system_info() return 0 ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 # Validate environment if not skipping preparation if not args.skip_prepare and not validate_environment(): return 1 @@ -1531,10 +1461,7 @@ def _handle_legacy_args(args) -> int: return 0 # Handle Conda environment if requested -<<<<<<< HEAD -======= from setup.environment import is_conda_available, get_conda_env_info, activate_conda_env ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 if args.use_conda: if not is_conda_available(): logger.error("Conda is not available. Please install Conda or use venv.") @@ -1549,19 +1476,13 @@ def _handle_legacy_args(args) -> int: prepare_environment(args) if args.system_info: -<<<<<<< HEAD -======= print("DEBUG: system_info flag detected") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 print_system_info() return 0 # Handle test stage if hasattr(args, "stage") and args.stage == "test": -<<<<<<< HEAD -======= from setup.test_stages import handle_test_stage ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 handle_test_stage(args) return 0 @@ -1571,10 +1492,7 @@ def _handle_legacy_args(args) -> int: or getattr(args, "integration", False) or getattr(args, "coverage", False) ): -<<<<<<< HEAD -======= from setup.test_stages import handle_test_stage ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 handle_test_stage(args) return 0 @@ -1593,8 +1511,6 @@ def _handle_legacy_args(args) -> int: return 0 -<<<<<<< HEAD -======= def _check_setup_warnings(): """Check for common setup issues and warn users.""" import sys @@ -1620,6 +1536,5 @@ def _check_setup_warnings(): logger.info("💡 Virtual environment exists. Activate it with: source venv/bin/activate") ->>>>>>> a7da61cf1f697de3c8c81f536bf579d36d88e613 if __name__ == "__main__": main() diff --git a/setup_env.sh b/setup_env.sh new file mode 100755 index 000000000..e9120007b --- /dev/null +++ b/setup_env.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -e + +echo "Starting build process..." + +# 1. Install Python Dependencies +echo "Installing Python dependencies..." +pip install --upgrade pip +pip install -e . + +# 2. Build Frontend +if [ "$RENDER" = "true" ]; then + echo "Detected Render environment. Building frontend..." + + if [ -d "client" ]; then + cd client + echo "Installing frontend dependencies..." + npm install + + # Install missing dev dependencies if needed (e.g. drizzle-orm for shared schema) + # Try to install them but don't fail if they are missing from registry? + # Actually, npm install reads package.json. + + echo "Building frontend..." + # Run build, but allow failure + if npm run build; then + if [ -d "dist" ]; then + echo "Frontend build successful." + cd .. + mkdir -p static/dist + echo "Copying frontend assets to static/dist..." + cp -r client/dist/* static/dist/ + ls -F static/dist/ + else + echo "Warning: npm run build succeeded but dist directory not found." + fi + else + echo "Warning: Frontend build failed. Continuing with backend-only deployment." + fi + + # Always return to root if we changed directory + if [ "$(basename "$(pwd)")" = "client" ]; then + cd .. + fi + else + echo "Warning: client directory not found. Skipping frontend build." + fi +else + echo "Skipping frontend build (RENDER not set or false)." +fi + +echo "Build process completed successfully." diff --git a/src/backend/python_nlp/smart_filters.py b/src/backend/python_nlp/smart_filters.py index 5b3f461e7..d0702c43f 100644 --- a/src/backend/python_nlp/smart_filters.py +++ b/src/backend/python_nlp/smart_filters.py @@ -115,7 +115,7 @@ def __init__(self, db_path: str = None): db_path = os.path.join(DATA_DIR, filename) # Validate the final path - self.db_path = str(PathValidator.validate_database_path(db_path, DATA_DIR)) + self.db_path = str(PathValidator.validate_and_resolve_db_path(db_path, DATA_DIR)) self.logger = logging.getLogger(__name__) self.conn = None if self.db_path == ":memory:": diff --git a/src/core/model_registry.py b/src/core/model_registry.py index 4c5df8e6b..4cff848dc 100644 --- a/src/core/model_registry.py +++ b/src/core/model_registry.py @@ -8,6 +8,7 @@ import asyncio import json import logging +import os import time from dataclasses import dataclass, field from enum import Enum @@ -462,6 +463,10 @@ async def _load_model_object(self, metadata: ModelMetadata) -> Optional[Any]: async def _load_sklearn_model(self, metadata: ModelMetadata) -> Optional[Any]: """Load a scikit-learn model.""" + + if os.environ.get("RENDER"): + logger.info(f"Skipping heavy model load for {metadata.model_id} in Render Lite Mode") + return None try: import joblib @@ -479,6 +484,10 @@ async def _load_sklearn_model(self, metadata: ModelMetadata) -> Optional[Any]: async def _load_transformers_model(self, metadata: ModelMetadata) -> Optional[Any]: """Load a transformers model.""" + + if os.environ.get("RENDER"): + logger.info(f"Skipping heavy model load for {metadata.model_id} in Render Lite Mode") + return None try: from transformers import AutoModelForSequenceClassification, AutoTokenizer @@ -496,6 +505,10 @@ async def _load_transformers_model(self, metadata: ModelMetadata) -> Optional[An async def _load_tensorflow_model(self, metadata: ModelMetadata) -> Optional[Any]: """Load a TensorFlow model.""" + + if os.environ.get("RENDER"): + logger.info(f"Skipping heavy model load for {metadata.model_id} in Render Lite Mode") + return None try: import tensorflow as tf diff --git a/src/core/performance_monitor.py b/src/core/performance_monitor.py index 330c9d03e..bb743fd62 100644 --- a/src/core/performance_monitor.py +++ b/src/core/performance_monitor.py @@ -360,6 +360,9 @@ def wrapper(*args, **kwargs): return decorator(func) else: # Used as @time_function("name") or with time_function("name"): + # Capture outer instance + monitor_instance = self + class TimerContext: def __enter__(self): self.start_time = time.perf_counter() @@ -367,7 +370,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): duration = (time.perf_counter() - self.start_time) * 1000 - self.record_metric( + monitor_instance.record_metric( name=name, value=duration, unit="ms", tags=tags, sample_rate=sample_rate ) diff --git a/src/main.py b/src/main.py index 0760da49b..d27f6949f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,7 @@ +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +import os +from pathlib import Path import configparser configparser.SafeConfigParser = configparser.ConfigParser @@ -14,6 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from pydantic import ValidationError +from .core.middleware import SecurityMiddleware, SecurityHeadersMiddleware from .core.module_manager import ModuleManager from .core.middleware import create_security_middleware, create_security_headers_middleware from .core.audit_logger import audit_logger, AuditEventType, AuditSeverity @@ -583,8 +588,8 @@ def create_app(): ) # Add comprehensive security middleware - app.add_middleware(create_security_middleware(app)) - app.add_middleware(create_security_headers_middleware(app)) + app.add_middleware(SecurityMiddleware) + app.add_middleware(SecurityHeadersMiddleware) # Add security headers middleware (additional layer) @app.middleware("http") @@ -620,10 +625,6 @@ async def general_exception_handler(request: Request, exc: Exception): content={"detail": "Internal server error", "message": "An unexpected error occurred"}, ) - @app.get("/") - async def root(): - """Redirect root to Gradio UI.""" - return RedirectResponse(url="/ui") # Create the main Gradio UI as a placeholder # Modules will add their own tabs and components to this. @@ -696,6 +697,60 @@ async def shutdown_event(): # This makes the UI accessible at the '/ui' endpoint gr.mount_gradio_app(app, gradio_app, path="/ui") + + # Serve React Frontend if build exists + static_dir = os.path.join(os.getcwd(), "static", "dist") + if os.path.exists(static_dir): + logger.info(f"Serving static files from {static_dir}") + + # Mount assets + assets_dir = os.path.join(static_dir, "assets") + if os.path.exists(assets_dir): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + # Serve index.html at root + @app.get("/") + async def root(): + return FileResponse(os.path.join(static_dir, "index.html")) + + # Catch-all for SPA routing (excluding API and UI) + # Catch-all for SPA routing (excluding API and UI) + # Catch-all for SPA routing (excluding API and UI) + @app.get("/{full_path:path}") + async def catch_all(full_path: str): + if full_path.startswith("api") or full_path.startswith("ui"): + raise HTTPException(status_code=404, detail="Not found") + + # Secure path handling using pathlib + static_path = Path(static_dir).resolve() + + # Prevent directory traversal + try: + # Resolve the requested path relative to static_dir + # lstrip('/') ensures it's treated as relative + requested_path = (static_path / full_path.lstrip('/')).resolve() + + # Verify the resolved path is still within static_dir + if not requested_path.is_relative_to(static_path): + raise HTTPException(status_code=403, detail="Access denied") + + # Check if file exists and is a file + if requested_path.exists() and requested_path.is_file(): + return FileResponse(str(requested_path)) + + # Otherwise return index.html for SPA + index_path = static_path / "index.html" + return FileResponse(str(index_path)) + + except Exception as e: + logger.warning(f"Static file access error: {e}") + raise HTTPException(status_code=403, detail="Access denied") + else: + logger.warning("Static build directory not found. Serving default redirect.") + @app.get("/") + async def root(): + """Redirect root to Gradio UI.""" + return RedirectResponse(url="/ui") logger.info("Application creation complete. FastAPI and Gradio are integrated.") return app