feat: parse ip location and storage in db

This commit is contained in:
moonrailgun 2024-01-29 18:54:44 +08:00
parent c049a62493
commit 99a6c91b1b
9 changed files with 119 additions and 88 deletions

View File

@ -80,6 +80,9 @@ async function findSession(req: Request, url: string) {
subdivision1, subdivision1,
subdivision2, subdivision2,
city, city,
longitude,
latitude,
accuracyRadius,
} = await getRequestInfo(req); } = await getRequestInfo(req);
const sessionId = hashUuid(workspaceId, hostname, ip, userAgent!); const sessionId = hashUuid(workspaceId, hostname, ip, userAgent!);
@ -99,6 +102,9 @@ async function findSession(req: Request, url: string) {
subdivision1, subdivision1,
subdivision2, subdivision2,
city, city,
longitude,
latitude,
accuracyRadius,
}, },
}); });
} catch (e: any) { } catch (e: any) {

View File

@ -26,21 +26,11 @@ export interface WebsiteEventPayload {
name?: string; name?: string;
} }
export async function findSession(req: Request): Promise<{ export async function findSession(req: Request): Promise<
id: any; WebsiteSession & {
websiteId: string;
hostname: string;
browser: string;
os: any;
device: string;
screen: string;
language: string;
country: any;
subdivision1: any;
subdivision2: any;
city: any;
workspaceId: string; workspaceId: string;
}> { }
> {
// Verify payload // Verify payload
const { payload } = req.body; const { payload } = req.body;
@ -88,6 +78,9 @@ export async function findSession(req: Request): Promise<{
subdivision1, subdivision1,
subdivision2, subdivision2,
city, city,
longitude,
latitude,
accuracyRadius,
device, device,
} = await getClientInfo(req, payload); } = await getClientInfo(req, payload);
@ -114,6 +107,9 @@ export async function findSession(req: Request): Promise<{
subdivision1, subdivision1,
subdivision2, subdivision2,
city, city,
longitude,
latitude,
accuracyRadius,
}, },
}); });
} catch (e: any) { } catch (e: any) {

View File

@ -12,7 +12,8 @@
"db:migrate:dev": "prisma migrate dev", "db:migrate:dev": "prisma migrate dev",
"db:migrate:apply": "prisma migrate deploy", "db:migrate:apply": "prisma migrate deploy",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "echo \"Error: no test specified\" && exit 1" "test": "vitest",
"test:ci": "vitest --run"
}, },
"keywords": [], "keywords": [],
"author": "moonrailgun <moonrailgun@gmail.com>", "author": "moonrailgun <moonrailgun@gmail.com>",

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "TelemetrySession" ADD COLUMN "accuracyRadius" INTEGER,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "WebsiteSession" ADD COLUMN "accuracyRadius" INTEGER,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;

View File

@ -108,6 +108,9 @@ model WebsiteSession {
subdivision1 String? @db.VarChar(20) subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50) subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50) city String? @db.VarChar(50)
longitude Float?
latitude Float?
accuracyRadius Int?
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id], onUpdate: Cascade, onDelete: Cascade) website Website @relation(fields: [websiteId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@ -208,6 +211,9 @@ model TelemetrySession {
subdivision1 String? @db.VarChar(20) subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50) subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50) city String? @db.VarChar(50)
longitude Float?
latitude Float?
accuracyRadius Int?
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
telemetryEvent TelemetryEvent[] telemetryEvent TelemetryEvent[]

View File

@ -13,6 +13,9 @@ export const TelemetrySessionModelSchema = z.object({
subdivision1: z.string().nullish(), subdivision1: z.string().nullish(),
subdivision2: z.string().nullish(), subdivision2: z.string().nullish(),
city: z.string().nullish(), city: z.string().nullish(),
longitude: z.number().nullish(),
latitude: z.number().nullish(),
accuracyRadius: z.number().int().nullish(),
createdAt: z.date(), createdAt: z.date(),
}) })

View File

@ -16,6 +16,9 @@ export const WebsiteSessionModelSchema = z.object({
subdivision1: z.string().nullish(), subdivision1: z.string().nullish(),
subdivision2: z.string().nullish(), subdivision2: z.string().nullish(),
city: z.string().nullish(), city: z.string().nullish(),
longitude: z.number().nullish(),
latitude: z.number().nullish(),
accuracyRadius: z.number().int().nullish(),
createdAt: z.date(), createdAt: z.date(),
}) })

View File

