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

View File

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

View File

@ -12,7 +12,8 @@
"db:migrate:dev": "prisma migrate dev",
"db:migrate:apply": "prisma migrate deploy",
"db:studio": "prisma studio",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest",
"test:ci": "vitest --run"
},
"keywords": [],
"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

@ -95,20 +95,23 @@ model Website {
}
model WebsiteSession {
id String @id @unique @db.Uuid
websiteId String @db.VarChar(30)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
ip String? @db.VarChar(45) // The max length of ipv6 which adapter with ipv4 is 45(for example: [::ffff:192.168.100.228] => 0000:0000:0000:0000:0000:ffff:192.168.100.228)
country String? @db.Char(2)
subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50)
createdAt DateTime @default(now()) @db.Timestamptz(6)
id String @id @unique @db.Uuid
websiteId String @db.VarChar(30)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
ip String? @db.VarChar(45) // The max length of ipv6 which adapter with ipv4 is 45(for example: [::ffff:192.168.100.228] => 0000:0000:0000:0000:0000:ffff:192.168.100.228)
country String? @db.Char(2)
subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50)
longitude Float?
latitude Float?
accuracyRadius Int?
createdAt DateTime @default(now()) @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@ -198,17 +201,20 @@ model WebsiteSessionData {
}
model TelemetrySession {
id String @id @unique @db.Uuid
workspaceId String @db.VarChar(30)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
ip String? @db.VarChar(45)
country String? @db.Char(2)
subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50)
createdAt DateTime @default(now()) @db.Timestamptz(6)
id String @id @unique @db.Uuid
workspaceId String @db.VarChar(30)
hostname String? @db.VarChar(100)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
ip String? @db.VarChar(45)
country String? @db.Char(2)
subdivision1 String? @db.VarChar(20)
subdivision2 String? @db.VarChar(50)
city String? @db.VarChar(50)
longitude Float?
latitude Float?
accuracyRadius Int?
createdAt DateTime @default(now()) @db.Timestamptz(6)
telemetryEvent TelemetryEvent[]
@ -345,12 +351,12 @@ model WorkspaceDailyUsage {
}
model WorkspaceAuditLog {
id String @id @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
id String @id @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
content String
relatedId String?
relatedType WorkspaceAuditLogType?
createdAt DateTime @default(now()) @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)

View File

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

View File

@ -16,6 +16,9 @@ export const WebsiteSessionModelSchema = z.object({
subdivision1: z.string().nullish(),
subdivision2: z.string().nullish(),
city: z.string().nullish(),
longitude: z.number().nullish(),
latitude: z.number().nullish(),
accuracyRadius: z.number().int().nullish(),
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 { getClientIp } from 'request-ip';
import isLocalhost from 'is-localhost-ip';
import { safeDecodeURIComponent } from './common';
import { browserName, detectOS, OperatingSystem } from 'detect-browser';
import {
DESKTOP_OS,
@ -14,16 +13,19 @@ import {
import maxmind, { Reader, CityResponse } from 'maxmind';
import { libraryPath } from './lib';
let lookup: Reader<CityResponse>;
export async function getRequestInfo(req: Request) {
const userAgent = req.headers['user-agent'];
const ip = getIpAddress(req);
const location = await getLocation(ip, req);
const country = location?.country;
const subdivision1 = location?.subdivision1;
const subdivision2 = location?.subdivision2;
const city = location?.city;
const location = await getLocation(ip);
const {
country,
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
} = location ?? {};
const browser = browserName(userAgent ?? '');
const os = detectOS(userAgent ?? '');
@ -36,6 +38,9 @@ export async function getRequestInfo(req: Request) {
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
};
}
@ -68,40 +73,13 @@ export function getIpAddress(req: Request): string {
return getClientIp(req)!;
}
export async function getLocation(ip: string, req: Request) {
let lookup: Reader<CityResponse>;
export async function getLocation(ip: string) {
// Ignore local ips
if (await isLocalhost(ip)) {
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
if (!lookup) {
lookup = await maxmind.open(libraryPath.geoPath);
@ -109,14 +87,19 @@ export async function getLocation(ip: string, req: Request) {
const result = lookup.get(ip);
if (result) {
return {
country: result.country?.iso_code ?? result?.registered_country?.iso_code,
subdivision1: result.subdivisions?.[0]?.iso_code,
subdivision2: result.subdivisions?.[1]?.names?.en,
city: result.city?.names?.en,
};
if (!result) {
return;
}
return {
country: result.country?.iso_code ?? result?.registered_country?.iso_code,
subdivision1: result.subdivisions?.[0]?.iso_code,
subdivision2: result.subdivisions?.[1]?.names?.en,
city: result.city?.names?.en,
longitude: result.location?.longitude,
latitude: result.location?.latitude,
accuracyRadius: result.location?.accuracy_radius,
};
}
function getRegionCode(