Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
309 lines
9.9 KiB
Python
309 lines
9.9 KiB
Python
"""
|
|
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)
|
|
|