""" Yield Domain Routing API. This handles incoming HTTP requests to yield domains: 1. Detect the domain from the Host header 2. Look up the yield configuration 3. Track the click 4. Redirect to the appropriate affiliate landing page In production, this runs on a separate subdomain or IP (yield.pounce.io) that yield domains CNAME to. """ import logging from datetime import datetime, timedelta from decimal import Decimal from typing import Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import RedirectResponse from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db from app.config import get_settings from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner from app.services.intent_detector import detect_domain_intent from app.services.telemetry import track_event logger = logging.getLogger(__name__) settings = get_settings() router = APIRouter(prefix="/r", tags=["yield-routing"]) # Revenue split USER_REVENUE_SHARE = Decimal("0.70") def hash_ip(ip: str) -> str: """Hash IP for privacy-compliant storage.""" import hashlib # Salt to prevent trivial rainbow table lookups. return hashlib.sha256(f"{ip}|{settings.secret_key}".encode()).hexdigest()[:32] def _get_client_ip(request: Request) -> Optional[str]: # Prefer proxy headers when behind nginx xff = request.headers.get("x-forwarded-for") if xff: # first IP in list ip = xff.split(",")[0].strip() if ip: return ip cf_ip = request.headers.get("cf-connecting-ip") if cf_ip: return cf_ip.strip() return request.client.host if request.client else None def _safe_tracking_url(template: str, *, click_id: str, domain: str, domain_id: int, partner: str) -> str: try: return template.format( click_id=click_id, domain=domain, domain_id=domain_id, partner=partner, ) except KeyError as e: raise HTTPException( status_code=500, detail=f"Partner tracking_url_template uses unsupported placeholder: {str(e)}", ) def generate_tracking_url( partner: AffiliatePartner, yield_domain: YieldDomain, click_id: str, ) -> str: """ Generate the tracking URL for a partner. Most affiliate networks expect parameters like: - clickid / subid: Our click tracking ID - ref: Domain name or user reference """ if not partner.tracking_url_template: raise HTTPException( status_code=503, detail=f"Partner routing not configured for {partner.slug}. Missing tracking_url_template.", ) return _safe_tracking_url( partner.tracking_url_template, click_id=click_id, domain=yield_domain.domain, domain_id=yield_domain.id, partner=partner.slug, ) @router.get("/{domain}") async def route_yield_domain( domain: str, request: Request, db: AsyncSession = Depends(get_db), direct: bool = Query(True, description="Direct redirect without landing page"), ): """ Route traffic for a yield domain. This is the main entry point for yield domain traffic. Query params: - direct: If true, redirect immediately without landing page """ domain = domain.lower().strip() # Find yield domain (must be connected + active) yield_domain = ( await db.execute( select(YieldDomain).where( and_( YieldDomain.domain == domain, YieldDomain.status == "active", YieldDomain.dns_verified == True, or_(YieldDomain.connected_at.is_not(None), YieldDomain.dns_verified_at.is_not(None)), ) ) ) ).scalar_one_or_none() if not yield_domain: logger.warning(f"Route request for unknown/inactive/unconnected domain: {domain}") raise HTTPException(status_code=404, detail="Domain not active for yield routing.") # Resolve partner partner: Optional[AffiliatePartner] = None if yield_domain.partner_id: partner = ( await db.execute( select(AffiliatePartner).where( and_( AffiliatePartner.id == yield_domain.partner_id, AffiliatePartner.is_active == True, ) ) ) ).scalar_one_or_none() if not partner and yield_domain.detected_intent: # Match full detected intent first (e.g. medical_dental) partner = ( await db.execute( select(AffiliatePartner) .where( and_( AffiliatePartner.is_active == True, AffiliatePartner.intent_categories.ilike(f"%{yield_domain.detected_intent}%"), ) ) .order_by(AffiliatePartner.priority.desc()) ) ).scalar_one_or_none() if not partner: raise HTTPException(status_code=503, detail="No active partner available for this domain intent.") # Rate limit: max 120 clicks/10min per IP per domain client_ip = _get_client_ip(request) ip_hash = hash_ip(client_ip) if client_ip else None if ip_hash: cutoff = datetime.utcnow() - timedelta(minutes=10) recent = ( await db.execute( select(func.count(YieldTransaction.id)).where( and_( YieldTransaction.yield_domain_id == yield_domain.id, YieldTransaction.event_type == "click", YieldTransaction.ip_hash == ip_hash, YieldTransaction.created_at >= cutoff, ) ) ) ).scalar() or 0 if recent >= 120: raise HTTPException(status_code=429, detail="Too many requests. Please slow down.") # Compute click economics (only CPC can be accounted immediately) gross = Decimal("0") net = Decimal("0") currency = (partner.payout_currency or "CHF").upper() if (partner.payout_type or "").lower() == "cpc": gross = partner.payout_amount or Decimal("0") net = (gross * USER_REVENUE_SHARE).quantize(Decimal("0.01")) click_id = uuid4().hex destination_url = generate_tracking_url(partner, yield_domain, click_id) user_agent = request.headers.get("user-agent") referrer = request.headers.get("referer") geo_country = request.headers.get("cf-ipcountry") or request.headers.get("x-country") geo_country = geo_country.strip().upper() if geo_country else None transaction = YieldTransaction( yield_domain_id=yield_domain.id, event_type="click", partner_slug=partner.slug, click_id=click_id, destination_url=destination_url[:2000], gross_amount=gross, net_amount=net, currency=currency, referrer=referrer[:500] if referrer else None, user_agent=user_agent[:500] if user_agent else None, geo_country=geo_country[:2] if geo_country else None, ip_hash=ip_hash, status="confirmed", confirmed_at=datetime.utcnow(), ) db.add(transaction) yield_domain.total_clicks += 1 yield_domain.last_click_at = datetime.utcnow() if net > 0: yield_domain.total_revenue += net await track_event( db, event_name="yield_click", request=request, user_id=yield_domain.user_id, is_authenticated=None, source="routing", domain=yield_domain.domain, yield_domain_id=yield_domain.id, click_id=click_id, metadata={"partner": partner.slug, "currency": currency, "net_amount": float(net)}, ) await db.commit() # Only direct redirect for MVP return RedirectResponse(url=destination_url, status_code=302) @router.get("/") async def yield_routing_info(): """Info endpoint for yield routing service.""" return { "service": "Pounce Yield Routing", "version": "2.0.0", "docs": f"{settings.site_url}/docs#/yield-routing", "status": "active", } # ============================================================================ # Host-based routing (for production deployment) # ============================================================================ @router.api_route("/catch-all", methods=["GET", "HEAD"]) async def catch_all_route( request: Request, db: AsyncSession = Depends(get_db), ): """ Catch-all route for host-based routing. In production, this endpoint handles requests where the Host header is the yield domain itself (e.g., zahnarzt-zuerich.ch). This requires: 1. Yield domains to CNAME to yield.pounce.io 2. Nginx/Caddy to route all hosts to this backend 3. This endpoint to parse the Host header """ host = request.headers.get("host", "").lower() # Remove port if present if ":" in host: host = host.split(":")[0] # Skip our own domains our_domains = ["pounce.ch", "pounce.io", "localhost", "127.0.0.1"] if any(host.endswith(d) for d in our_domains): return {"status": "not a yield domain", "host": host} # If host matches a connected yield domain, route it _ = ( await db.execute( select(YieldDomain.id).where( and_( YieldDomain.domain == host, YieldDomain.status == "active", YieldDomain.dns_verified == True, or_(YieldDomain.connected_at.is_not(None), YieldDomain.dns_verified_at.is_not(None)), ) ) ) ).scalar_one_or_none() if not _: raise HTTPException(status_code=404, detail="Host not configured for yield routing.") return RedirectResponse(url=f"/api/v1/r/{host}?direct=true", status_code=302)