feat: parse ip location and storage in db
This commit is contained in:
parent
c049a62493
commit
99a6c91b1b
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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>",
|
||||
|
@ -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;
|
@ -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)
|
||||
|
||||
|
@ -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(),
|
||||
})
|
||||
|
||||
|
@ -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(),
|
||||
})
|
||||
|
||||
|
24
src/server/utils/__tests__/detect.test.ts
Normal file
24
src/server/utils/__tests__/detect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user