Files
syndarix/backend/app/utils/device.py
Felipe Cardoso 035e6af446 Add comprehensive tests for session cleanup and async CRUD operations; improve error handling and validation across schemas and API routes
- Introduced extensive tests for session cleanup, async session CRUD methods, and concurrent cleanup to ensure reliability and efficiency.
- Enhanced `schemas/users.py` with reusable password strength validation logic.
- Improved error handling in `admin.py` routes by replacing `detail` with `message` for consistency and readability.
2025-11-01 05:22:45 +01:00

235 lines
7.0 KiB
Python

"""
Utility functions for extracting and parsing device information from HTTP requests.
"""
import re
from typing import Optional
from fastapi import Request
from app.schemas.sessions import DeviceInfo
def extract_device_info(request: Request) -> DeviceInfo:
"""
Extract device information from the HTTP request.
Args:
request: FastAPI Request object
Returns:
DeviceInfo object with parsed device information
"""
user_agent = request.headers.get('user-agent', '')
device_info = DeviceInfo(
device_name=parse_device_name(user_agent),
device_id=request.headers.get('x-device-id'), # Client must send this header
ip_address=get_client_ip(request),
user_agent=user_agent[:500] if user_agent else None, # Truncate to max length
location_city=None, # Can be populated via IP geolocation service
location_country=None, # Can be populated via IP geolocation service
)
return device_info
def parse_device_name(user_agent: str) -> Optional[str]:
"""
Parse user agent string to extract a friendly device name.
Args:
user_agent: User-Agent header string
Returns:
Friendly device name string or None
Examples:
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)" -> "iPhone"
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" -> "Mac"
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -> "Windows PC"
"""
if not user_agent:
return "Unknown device"
user_agent_lower = user_agent.lower()
# Mobile devices (check first, as they can contain desktop patterns too)
if 'iphone' in user_agent_lower:
return "iPhone"
elif 'ipad' in user_agent_lower:
return "iPad"
elif 'android' in user_agent_lower:
# Try to extract device model
android_match = re.search(r'android.*;\s*([^)]+)\s*build', user_agent_lower)
if android_match:
device_model = android_match.group(1).strip()
return f"Android ({device_model.title()})"
return "Android device"
elif 'windows phone' in user_agent_lower:
return "Windows Phone"
# Tablets (check before desktop, as some tablets contain "android")
elif 'tablet' in user_agent_lower:
return "Tablet"
# Smart TVs (check before desktop OS patterns)
elif any(tv in user_agent_lower for tv in ['smart-tv', 'smarttv']):
return "Smart TV"
# Game consoles (check before desktop OS patterns, as Xbox contains "Windows")
elif 'playstation' in user_agent_lower:
return "PlayStation"
elif 'xbox' in user_agent_lower:
return "Xbox"
elif 'nintendo' in user_agent_lower:
return "Nintendo"
# Desktop operating systems
elif 'macintosh' in user_agent_lower or 'mac os x' in user_agent_lower:
# Try to extract browser
browser = extract_browser(user_agent)
return f"{browser} on Mac" if browser else "Mac"
elif 'windows' in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Windows" if browser else "Windows PC"
elif 'linux' in user_agent_lower and 'android' not in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Linux" if browser else "Linux"
elif 'cros' in user_agent_lower:
return "Chromebook"
# Fallback: just return browser name if detected
browser = extract_browser(user_agent)
if browser:
return browser
return "Unknown device"
def extract_browser(user_agent: str) -> Optional[str]:
"""
Extract browser name from user agent string.
Args:
user_agent: User-Agent header string
Returns:
Browser name or None
Examples:
"Mozilla/5.0 ... Chrome/96.0" -> "Chrome"
"Mozilla/5.0 ... Firefox/94.0" -> "Firefox"
"""
if not user_agent:
return None
user_agent_lower = user_agent.lower()
# Check specific browsers (order matters - check Edge before Chrome!)
if 'edg/' in user_agent_lower or 'edge/' in user_agent_lower:
return "Edge"
elif 'opr/' in user_agent_lower or 'opera' in user_agent_lower:
return "Opera"
elif 'chrome/' in user_agent_lower:
return "Chrome"
elif 'safari/' in user_agent_lower:
# Make sure it's actually Safari, not Chrome (which also contains "Safari")
if 'chrome' not in user_agent_lower:
return "Safari"
return None
elif 'firefox/' in user_agent_lower:
return "Firefox"
elif 'msie' in user_agent_lower or 'trident/' in user_agent_lower:
return "Internet Explorer"
return None
def get_client_ip(request: Request) -> Optional[str]:
"""
Extract client IP address from request, considering proxy headers.
Checks X-Forwarded-For and X-Real-IP headers for proxy scenarios.
Args:
request: FastAPI Request object
Returns:
Client IP address string or None
Notes:
- In production behind a proxy/load balancer, X-Forwarded-For is often set
- The first IP in X-Forwarded-For is typically the real client IP
- request.client.host is fallback for direct connections
"""
# Check X-Forwarded-For (common in proxied environments)
x_forwarded_for = request.headers.get('x-forwarded-for')
if x_forwarded_for:
# Get the first IP (original client)
client_ip = x_forwarded_for.split(',')[0].strip()
return client_ip
# Check X-Real-IP (used by some proxies like nginx)
x_real_ip = request.headers.get('x-real-ip')
if x_real_ip:
return x_real_ip.strip()
# Fallback to direct connection IP
if request.client and request.client.host:
return request.client.host
return None
def is_mobile_device(user_agent: str) -> bool:
"""
Check if the device is a mobile device based on user agent.
Args:
user_agent: User-Agent header string
Returns:
True if mobile device, False otherwise
"""
if not user_agent:
return False
mobile_patterns = [
'mobile', 'android', 'iphone', 'ipad', 'ipod',
'blackberry', 'windows phone', 'webos', 'opera mini',
'iemobile', 'mobile safari'
]
user_agent_lower = user_agent.lower()
return any(pattern in user_agent_lower for pattern in mobile_patterns)
def get_device_type(user_agent: str) -> str:
"""
Determine the general device type.
Args:
user_agent: User-Agent header string
Returns:
Device type: "mobile", "tablet", "desktop", or "other"
"""
if not user_agent:
return "other"
user_agent_lower = user_agent.lower()
# Check for tablets first (they can contain "mobile" too)
if 'ipad' in user_agent_lower or 'tablet' in user_agent_lower:
return "tablet"
# Check for mobile
if is_mobile_device(user_agent):
return "mobile"
# Check for desktop OS patterns
if any(os in user_agent_lower for os in ['windows', 'macintosh', 'linux', 'cros']):
return "desktop"
return "other"