Skip to content

Commit ae38300

Browse files
committed
docs: add new tools
1 parent 9bedd39 commit ae38300

File tree

14 files changed

+3116
-4
lines changed

14 files changed

+3116
-4
lines changed

apps/public/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"build": "next build",
77
"dev": "next dev",
88
"start": "next start",
9-
"types:check": "fumadocs-mdx && tsc --noEmit",
9+
"typecheck": "fumadocs-mdx && tsc --noEmit",
1010
"postinstall": "fumadocs-mdx",
1111
"lint": "biome check",
1212
"format": "biome format --write"
@@ -15,6 +15,7 @@
1515
"@nivo/funnel": "^0.99.0",
1616
"@number-flow/react": "0.5.10",
1717
"@openpanel/common": "workspace:*",
18+
"@openpanel/geo": "workspace:*",
1819
"@openpanel/nextjs": "^1.1.1",
1920
"@openpanel/payments": "workspace:^",
2021
"@openpanel/sdk-info": "workspace:^",
@@ -23,6 +24,7 @@
2324
"@radix-ui/react-slider": "1.3.6",
2425
"@radix-ui/react-slot": "1.2.4",
2526
"@radix-ui/react-tooltip": "1.2.8",
27+
"cheerio": "^1.0.0",
2628
"class-variance-authority": "0.7.1",
2729
"clsx": "2.1.1",
2830
"dotted-map": "2.2.3",
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import * as dns from 'node:dns/promises';
2+
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
3+
import { getGeoLocation } from '@openpanel/geo';
4+
import { NextResponse } from 'next/server';
5+
6+
interface IPInfo {
7+
ip: string;
8+
location: {
9+
country: string | undefined;
10+
city: string | undefined;
11+
region: string | undefined;
12+
latitude: number | undefined;
13+
longitude: number | undefined;
14+
};
15+
isp: string | null;
16+
asn: string | null;
17+
organization: string | null;
18+
hostname: string | null;
19+
}
20+
21+
interface IPInfoResponse {
22+
ip: string;
23+
location: {
24+
country: string | undefined;
25+
city: string | undefined;
26+
region: string | undefined;
27+
latitude: number | undefined;
28+
longitude: number | undefined;
29+
};
30+
isp: string | null;
31+
asn: string | null;
32+
organization: string | null;
33+
hostname: string | null;
34+
isLocalhost: boolean;
35+
isPrivate: boolean;
36+
}
37+
38+
// Simple rate limiting (in-memory)
39+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
40+
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
41+
const RATE_LIMIT_MAX = 20; // 20 requests per minute
42+
43+
function checkRateLimit(ip: string): boolean {
44+
const now = Date.now();
45+
const record = rateLimitMap.get(ip);
46+
47+
if (!record || now > record.resetAt) {
48+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
49+
return true;
50+
}
51+
52+
if (record.count >= RATE_LIMIT_MAX) {
53+
return false;
54+
}
55+
56+
record.count++;
57+
return true;
58+
}
59+
60+
function isPrivateIP(ip: string): boolean {
61+
// IPv6 loopback
62+
if (ip === '::1') return true;
63+
if (ip.startsWith('::ffff:127.')) return true;
64+
65+
// IPv4 loopback
66+
if (ip.startsWith('127.')) return true;
67+
68+
// IPv4 private ranges
69+
if (ip.startsWith('10.')) return true;
70+
if (ip.startsWith('192.168.')) return true;
71+
if (ip.startsWith('172.')) {
72+
const parts = ip.split('.');
73+
if (parts.length >= 2) {
74+
const secondOctet = Number.parseInt(parts[1] || '0', 10);
75+
if (secondOctet >= 16 && secondOctet <= 31) {
76+
return true;
77+
}
78+
}
79+
}
80+
81+
// IPv6 private ranges
82+
if (
83+
ip.startsWith('fc00:') ||
84+
ip.startsWith('fd00:') ||
85+
ip.startsWith('fe80:')
86+
) {
87+
return true;
88+
}
89+
90+
return false;
91+
}
92+
93+
async function getIPInfo(ip: string): Promise<IPInfo> {
94+
if (!ip || ip === '127.0.0.1' || ip === '::1') {
95+
return {
96+
ip,
97+
location: {
98+
country: undefined,
99+
city: undefined,
100+
region: undefined,
101+
latitude: undefined,
102+
longitude: undefined,
103+
},
104+
isp: null,
105+
asn: null,
106+
organization: null,
107+
hostname: null,
108+
};
109+
}
110+
111+
// Get geolocation
112+
const geo = await getGeoLocation(ip);
113+
114+
// Get ISP/ASN info
115+
let isp: string | null = null;
116+
let asn: string | null = null;
117+
let organization: string | null = null;
118+
119+
if (!isPrivateIP(ip)) {
120+
try {
121+
const controller = new AbortController();
122+
const timeout = setTimeout(() => controller.abort(), 3000);
123+
124+
const response = await fetch(
125+
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,reverse`,
126+
{
127+
signal: controller.signal,
128+
},
129+
);
130+
131+
clearTimeout(timeout);
132+
133+
if (response.ok) {
134+
const data = await response.json();
135+
if (data.status !== 'fail') {
136+
isp = data.isp || null;
137+
asn = data.as ? `AS${data.as.split(' ')[0]}` : null;
138+
organization = data.org || null;
139+
}
140+
}
141+
} catch {
142+
// Ignore errors
143+
}
144+
}
145+
146+
// Reverse DNS lookup for hostname
147+
let hostname: string | null = null;
148+
try {
149+
const hostnames = await dns.reverse(ip);
150+
hostname = hostnames[0] || null;
151+
} catch {
152+
// Ignore errors
153+
}
154+
155+
return {
156+
ip,
157+
location: {
158+
country: geo.country,
159+
city: geo.city,
160+
region: geo.region,
161+
latitude: geo.latitude,
162+
longitude: geo.longitude,
163+
},
164+
isp,
165+
asn,
166+
organization,
167+
hostname,
168+
};
169+
}
170+
171+
export async function GET(request: Request) {
172+
const { searchParams } = new URL(request.url);
173+
const ipParam = searchParams.get('ip');
174+
175+
// Rate limiting
176+
const { ip: clientIp } = getClientIpFromHeaders(request.headers);
177+
if (clientIp && !checkRateLimit(clientIp)) {
178+
return NextResponse.json(
179+
{ error: 'Rate limit exceeded. Please try again later.' },
180+
{ status: 429 },
181+
);
182+
}
183+
184+
let ipToLookup: string;
185+
186+
if (ipParam) {
187+
// Lookup provided IP
188+
ipToLookup = ipParam.trim();
189+
} else {
190+
// Auto-detect client IP
191+
ipToLookup = clientIp || '';
192+
}
193+
194+
if (!ipToLookup) {
195+
return NextResponse.json(
196+
{ error: 'No IP address provided or detected' },
197+
{ status: 400 },
198+
);
199+
}
200+
201+
// Validate IP format (basic check)
202+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
203+
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
204+
if (!ipv4Regex.test(ipToLookup) && !ipv6Regex.test(ipToLookup)) {
205+
return NextResponse.json(
206+
{ error: 'Invalid IP address format' },
207+
{ status: 400 },
208+
);
209+
}
210+
211+
try {
212+
const info = await getIPInfo(ipToLookup);
213+
const isLocalhost = ipToLookup === '127.0.0.1' || ipToLookup === '::1';
214+
const isPrivate = isPrivateIP(ipToLookup);
215+
216+
const response: IPInfoResponse = {
217+
...info,
218+
isLocalhost,
219+
isPrivate,
220+
};
221+
222+
return NextResponse.json(response);
223+
} catch (error) {
224+
console.error('IP lookup error:', error);
225+
return NextResponse.json(
226+
{
227+
error:
228+
error instanceof Error
229+
? error.message
230+
: 'Failed to lookup IP address',
231+
},
232+
{ status: 500 },
233+
);
234+
}
235+
}

0 commit comments

Comments
 (0)