@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import { getLocation } from '../detect';
describe('detect', () => {
describe('getLocation', () => {
test('should detect local ip', async () => {
const location = await getLocation('127.0.0.1');
expect(location).toBeUndefined();
});
test('should detect public ip', async () => {
const location = await getLocation('76.76.21.123');
expect(location).toHaveProperty('country', 'US');
expect(location).toHaveProperty('subdivision1', 'CA');
expect(location).toHaveProperty('subdivision2', undefined);
expect(location).toHaveProperty('city', 'Walnut');
expect(location).toHaveProperty('longitude', -117.8512);
expect(location).toHaveProperty('latitude', 34.0233);
expect(location).toHaveProperty('accuracy_radius', 20);
});
});
});

View File

@ -2,7 +2,6 @@ import { Request } from 'express';
import type { WebsiteEventPayload } from '../model/website'; import type { WebsiteEventPayload } from '../model/website';
import { getClientIp } from 'request-ip'; import { getClientIp } from 'request-ip';
import isLocalhost from 'is-localhost-ip'; import isLocalhost from 'is-localhost-ip';
import { safeDecodeURIComponent } from './common';
import { browserName, detectOS, OperatingSystem } from 'detect-browser'; import { browserName, detectOS, OperatingSystem } from 'detect-browser';
import { import {
DESKTOP_OS, DESKTOP_OS,
@ -14,16 +13,19 @@ import {
import maxmind, { Reader, CityResponse } from 'maxmind'; import maxmind, { Reader, CityResponse } from 'maxmind';
import { libraryPath } from './lib'; import { libraryPath } from './lib';
let lookup: Reader<CityResponse>;
export async function getRequestInfo(req: Request) { export async function getRequestInfo(req: Request) {
const userAgent = req.headers['user-agent']; const userAgent = req.headers['user-agent'];
const ip = getIpAddress(req); const ip = getIpAddress(req);
const location = await getLocation(ip, req); const location = await getLocation(ip);
const country = location?.country; const {
const subdivision1 = location?.subdivision1; country,
const subdivision2 = location?.subdivision2; subdivision1,
const city = location?.city; subdivision2,
city,
longitude,
latitude,
accuracyRadius,
} = location ?? {};
const browser = browserName(userAgent ?? ''); const browser = browserName(userAgent ?? '');
const os = detectOS(userAgent ?? ''); const os = detectOS(userAgent ?? '');
@ -36,6 +38,9 @@ export async function getRequestInfo(req: Request) {
subdivision1, subdivision1,
subdivision2, subdivision2,
city, city,
longitude,
latitude,
accuracyRadius,
}; };
} }
@ -68,40 +73,13 @@ export function getIpAddress(req: Request): string {
return getClientIp(req)!; return getClientIp(req)!;
} }
export async function getLocation(ip: string, req: Request) { let lookup: Reader<CityResponse>;
export async function getLocation(ip: string) {
// Ignore local ips // Ignore local ips
if (await isLocalhost(ip)) { if (await isLocalhost(ip)) {
return; return;
} }
// Cloudflare headers
if (req.headers['cf-ipcountry']) {
const country = safeDecodeURIComponent(req.headers['cf-ipcountry']);
const subdivision1 = safeDecodeURIComponent(req.headers['cf-region-code']);
const city = safeDecodeURIComponent(req.headers['cf-ipcity']);
return {
country,
subdivision1: getRegionCode(country, subdivision1),
city,
};
}
// Vercel headers
if (req.headers['x-vercel-ip-country']) {
const country = safeDecodeURIComponent(req.headers['x-vercel-ip-country']);
const subdivision1 = safeDecodeURIComponent(
req.headers['x-vercel-ip-country-region']
);
const city = safeDecodeURIComponent(req.headers['x-vercel-ip-city']);
return {
country,
subdivision1: getRegionCode(country, subdivision1),
city,
};
}
// Database lookup // Database lookup
if (!lookup) { if (!lookup) {
lookup = await maxmind.open(libraryPath.geoPath); lookup = await maxmind.open(libraryPath.geoPath);
@ -109,15 +87,20 @@ export async function getLocation(ip: string, req: Request) {
const result = lookup.get(ip); const result = lookup.get(ip);
if (result) { if (!result) {
return;
}
return { return {
country: result.country?.iso_code ?? result?.registered_country?.iso_code, country: result.country?.iso_code ?? result?.registered_country?.iso_code,
subdivision1: result.subdivisions?.[0]?.iso_code, subdivision1: result.subdivisions?.[0]?.iso_code,
subdivision2: result.subdivisions?.[1]?.names?.en, subdivision2: result.subdivisions?.[1]?.names?.en,
city: result.city?.names?.en, city: result.city?.names?.en,
longitude: result.location?.longitude,
latitude: result.location?.latitude,
accuracyRadius: result.location?.accuracy_radius,
}; };
} }
}
function getRegionCode( function getRegionCode(
country: string | null | undefined, country: string | null | undefined,