Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions apps/web/__tests__/unit/analytics-track-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ vi.mock("@/lib/rate-limit", () => ({
return count > REQUESTS_PER_IP_LIMIT;
},
),
clientAddress: (headers: Headers) => headers.get("x-real-ip") ?? undefined,
}));

vi.mock("@/lib/visitor-location", () => ({
geoIpLocation: vi.fn((ip: string | null | undefined) =>
ip === "198.51.100.7"
? { country: "VE", region: "Zulia", city: "Maracaibo" }
: null,
),
}));

vi.mock("server-only", () => ({}));
Expand Down Expand Up @@ -128,12 +137,21 @@ vi.mock("@/lib/Notification", () => ({

const { POST } = await import("@/app/api/analytics/track/route");
const { createAnonymousViewNotification } = await import("@/lib/Notification");
const { geoIpLocation } = await import("@/lib/visitor-location");

const track = (body: Record<string, unknown>, ip = "203.0.113.1") =>
const track = (
body: Record<string, unknown>,
ip = "203.0.113.1",
headers: Record<string, string> = {},
) =>
POST(
new Request("https://cap.so/api/analytics/track", {
method: "POST",
headers: { "content-type": "application/json", "x-real-ip": ip },
headers: {
"content-type": "application/json",
"x-real-ip": ip,
...headers,
},
body: JSON.stringify(body),
}) as unknown as NextRequest,
);
Expand All @@ -150,6 +168,7 @@ describe("POST /api/analytics/track", () => {
mocks.requestsPerIp.clear();
mocks.rateLimitCalls = [];
vi.mocked(createAnonymousViewNotification).mockClear();
vi.mocked(geoIpLocation).mockClear();
mocks.userId = null;
mocks.orgMember = false;
mocks.verifiedPasswords = [];
Expand Down Expand Up @@ -188,6 +207,34 @@ describe("POST /api/analytics/track", () => {
]);
});

it("takes the location from Vercel's headers when present", async () => {
await track({ videoId: VIDEO_ID }, "198.51.100.7", {
"x-vercel-ip-country": "GB",
"x-vercel-ip-country-region": "ENG",
"x-vercel-ip-city": "London",
});

expect(geoIpLocation).not.toHaveBeenCalled();
expect(recordedEvents()).toEqual([
expect.objectContaining({ country: "GB", region: "ENG", city: "London" }),
]);
});

it("falls back to the GeoIP database without Vercel's headers", async () => {
await track({ videoId: VIDEO_ID }, "198.51.100.7");
await track({ videoId: VIDEO_ID }, "203.0.113.50");

expect(geoIpLocation).toHaveBeenCalledWith("198.51.100.7");
expect(recordedEvents()).toEqual([
expect.objectContaining({
country: "VE",
region: "Zulia",
city: "Maracaibo",
}),
expect.objectContaining({ country: "", region: "", city: "" }),
]);
});

it("does not record views for videos that do not exist", async () => {
mocks.video = undefined;

Expand Down
91 changes: 91 additions & 0 deletions apps/web/__tests__/unit/visitor-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import {
type GeoIpReader,
geoIpLocation,
lookupLocation,
} from "@/lib/visitor-location";

vi.mock("@cap/env", () => ({
serverEnv: () => ({
CAP_GEOIP_DATABASE_PATH: "/nonexistent/dbip-city-lite.mmdb",
}),
}));

describe("geoIpLocation", () => {
it("returns null and warns once when the database file is missing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(geoIpLocation("8.8.8.8")).toBeNull();
expect(geoIpLocation("1.1.1.1")).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
});

const readerWith = (records: Record<string, unknown>): GeoIpReader => ({
get: (ip) => {
if (ip === "not-an-ip") throw new Error("Invalid IP address");
return (records[ip] ?? null) as ReturnType<GeoIpReader["get"]>;
},
});

describe("lookupLocation", () => {
it("reads country, region and city from a GeoIP2 City record", () => {
const reader = readerWith({
"81.2.69.142": {
country: { iso_code: "GB" },
subdivisions: [{ iso_code: "ENG", names: { en: "England" } }],
city: { names: { en: "London" } },
},
});
expect(lookupLocation(reader, "81.2.69.142")).toEqual({
country: "GB",
region: "ENG",
city: "London",
});
});

// DB-IP Lite has subdivision names but no subdivision codes.
it("falls back to the subdivision name when it has no code", () => {
const reader = readerWith({
"190.202.0.1": {
country: { iso_code: "VE" },
subdivisions: [{ names: { en: "Zulia" } }],
city: { names: { en: "San Carlos del Zulia" } },
},
});
expect(lookupLocation(reader, "190.202.0.1")).toEqual({
country: "VE",
region: "Zulia",
city: "San Carlos del Zulia",
});
});

it("keeps the country when the record has no region or city", () => {
const reader = readerWith({
"2001:db8::1": { country: { iso_code: "us" } },
});
expect(lookupLocation(reader, "2001:db8::1")).toEqual({
country: "US",
region: "",
city: "",
});
});

it("returns null for missing, unknown, private and invalid addresses", () => {
const reader = readerWith({
"10.0.0.1": { country: {} },
"203.0.113.9": { country: { iso_code: "EU1" } },
});
for (const ip of [
null,
undefined,
"",
"127.0.0.1",
"10.0.0.1",
"203.0.113.9",
"not-an-ip",
]) {
expect(lookupLocation(reader, ip)).toBeNull();
}
});
});
13 changes: 9 additions & 4 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
createAnonymousViewNotification,
sendFirstViewEmail,
} from "@/lib/Notification";
import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
import { clientAddress, isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
import { runPromise } from "@/lib/server";
import { geoIpLocation } from "@/lib/visitor-location";

const trackPayloadSchema = z.object({
videoId: z.string().min(1).max(64),
Expand Down Expand Up @@ -86,14 +87,18 @@ export async function POST(request: NextRequest) {

const timestamp = viewTimestamp(body.occurredAt, Date.now());

const country =
let country =
sanitizeString(request.headers.get("x-vercel-ip-country")) || "";
const region =
let region =
sanitizeString(request.headers.get("x-vercel-ip-country-region")) || "";
const city =
let city =
sanitizeString(
decodeUrlEncodedHeaderValue(request.headers.get("x-vercel-ip-city")),
) || "";
if (!country) {
const location = geoIpLocation(clientAddress(request.headers));
if (location) ({ country, region, city } = location);
}

await runPromise(
Effect.gen(function* () {
Expand Down
20 changes: 17 additions & 3 deletions apps/web/app/api/mobile/[...route]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
createIdentityEditSpec,
} from "@/lib/video-edits";
import { startVideoProcessingWorkflow } from "@/lib/video-processing";
import { geoIpLocation } from "@/lib/visitor-location";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -2965,6 +2966,19 @@ const trackMobileCapView = Effect.fn("Mobile.trackCapView")(function* (
return { success: true as const };
}

const vercelCountry = readHeader(request, "x-vercel-ip-country");
const location = vercelCountry
? {
country: vercelCountry,
region: readHeader(request, "x-vercel-ip-country-region"),
city: readHeader(request, "x-vercel-ip-city"),
}
: (geoIpLocation(clientAddress(toHeaders(request.headers))) ?? {
country: "",
region: "",
city: "",
});

const tinybird = yield* Tinybird;
yield* tinybird
.appendEvents([
Expand All @@ -2976,9 +2990,9 @@ const trackMobileCapView = Effect.fn("Mobile.trackCapView")(function* (
tenant_id: video.orgId || video.ownerId,
video_id: videoId,
pathname: `/s/${videoId}`,
country: readHeader(request, "x-vercel-ip-country"),
region: readHeader(request, "x-vercel-ip-country-region"),
city: readHeader(request, "x-vercel-ip-city"),
country: location.country,
region: location.region,
city: location.city,
browser: "Cap for iOS",
device: "mobile",
os: "iOS",
Expand Down
72 changes: 72 additions & 0 deletions apps/web/lib/visitor-location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { readFileSync } from "node:fs";
import { serverEnv } from "@cap/env";
import { type CityResponse, Reader } from "mmdb-lib";

export type VisitorLocation = {
country: string;
region: string;
city: string;
};

type GeoIpRecord = {
country?: { iso_code?: string };
subdivisions?: Array<{ iso_code?: string; names?: { en?: string } }>;
city?: { names?: { en?: string } };
};

export type GeoIpReader = { get(ip: string): GeoIpRecord | null };

const MAX_LENGTH = 256;

const clean = (value: string | undefined) =>
value?.trim().slice(0, MAX_LENGTH) ?? "";

export function lookupLocation(
reader: GeoIpReader,
ip: string | null | undefined,
): VisitorLocation | null {
if (!ip) return null;
let record: GeoIpRecord | null;
try {
record = reader.get(ip);
} catch {
return null;
}
const country = clean(record?.country?.iso_code).toUpperCase();
if (!/^[A-Z]{2}$/.test(country)) return null;
const subdivision = record?.subdivisions?.[0];
return {
country,
region: clean(subdivision?.iso_code || subdivision?.names?.en),
city: clean(record?.city?.names?.en),
};
}

let reader: GeoIpReader | null | undefined;

function geoIpReader(): GeoIpReader | null {
if (reader !== undefined) return reader;
reader = null;
const path = serverEnv().CAP_GEOIP_DATABASE_PATH?.trim();
if (!path) return reader;
try {
reader = new Reader<CityResponse>(readFileSync(path));
} catch (error) {
console.warn(
`[geoip] Could not open ${path}; views keep no location`,
error,
);
}
return reader;
}

/**
* Location of an IP from the GeoIP database in CAP_GEOIP_DATABASE_PATH, for
* hosts other than Vercel, which sends no x-vercel-ip-* headers.
*/
export function geoIpLocation(
ip: string | null | undefined,
): VisitorLocation | null {
const current = geoIpReader();
return current ? lookupLocation(current, ip) : null;
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"lucide-react": "^0.525.0",
"media-chrome": "^4.12.0",
"mediabunny": "^1.45.2",
"mmdb-lib": "3.0.3",
"moment": "^2.31.0",
"motion": "^12.18.1",
"next": "16.4.5",
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ function createServerEnv() {
/// Tinybird analytics
TINYBIRD_HOST: z.string().optional(),
TINYBIRD_TOKEN: z.string().optional(),
CAP_GEOIP_DATABASE_PATH: z
.string()
.optional()
.describe(
"Path to a GeoIP2/GeoLite2 City .mmdb file. Fills the country and city of views when the host does not send Vercel's location headers",
),
VERCEL_ENV: z
.union([
z.literal("production"),
Expand Down
Loading