From a3873bb50d59f05005f1f72d0f0dce60fb6767ca Mon Sep 17 00:00:00 2001 From: Unchained Date: Wed, 1 Apr 2026 07:42:34 +0200 Subject: [PATCH] fix(analytics): properly forward client IPs to Rybbit and OpenPanel - Create new API route /api/rybbit/track to proxy Rybbit tracking requests - Extract real client IP from Cloudflare headers (cf-connecting-ip) - Forward X-Forwarded-For and X-Real-IP headers to analytics backends - Update OpenPanel proxy to also forward client IP - Update next.config.ts rewrite to use internal API route This fixes geo-location issues where all traffic appeared to come from Cloudflare edge locations instead of actual visitor countries. --- next.config.ts | 2 +- src/app/api/op/track/route.ts | 11 ++++++ src/app/api/rybbit/track/route.ts | 66 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/app/api/rybbit/track/route.ts diff --git a/next.config.ts b/next.config.ts index 7526f98..e50d98b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,7 @@ const nextConfig: NextConfig = { }, { source: "/api/track", - destination: `${rybbitHost}/api/track`, + destination: "/api/rybbit/track", }, { source: "/api/site/tracking-config/:id", diff --git a/src/app/api/op/track/route.ts b/src/app/api/op/track/route.ts index edb63a4..b782737 100644 --- a/src/app/api/op/track/route.ts +++ b/src/app/api/op/track/route.ts @@ -5,9 +5,20 @@ const OPENPANEL_API_URL = process.env.OPENPANEL_API_URL || "https://op.nodecrew. export async function POST(request: NextRequest) { try { const body = await request.text(); + + // Get the real client IP from various headers + const clientIp = + request.headers.get("cf-connecting-ip") || // Cloudflare + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + request.ip || + "unknown"; + const headers: Record = { "Content-Type": "application/json", "openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "", + "X-Forwarded-For": clientIp, + "X-Real-IP": clientIp, }; if (process.env.OPENPANEL_CLIENT_SECRET) { diff --git a/src/app/api/rybbit/track/route.ts b/src/app/api/rybbit/track/route.ts new file mode 100644 index 0000000..d8c0227 --- /dev/null +++ b/src/app/api/rybbit/track/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; + +const RYBBIT_API_URL = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Get the real client IP from various headers + // Cloudflare headers take precedence + const clientIp = + request.headers.get("cf-connecting-ip") || // Cloudflare + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || // First IP in chain + request.headers.get("x-real-ip") || // Nginx/Traefik + request.ip || // Next.js fallback + "unknown"; + + const userAgent = request.headers.get("user-agent") || ""; + + // Forward to Rybbit backend with proper headers + const response = await fetch(`${RYBBIT_API_URL}/api/track`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": clientIp, + "X-Real-IP": clientIp, + "User-Agent": userAgent, + // Forward Cloudflare headers if present + ...(request.headers.get("cf-ipcountry") && { + "CF-IPCountry": request.headers.get("cf-ipcountry")!, + }), + ...(request.headers.get("cf-ray") && { + "CF-Ray": request.headers.get("cf-ray")!, + }), + }, + body: JSON.stringify(body), + }); + + const data = await response.text(); + return new NextResponse(data, { + status: response.status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch (error) { + console.error("[Rybbit Proxy] Error:", error); + return new NextResponse( + JSON.stringify({ error: "Proxy error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} + +// Handle CORS preflight +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); +}