v1
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
import user_agents
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth.deps import get_current_user
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.schemas.analytics import AnalyticsSummary, BookingOperationsSummary, EventCreate
|
||||
from app.services.analytics import get_booking_operations_summary, get_summary, record_event
|
||||
|
||||
router = APIRouter(tags=["Analytics"])
|
||||
ANON_COOKIE_NAME = "__gw_anon"
|
||||
ANON_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
||||
CLIENT_METADATA_KEYS = {
|
||||
"area",
|
||||
"channel",
|
||||
"destination",
|
||||
"menu",
|
||||
"plan",
|
||||
"popular",
|
||||
"price",
|
||||
"unit",
|
||||
"variant",
|
||||
}
|
||||
|
||||
_PRIVATE_PREFIXES = ("127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
|
||||
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.", "::1", "localhost")
|
||||
|
||||
|
||||
def _mask_ip(ip: str) -> str:
|
||||
"""Return a privacy-safe partial IP: last octet replaced with 'x'."""
|
||||
if ":" in ip: # IPv6 — keep first 4 groups
|
||||
parts = ip.split(":")
|
||||
return ":".join(parts[:4]) + ":x"
|
||||
parts = ip.split(".")
|
||||
if len(parts) == 4:
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}.x"
|
||||
return ip
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str | None:
|
||||
"""Resolve the best-effort client IP, preferring forwarded headers."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _should_secure_cookie(request: Request) -> bool:
|
||||
"""Use Secure cookies in HTTPS contexts, but allow localhost HTTP development."""
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def _sanitize_client_metadata(metadata: dict | None) -> dict | None:
|
||||
"""Keep only flat, non-identifying telemetry labels from the browser."""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
clean: dict[str, str | int | float | bool] = {}
|
||||
|
||||
for key, value in metadata.items():
|
||||
if not isinstance(key, str) or key not in CLIENT_METADATA_KEYS:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
clean[key] = value[:120]
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
clean[key] = value
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
clean[key] = value
|
||||
|
||||
return clean or None
|
||||
|
||||
|
||||
def _get_or_create_session_id(request: Request, response: Response, payload_session_id: str | None) -> str:
|
||||
"""Use a server-owned anonymous session id, falling back to legacy payload support."""
|
||||
cookie_session_id = request.cookies.get(ANON_COOKIE_NAME)
|
||||
session_id = cookie_session_id or payload_session_id or secrets.token_urlsafe(24)
|
||||
|
||||
if cookie_session_id != session_id:
|
||||
response.set_cookie(
|
||||
key=ANON_COOKIE_NAME,
|
||||
value=session_id,
|
||||
max_age=ANON_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=_should_secure_cookie(request),
|
||||
path="/",
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
|
||||
def _parse_ua(ua_string: str) -> tuple[str | None, str | None]:
|
||||
"""Parse a User-Agent string into (browser, os_name)."""
|
||||
if not ua_string:
|
||||
return None, None
|
||||
ua = user_agents.parse(ua_string)
|
||||
browser = ua.browser.family
|
||||
if browser and browser != "Other" and ua.browser.version_string:
|
||||
major = ua.browser.version_string.split(".")[0]
|
||||
browser = f"{browser} {major}"
|
||||
os_name = ua.os.family
|
||||
if os_name and os_name != "Other" and ua.os.version_string:
|
||||
os_name = f"{os_name} {ua.os.version_string}"
|
||||
return (
|
||||
None if not browser or browser == "Other" else browser[:100],
|
||||
None if not os_name or os_name == "Other" else os_name[:100],
|
||||
)
|
||||
|
||||
|
||||
async def _geo_lookup(ip: str) -> tuple[str | None, str | None]:
|
||||
"""Resolve IP to (country, city) via ip-api.com. Returns (None, None) on failure."""
|
||||
if not ip or any(ip.startswith(p) for p in _PRIVATE_PREFIXES):
|
||||
return None, None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
r = await client.get(
|
||||
f"http://ip-api.com/json/{ip}",
|
||||
params={"fields": "status,country,city"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
if d.get("status") == "success":
|
||||
return d.get("country"), d.get("city")
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
@router.post("/api/web/event", status_code=201)
|
||||
@router.post("/api/analytics/event", status_code=201)
|
||||
@limiter.limit("60/minute")
|
||||
async def ingest_event(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: EventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a telemetry event. Public — no auth required."""
|
||||
raw_ip = _get_client_ip(request)
|
||||
|
||||
ip_hash = hashlib.sha256(raw_ip.encode()).hexdigest()[:16] if raw_ip else None
|
||||
ip_partial = _mask_ip(raw_ip) if raw_ip else None
|
||||
|
||||
ua_string = request.headers.get("User-Agent", "")
|
||||
browser, os_name = _parse_ua(ua_string)
|
||||
|
||||
country, city = await _geo_lookup(raw_ip or "")
|
||||
session_id = _get_or_create_session_id(request, response, data.session_id)
|
||||
|
||||
metadata = _sanitize_client_metadata(data.metadata) or {}
|
||||
referer = request.headers.get("referer")
|
||||
if referer:
|
||||
metadata["referrer"] = referer[:255]
|
||||
|
||||
normalized = data.model_copy(update={
|
||||
"session_id": session_id,
|
||||
"metadata": metadata or None,
|
||||
})
|
||||
|
||||
await record_event(
|
||||
db, normalized,
|
||||
ip_hash=ip_hash,
|
||||
ip_partial=ip_partial,
|
||||
user_agent=ua_string[:512] if ua_string else None,
|
||||
browser=browser,
|
||||
os_name=os_name,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/v1/analytics/summary", response_model=AnalyticsSummary)
|
||||
async def analytics_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return analytics summary. Auth required."""
|
||||
return await get_summary(db)
|
||||
|
||||
|
||||
@router.get("/api/v1/analytics/bookings-summary", response_model=BookingOperationsSummary)
|
||||
async def booking_operations_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return booking operations reporting. Auth required."""
|
||||
return await get_booking_operations_summary(db)
|
||||
Reference in New Issue
Block a user