Files
dataroom/lib/tracking/record-link-view.ts
zeekayandHanzo Dev 8b3607cd0d geo: read the visitor's location from our own edge
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]>
2026-08-09 10:27:33 -07:00

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;
}