From ef85d3bb36cb7a6ebb4f1bc0fb32e457d2fcbbda Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 28 Feb 2026 18:41:04 +0100 Subject: [PATCH] feat(affiliate): /go/ click redirect with rate limiting + click logging 302 redirect (not 301) so every click is tracked. Extracts lang/article_slug from Referer header best-effort. Rate-limited to 60/min per IP; clicks above limit still redirect but are not logged to prevent amplification. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/app.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index f7ea5bd..82ba62a 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -280,6 +280,49 @@ def create_app() -> Quart: except Exception as e: return {"status": "unhealthy", "db": str(e)}, 500 + # ------------------------------------------------------------------------- + # Affiliate click redirect — language-agnostic, no blueprint prefix + # ------------------------------------------------------------------------- + + @app.route("/go/") + async def affiliate_redirect(slug: str): + """302 redirect to affiliate URL, logging the click. + + Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s. + Extracts article_slug and lang from Referer header best-effort. + """ + from .affiliate import get_product, log_click + from .core import check_rate_limit + + # Extract lang from Referer path (e.g. /de/blog/... → "de"), default de + referer = request.headers.get("Referer", "") + lang = "de" + article_slug = None + if referer: + try: + from urllib.parse import urlparse + ref_path = urlparse(referer).path + parts = ref_path.strip("/").split("/") + if parts and len(parts[0]) == 2: + lang = parts[0] + if len(parts) > 1: + article_slug = parts[-1] or None + except Exception: + pass + + product = await get_product(slug, lang) + if not product: + abort(404) + + ip = request.remote_addr or "unknown" + allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60) + if not allowed: + # Still redirect even if rate-limited; just don't log the click + return redirect(product["affiliate_url"], 302) + + await log_click(product["id"], ip, article_slug, referer or None) + return redirect(product["affiliate_url"], 302) + # Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed @app.route("/terms") async def legacy_terms():