# middleware/platform_context.py """ Platform Context Middleware Detects platform from host/domain/path and injects into request.state. This middleware runs BEFORE VendorContextMiddleware to establish platform context. Handles two routing modes: 1. Production: Domain-based (oms.lu, loyalty.lu → Platform detection) 2. Development: Path-based (localhost:9999/oms/*, localhost:9999/loyalty/* → Platform detection) Also provides platform_clean_path for downstream middleware to use. """ import logging from fastapi import Request from sqlalchemy.orm import Session from starlette.middleware.base import BaseHTTPMiddleware from app.core.config import settings from app.core.database import get_db from models.database.platform import Platform logger = logging.getLogger(__name__) # Default platform code for backward compatibility DEFAULT_PLATFORM_CODE = "oms" class PlatformContextManager: """Manages platform context detection for multi-platform routing.""" @staticmethod def detect_platform_context(request: Request) -> dict | None: """ Detect platform context from request. Priority order: 1. Domain-based (production): oms.lu → platform code "oms" 2. Path-based (development): localhost:9999/oms/* → platform code "oms" 3. Default: localhost without path prefix → default platform Returns dict with platform info or None if not detected. """ host = request.headers.get("host", "") path = request.url.path # Remove port from host if present (e.g., localhost:9999 -> localhost) host_without_port = host.split(":")[0] if ":" in host else host # Skip platform detection for admin routes - admin is global if PlatformContextManager.is_admin_request(request): return None # Method 1: Domain-based detection (production) # Check if the host matches a known platform domain # This will be resolved in get_platform_from_context by DB lookup if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]: # Could be a platform domain or a vendor subdomain/custom domain # Check if it's a known platform domain pattern # For now, assume non-localhost hosts that aren't subdomains are platform domains if "." in host_without_port: # This could be: # - Platform domain: oms.lu, loyalty.lu # - Vendor subdomain: vendor.oms.lu # - Custom domain: shop.mycompany.com # We detect platform domain vs subdomain by checking if it's a root domain parts = host_without_port.split(".") if len(parts) == 2: # e.g., oms.lu (root domain) return { "domain": host_without_port, "detection_method": "domain", "host": host, "original_path": path, } # Method 2: Path-based detection (development) # Check for path prefix like /oms/, /loyalty/ if path.startswith("/"): path_parts = path[1:].split("/") # Remove leading / and split if path_parts and path_parts[0]: potential_platform_code = path_parts[0].lower() # Check if this could be a platform code (not vendor paths) if potential_platform_code not in [ "vendor", "vendors", "admin", "api", "static", "media", "assets", "health", "docs", "redoc", "openapi.json", ]: return { "path_prefix": potential_platform_code, "detection_method": "path", "host": host, "original_path": path, "clean_path": "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/", } # Method 3: Default platform for localhost without prefix if host_without_port in ["localhost", "127.0.0.1"]: return { "path_prefix": DEFAULT_PLATFORM_CODE, "detection_method": "default", "host": host, "original_path": path, "clean_path": path, } return None @staticmethod def get_platform_from_context(db: Session, context: dict) -> Platform | None: """ Get platform from database using context information. Supports: 1. Domain-based lookup (Platform.domain) 2. Path-prefix lookup (Platform.path_prefix) 3. Default lookup (Platform.code) """ if not context: return None platform = None # Method 1: Domain-based lookup if context.get("detection_method") == "domain": domain = context.get("domain") if domain: platform = ( db.query(Platform) .filter(Platform.domain == domain) .filter(Platform.is_active == True) .first() ) if platform: logger.debug( f"[PLATFORM] Platform found via domain: {domain} → {platform.name}" ) return platform else: logger.debug(f"[PLATFORM] No platform found for domain: {domain}") # Method 2: Path-prefix lookup if context.get("detection_method") == "path": path_prefix = context.get("path_prefix") if path_prefix: # First try path_prefix, then try code platform = ( db.query(Platform) .filter( (Platform.path_prefix == path_prefix) | (Platform.code == path_prefix) ) .filter(Platform.is_active == True) .first() ) if platform: logger.debug( f"[PLATFORM] Platform found via path prefix: {path_prefix} → {platform.name}" ) return platform else: logger.debug(f"[PLATFORM] No platform found for path prefix: {path_prefix}") # Method 3: Default lookup if context.get("detection_method") == "default": platform = ( db.query(Platform) .filter(Platform.code == DEFAULT_PLATFORM_CODE) .filter(Platform.is_active == True) .first() ) if platform: logger.debug( f"[PLATFORM] Default platform found: {platform.name}" ) return platform return None @staticmethod def extract_clean_path(request: Request, platform_context: dict | None) -> str: """ Extract clean path without platform prefix for routing. Downstream middleware (like VendorContextMiddleware) should use this clean path for their detection logic. """ if not platform_context: return request.url.path # For path-based detection, use the pre-computed clean path if platform_context.get("detection_method") == "path": return platform_context.get("clean_path", request.url.path) # For domain-based or default, path remains unchanged return request.url.path @staticmethod def is_admin_request(request: Request) -> bool: """Check if request is for admin interface.""" host = request.headers.get("host", "") path = request.url.path if ":" in host: host = host.split(":")[0] if host.startswith("admin."): return True if path.startswith("/admin"): return True return False @staticmethod def is_static_file_request(request: Request) -> bool: """Check if request is for static files.""" path = request.url.path.lower() static_extensions = ( ".ico", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".woff", ".woff2", ".ttf", ".eot", ".webp", ".map", ".json", ".xml", ".txt", ".pdf", ".webmanifest", ) static_paths = ("/static/", "/media/", "/assets/", "/.well-known/") if path.endswith(static_extensions): return True if any(path.startswith(static_path) for static_path in static_paths): return True if "favicon.ico" in path: return True return False class PlatformContextMiddleware(BaseHTTPMiddleware): """ Middleware to inject platform context into request state. Runs BEFORE VendorContextMiddleware to establish platform context. Sets: request.state.platform: Platform object request.state.platform_context: Detection metadata request.state.platform_clean_path: Path without platform prefix """ async def dispatch(self, request: Request, call_next): """ Detect and inject platform context. """ # Skip platform detection for static files if PlatformContextManager.is_static_file_request(request): logger.debug( f"[PLATFORM] Skipping platform detection for static file: {request.url.path}" ) request.state.platform = None request.state.platform_context = None request.state.platform_clean_path = request.url.path return await call_next(request) # Skip platform detection for system endpoints if request.url.path in ["/health", "/docs", "/redoc", "/openapi.json"]: logger.debug( f"[PLATFORM] Skipping platform detection for system path: {request.url.path}" ) request.state.platform = None request.state.platform_context = None request.state.platform_clean_path = request.url.path return await call_next(request) # Admin requests are global (no platform context) if PlatformContextManager.is_admin_request(request): logger.debug( f"[PLATFORM] Admin request - no platform context: {request.url.path}" ) request.state.platform = None request.state.platform_context = None request.state.platform_clean_path = request.url.path return await call_next(request) # Detect platform context platform_context = PlatformContextManager.detect_platform_context(request) if platform_context: db_gen = get_db() db = next(db_gen) try: platform = PlatformContextManager.get_platform_from_context( db, platform_context ) if platform: request.state.platform = platform request.state.platform_context = platform_context request.state.platform_clean_path = PlatformContextManager.extract_clean_path( request, platform_context ) logger.debug( "[PLATFORM_CONTEXT] Platform detected", extra={ "platform_id": platform.id, "platform_code": platform.code, "platform_name": platform.name, "detection_method": platform_context.get("detection_method"), "original_path": request.url.path, "clean_path": request.state.platform_clean_path, }, ) else: # Platform code detected but not found in database # This could be a vendor path like /vendors/... logger.debug( "[PLATFORM] Platform code not found, may be vendor path", extra={ "context": platform_context, "detection_method": platform_context.get("detection_method"), }, ) request.state.platform = None request.state.platform_context = None request.state.platform_clean_path = request.url.path finally: db.close() else: logger.debug( "[PLATFORM] No platform context detected", extra={ "path": request.url.path, "host": request.headers.get("host", ""), }, ) request.state.platform = None request.state.platform_context = None request.state.platform_clean_path = request.url.path # Continue to next middleware return await call_next(request) def get_current_platform(request: Request) -> Platform | None: """Helper function to get current platform from request state.""" return getattr(request.state, "platform", None) def require_platform_context(): """Dependency to require platform context in endpoints.""" def dependency(request: Request): platform = get_current_platform(request) if not platform: from fastapi import HTTPException raise HTTPException( status_code=404, detail="Platform not found" ) return platform return dependency