mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
Every view was being recorded as 127.0.0.1 in Munich. The IP and geo came from @vercel/functions behind `process.env.VERCEL === "1"`, and this app runs on Kubernetes — so the branch was always false and every call took the LOCALHOST_GEO_DATA placeholder. Analytics has been recording a fixed German address for every visitor. Cloudflare fronts dataroom.hanzo.ai (and every hanzo.ai host), so the real values already arrive as request headers: cf-connecting-ip, cf-ipcountry, and — when the zone's visitor-location transform is on — cf-ipcity, cf-region, cf-iplatitude, cf-iplongitude, cf-ipcontinent. lib/utils/geo.ts now reads those. No SDK, no platform binding, and each optional field degrades on its own so a missing city never costs us the country. `cf-connecting-ip` is the one a client cannot forge — Cloudflare rewrites it at the edge. x-forwarded-for is only the fallback for a request that did not come through it, and only its FIRST entry: the rest are proxies, and anything a client sent is appended rather than prepended. This also repairs rate limiting. `send-otp:`, `verify-otp:` and `verify-email:` key their limiter on the caller's address, and off Vercel that address was undefined — so every caller shared one bucket and the limit was effectively global. Verified with npx tsc --noEmit: 100 errors before and after, compared by file+code+message rather than line, since inserting imports shifts line numbers and would otherwise report unchanged errors as new. Co-authored-by: Hanzo Dev <[email protected]>
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
import { NextRequest, userAgent } from "next/server";
|
|
|
|
|
|
|
|
import { recordLinkViewTB } from "@/lib/tinybird";
|
|
import { isBot } from "@/lib/utils/user-agent";
|
|
|
|
import sendNotification from "../api/notification-helper";
|
|
import { sendLinkViewWebhook } from "../api/views/send-webhook-event";
|
|
import { EU_COUNTRY_CODES } from "../constants";
|
|
import { capitalize, getDomainWithoutWWW } from "../utils";
|
|
import { LOCALHOST_GEO_DATA, LOCALHOST_IP, getClientIp, getContinent, getGeoData } from "@/lib/utils/geo";
|
|
|
|
export async function recordLinkView({
|
|
req,
|
|
clickId,
|
|
viewId,
|
|
linkId,
|
|
teamId,
|
|
documentId,
|
|
dataroomId,
|
|
enableNotification,
|
|
}: {
|
|
req: NextRequest;
|
|
clickId: string;
|
|
viewId: string;
|
|
linkId: string;
|
|
teamId: string;
|
|
documentId?: string;
|
|
dataroomId?: string;
|
|
enableNotification: boolean | null;
|
|
}) {
|
|
const ua = userAgent(req);
|
|
const bot = isBot(ua.ua);
|
|
|
|
// don't track clicks from bots
|
|
if (bot) {
|
|
return null;
|
|
}
|
|
|
|
const ip = getClientIp(req.headers) ?? LOCALHOST_IP;
|
|
|
|
// Continent, region and coordinates come from our edge (Cloudflare), so they
|
|
// are the visitor's real location rather than a platform region.
|
|
const geo = getGeoData(req.headers);
|
|
const continent = getContinent(req.headers) ?? LOCALHOST_GEO_DATA.continent;
|
|
const region = geo.region ?? LOCALHOST_GEO_DATA.region;
|
|
|
|
const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);
|
|
|
|
const referer = req.headers.get("referer");
|
|
const refererDomain = referer ? getDomainWithoutWWW(referer) : "(direct)";
|
|
|
|
const clickData = {
|
|
timestamp: new Date(Date.now()).toISOString(),
|
|
click_id: clickId,
|
|
view_id: viewId,
|
|
link_id: linkId,
|
|
document_id: documentId || null,
|
|
dataroom_id: dataroomId || null,
|
|
continent: continent || "",
|
|
country: geo.country || "Unknown",
|
|
region: region || "Unknown",
|
|
city: geo.city || "Unknown",
|
|
latitude: geo.latitude || "Unknown",
|
|
longitude: geo.longitude || "Unknown",
|
|
device: ua.device.type ? capitalize(ua.device.type) : "Desktop",
|
|
device_vendor: ua.device.vendor || "Unknown",
|
|
device_model: ua.device.model || "Unknown",
|
|
browser: ua.browser.name || "Unknown",
|
|
browser_version: ua.browser.version || "Unknown",
|
|
engine: ua.engine.name || "Unknown",
|
|
engine_version: ua.engine.version || "Unknown",
|
|
os: ua.os.name || "Unknown",
|
|
os_version: ua.os.version || "Unknown",
|
|
cpu_architecture: ua.cpu?.architecture || "Unknown",
|
|
ua: ua.ua || "Unknown",
|
|
bot: ua.isBot,
|
|
referer: refererDomain,
|
|
referer_url: referer || "(direct)",
|
|
ip_address:
|
|
// only record IP if it's a valid IP and not from a EU country
|
|
typeof ip === "string" && ip.trim().length > 0 && !isEuCountry
|
|
? ip
|
|
: null,
|
|
};
|
|
|
|
const locationData = {
|
|
continent,
|
|
country: geo.country || "Unknown",
|
|
region: region || "Unknown",
|
|
city: geo.city || "Unknown",
|
|
};
|
|
|
|
const [, ,] = await Promise.all([
|
|
// record link view in Tinybird
|
|
recordLinkViewTB(clickData),
|
|
|
|
// send email notification
|
|
enableNotification ? sendNotification({ viewId, locationData }) : null,
|
|
|
|
// send webhook event
|
|
sendLinkViewWebhook({ teamId, clickData }),
|
|
]);
|
|
|
|
return clickData;
|
|
}
|