Files
dataroom/pages/api/record_video_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

168 lines
4.9 KiB
TypeScript

import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { EU_COUNTRY_CODES, VIDEO_EVENT_TYPES } from "@/lib/constants";
import { newId } from "@/lib/id-helper";
import { recordVideoView } from "@/lib/tinybird";
import { Geo } from "@/lib/types";
import { capitalize, getDomainWithoutWWW, log } from "@/lib/utils";
import { LOCALHOST_GEO_DATA, getGeoData } from "@/lib/utils/geo";
import { getIpAddress } from "@/lib/utils/ip";
import { userAgentFromString } from "@/lib/utils/user-agent";
const bodyValidation = z.object({
timestamp: z.string(),
id: z.string(),
link_id: z.string(),
document_id: z.string(),
view_id: z.string(),
dataroom_id: z.string().nullable(),
version_number: z.number(),
event_type: z.enum(VIDEO_EVENT_TYPES),
start_time: z.number(),
end_time: z.number(),
playback_rate: z.number().transform((rate) => Math.round(rate * 100)), // 1.5 -> 150
volume: z.number().transform((vol) => Math.round(vol * 100)), // 0.7 -> 70
is_muted: z.number(),
is_focused: z.number(),
is_fullscreen: z.number(),
country: z.string().optional(),
city: z.string().optional(),
region: z.string().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
ua: z.string().optional(),
browser: z.string().optional(),
browser_version: z.string().optional(),
engine: z.string().optional(),
engine_version: z.string().optional(),
os: z.string().optional(),
os_version: z.string().optional(),
device: z.string().optional(),
device_vendor: z.string().optional(),
device_model: z.string().optional(),
cpu_architecture: z.string().optional(),
bot: z.boolean().optional(),
referer: z.string().optional(),
referer_url: z.string().optional(),
ip_address: z.string().nullable(),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const geo: Geo =
getGeoData(req.headers);
const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);
// Get user agent data
const ua = userAgentFromString(req.headers["user-agent"]);
const referer = req.headers.referer;
const refererDomain = referer ? getDomainWithoutWWW(referer) : "(direct)";
const ipAddress = getIpAddress(req.headers);
const videoViewId = newId("videoView");
const {
timestamp,
linkId,
documentId,
viewId,
dataroomId,
versionNumber,
startTime,
endTime,
playbackRate,
volume,
isMuted,
isFocused,
isFullscreen,
eventType,
} = req.body as {
timestamp: string;
linkId: string;
documentId: string;
viewId: string;
dataroomId: string | null;
versionNumber: number;
startTime: number;
endTime: number | undefined;
playbackRate: number;
volume: number;
isMuted: number;
isFocused: number;
isFullscreen: number;
eventType: string;
};
const videoViewObject = {
timestamp: timestamp,
id: videoViewId,
link_id: linkId,
document_id: documentId,
view_id: viewId,
dataroom_id: dataroomId || null,
version_number: versionNumber || 1,
event_type: eventType,
start_time: startTime,
end_time: endTime,
playback_rate: playbackRate,
volume,
is_muted: isMuted ? 1 : 0,
is_focused: isFocused ? 1 : 0,
is_fullscreen: isFullscreen ? 1 : 0,
country: geo?.country || "Unknown",
city: geo?.city || "Unknown",
region: geo?.region || "Unknown",
latitude: geo?.latitude || "Unknown",
longitude: geo?.longitude || "Unknown",
ua: ua.ua || "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",
device: ua.device.type ? capitalize(ua.device.type) : "Desktop",
device_vendor: ua.device.vendor || "Unknown",
device_model: ua.device.model || "Unknown",
cpu_architecture: ua.cpu?.architecture || "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 ipAddress === "string" &&
ipAddress.trim().length > 0 &&
!isEuCountry
? ipAddress
: null,
};
const result = bodyValidation.safeParse(videoViewObject);
if (!result.success) {
return res
.status(400)
.json({ error: `Invalid body: ${result.error.message}` });
}
try {
await recordVideoView(result.data);
res.status(200).json({ message: "Video view recorded" });
} catch (error) {
log({
message: `Failed to record video view (tinybird) for ${linkId}. \n\n ${error}`,
type: "error",
mention: true,
});
res.status(500).json({ error: "Failed to record video view" });
}
}