- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest). - Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting. - Updated `requirements.txt` to include Ruff and remove replaced tools. - Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
243 lines
7.0 KiB
Python
243 lines
7.0 KiB
Python
"""
|
|
Utility functions for extracting and parsing device information from HTTP requests.
|
|
"""
|
|
|
|
import re
|
|
|
|
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) -> str | None:
|
|
"""
|
|
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) -> str | None:
|
|
"""
|
|
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) -> str | None:
|
|
"""
|
|
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"
|