docs: add new tools
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"types:check": "fumadocs-mdx && tsc --noEmit",
|
"typecheck": "fumadocs-mdx && tsc --noEmit",
|
||||||
"postinstall": "fumadocs-mdx",
|
"postinstall": "fumadocs-mdx",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@nivo/funnel": "^0.99.0",
|
"@nivo/funnel": "^0.99.0",
|
||||||
"@number-flow/react": "0.5.10",
|
"@number-flow/react": "0.5.10",
|
||||||
"@openpanel/common": "workspace:*",
|
"@openpanel/common": "workspace:*",
|
||||||
|
"@openpanel/geo": "workspace:*",
|
||||||
"@openpanel/nextjs": "^1.1.1",
|
"@openpanel/nextjs": "^1.1.1",
|
||||||
"@openpanel/payments": "workspace:^",
|
"@openpanel/payments": "workspace:^",
|
||||||
"@openpanel/sdk-info": "workspace:^",
|
"@openpanel/sdk-info": "workspace:^",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@radix-ui/react-slider": "1.3.6",
|
"@radix-ui/react-slider": "1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dotted-map": "2.2.3",
|
"dotted-map": "2.2.3",
|
||||||
|
|||||||
235
apps/public/src/app/api/tools/ip-lookup/route.ts
Normal file
235
apps/public/src/app/api/tools/ip-lookup/route.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import * as dns from 'node:dns/promises';
|
||||||
|
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||||
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
interface IPInfo {
|
||||||
|
ip: string;
|
||||||
|
location: {
|
||||||
|
country: string | undefined;
|
||||||
|
city: string | undefined;
|
||||||
|
region: string | undefined;
|
||||||
|
latitude: number | undefined;
|
||||||
|
longitude: number | undefined;
|
||||||
|
};
|
||||||
|
isp: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPInfoResponse {
|
||||||
|
ip: string;
|
||||||
|
location: {
|
||||||
|
country: string | undefined;
|
||||||
|
city: string | undefined;
|
||||||
|
region: string | undefined;
|
||||||
|
latitude: number | undefined;
|
||||||
|
longitude: number | undefined;
|
||||||
|
};
|
||||||
|
isp: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
isLocalhost: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple rate limiting (in-memory)
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX = 20; // 20 requests per minute
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = rateLimitMap.get(ip);
|
||||||
|
|
||||||
|
if (!record || now > record.resetAt) {
|
||||||
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= RATE_LIMIT_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(ip: string): boolean {
|
||||||
|
// IPv6 loopback
|
||||||
|
if (ip === '::1') return true;
|
||||||
|
if (ip.startsWith('::ffff:127.')) return true;
|
||||||
|
|
||||||
|
// IPv4 loopback
|
||||||
|
if (ip.startsWith('127.')) return true;
|
||||||
|
|
||||||
|
// IPv4 private ranges
|
||||||
|
if (ip.startsWith('10.')) return true;
|
||||||
|
if (ip.startsWith('192.168.')) return true;
|
||||||
|
if (ip.startsWith('172.')) {
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const secondOctet = Number.parseInt(parts[1] || '0', 10);
|
||||||
|
if (secondOctet >= 16 && secondOctet <= 31) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 private ranges
|
||||||
|
if (
|
||||||
|
ip.startsWith('fc00:') ||
|
||||||
|
ip.startsWith('fd00:') ||
|
||||||
|
ip.startsWith('fe80:')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIPInfo(ip: string): Promise<IPInfo> {
|
||||||
|
if (!ip || ip === '127.0.0.1' || ip === '::1') {
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
location: {
|
||||||
|
country: undefined,
|
||||||
|
city: undefined,
|
||||||
|
region: undefined,
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
},
|
||||||
|
isp: null,
|
||||||
|
asn: null,
|
||||||
|
organization: null,
|
||||||
|
hostname: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get geolocation
|
||||||
|
const geo = await getGeoLocation(ip);
|
||||||
|
|
||||||
|
// Get ISP/ASN info
|
||||||
|
let isp: string | null = null;
|
||||||
|
let asn: string | null = null;
|
||||||
|
let organization: string | null = null;
|
||||||
|
|
||||||
|
if (!isPrivateIP(ip)) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,reverse`,
|
||||||
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status !== 'fail') {
|
||||||
|
isp = data.isp || null;
|
||||||
|
asn = data.as ? `AS${data.as.split(' ')[0]}` : null;
|
||||||
|
organization = data.org || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse DNS lookup for hostname
|
||||||
|
let hostname: string | null = null;
|
||||||
|
try {
|
||||||
|
const hostnames = await dns.reverse(ip);
|
||||||
|
hostname = hostnames[0] || null;
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
location: {
|
||||||
|
country: geo.country,
|
||||||
|
city: geo.city,
|
||||||
|
region: geo.region,
|
||||||
|
latitude: geo.latitude,
|
||||||
|
longitude: geo.longitude,
|
||||||
|
},
|
||||||
|
isp,
|
||||||
|
asn,
|
||||||
|
organization,
|
||||||
|
hostname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const ipParam = searchParams.get('ip');
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const { ip: clientIp } = getClientIpFromHeaders(request.headers);
|
||||||
|
if (clientIp && !checkRateLimit(clientIp)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipToLookup: string;
|
||||||
|
|
||||||
|
if (ipParam) {
|
||||||
|
// Lookup provided IP
|
||||||
|
ipToLookup = ipParam.trim();
|
||||||
|
} else {
|
||||||
|
// Auto-detect client IP
|
||||||
|
ipToLookup = clientIp || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ipToLookup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No IP address provided or detected' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP format (basic check)
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
||||||
|
if (!ipv4Regex.test(ipToLookup) && !ipv6Regex.test(ipToLookup)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid IP address format' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await getIPInfo(ipToLookup);
|
||||||
|
const isLocalhost = ipToLookup === '127.0.0.1' || ipToLookup === '::1';
|
||||||
|
const isPrivate = isPrivateIP(ipToLookup);
|
||||||
|
|
||||||
|
const response: IPInfoResponse = {
|
||||||
|
...info,
|
||||||
|
isLocalhost,
|
||||||
|
isPrivate,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IP lookup error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to lookup IP address',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
694
apps/public/src/app/api/tools/site-checker/route.ts
Normal file
694
apps/public/src/app/api/tools/site-checker/route.ts
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
import * as dns from 'node:dns/promises';
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import * as tls from 'node:tls';
|
||||||
|
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||||
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10000; // 10 seconds
|
||||||
|
const MAX_REDIRECTS = 10;
|
||||||
|
|
||||||
|
interface RedirectHop {
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
responseTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailedTiming {
|
||||||
|
dns: number;
|
||||||
|
connect: number;
|
||||||
|
tls: number;
|
||||||
|
ttfb: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteCheckResult {
|
||||||
|
url: string;
|
||||||
|
finalUrl: string;
|
||||||
|
timestamp: string;
|
||||||
|
seo: {
|
||||||
|
title: { value: string; length: number };
|
||||||
|
description: { value: string; length: number };
|
||||||
|
canonical: string | null;
|
||||||
|
h1: string[];
|
||||||
|
robotsMeta: string | null;
|
||||||
|
robotsTxtStatus: 'allowed' | 'blocked' | 'error';
|
||||||
|
hasSitemap: boolean;
|
||||||
|
};
|
||||||
|
social: {
|
||||||
|
og: {
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null;
|
||||||
|
url: string | null;
|
||||||
|
type: string | null;
|
||||||
|
};
|
||||||
|
twitter: {
|
||||||
|
card: string | null;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
technical: {
|
||||||
|
statusCode: number;
|
||||||
|
redirectChain: RedirectHop[];
|
||||||
|
responseTime: DetailedTiming;
|
||||||
|
contentType: string;
|
||||||
|
pageSize: number;
|
||||||
|
server: string | null;
|
||||||
|
ssl: {
|
||||||
|
valid: boolean;
|
||||||
|
issuer: string;
|
||||||
|
expires: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
hosting: {
|
||||||
|
ip: string;
|
||||||
|
location: {
|
||||||
|
country: string;
|
||||||
|
countryName?: string;
|
||||||
|
city: string;
|
||||||
|
region: string | null;
|
||||||
|
timezone: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
} | null;
|
||||||
|
isp: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
cdn: string | null;
|
||||||
|
};
|
||||||
|
security: {
|
||||||
|
csp: string | null;
|
||||||
|
xFrameOptions: string | null;
|
||||||
|
xContentTypeOptions: string | null;
|
||||||
|
hsts: string | null;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple rate limiting (in-memory)
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX = 10; // 10 requests per minute
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = rateLimitMap.get(ip);
|
||||||
|
|
||||||
|
if (!record || now > record.resetAt) {
|
||||||
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= RATE_LIMIT_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCDN(headers: Headers): string | null {
|
||||||
|
const server = headers.get('server')?.toLowerCase() || '';
|
||||||
|
const cfRay = headers.get('cf-ray');
|
||||||
|
const vercelId = headers.get('x-vercel-id');
|
||||||
|
const fastly = headers.get('fastly-request-id');
|
||||||
|
const cloudfront = headers.get('x-amz-cf-id');
|
||||||
|
|
||||||
|
if (cfRay || server.includes('cloudflare')) return 'Cloudflare';
|
||||||
|
if (vercelId || server.includes('vercel')) return 'Vercel';
|
||||||
|
if (fastly || server.includes('fastly')) return 'Fastly';
|
||||||
|
if (cloudfront || server.includes('cloudfront')) return 'CloudFront';
|
||||||
|
if (server.includes('nginx')) return 'Nginx';
|
||||||
|
if (server.includes('apache')) return 'Apache';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRobotsTxt(
|
||||||
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<'allowed' | 'blocked' | 'error'> {
|
||||||
|
try {
|
||||||
|
const robotsUrl = new URL('/robots.txt', baseUrl).toString();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const response = await fetch(robotsUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'OpenPanel-SiteChecker/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const rules = text.split('\n').filter((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return trimmed && !trimmed.startsWith('#');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple check: look for Disallow rules that match our path
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.toLowerCase().startsWith('disallow:')) {
|
||||||
|
const pattern = rule.substring(9).trim();
|
||||||
|
if (pattern && path.includes(pattern.replace('*', ''))) {
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'allowed';
|
||||||
|
} catch {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSitemap(baseUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const sitemapUrl = new URL('/sitemap.xml', baseUrl).toString();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
|
||||||
|
const response = await fetch(sitemapUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'OpenPanel-SiteChecker/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSSLInfo(
|
||||||
|
hostname: string,
|
||||||
|
): Promise<SiteCheckResult['technical']['ssl'] | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = tls.connect(
|
||||||
|
{
|
||||||
|
host: hostname,
|
||||||
|
port: 443,
|
||||||
|
servername: hostname,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const cert = socket.getPeerCertificate(true);
|
||||||
|
socket.destroy();
|
||||||
|
|
||||||
|
if (!cert || !cert.valid_to) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
valid: new Date(cert.valid_to) > new Date(),
|
||||||
|
issuer: cert.issuer?.CN || 'Unknown',
|
||||||
|
expires: cert.valid_to,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(null);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHostname(hostname: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const addresses = await dns.resolve4(hostname);
|
||||||
|
return addresses[0] || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPInfo {
|
||||||
|
isp: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIPInfo(ip: string): Promise<IPInfo> {
|
||||||
|
if (!ip || ip === '127.0.0.1' || ip === '::1') {
|
||||||
|
return { isp: null, asn: null, organization: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Using ip-api.com free tier (no API key required, 45 requests/minute)
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,status`,
|
||||||
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { isp: null, asn: null, organization: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check if the API returned an error status
|
||||||
|
if (data.status === 'fail') {
|
||||||
|
return { isp: null, asn: null, organization: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isp: data.isp || null,
|
||||||
|
asn: data.as ? `AS${data.as.split(' ')[0]}` : null, // Format as AS12345
|
||||||
|
organization: data.org || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { isp: null, asn: null, organization: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureDNSLookup(hostname: string): Promise<number> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await dns.resolve4(hostname);
|
||||||
|
return Date.now() - start;
|
||||||
|
} catch {
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureConnectionTime(
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<{ connectTime: number; tlsTime: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now();
|
||||||
|
let connectTime = 0;
|
||||||
|
let tlsTime = 0;
|
||||||
|
|
||||||
|
const socket = net.createConnection(port, hostname, () => {
|
||||||
|
connectTime = Date.now() - start;
|
||||||
|
|
||||||
|
if (port === 443) {
|
||||||
|
// Measure TLS handshake
|
||||||
|
const tlsStart = Date.now();
|
||||||
|
const tlsSocket = tls.connect({
|
||||||
|
socket,
|
||||||
|
servername: hostname,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
tlsSocket.on('secureConnect', () => {
|
||||||
|
tlsTime = Date.now() - tlsStart;
|
||||||
|
tlsSocket.destroy();
|
||||||
|
resolve({ connectTime, tlsTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
tlsSocket.on('error', () => {
|
||||||
|
tlsSocket.destroy();
|
||||||
|
resolve({ connectTime, tlsTime: 0 });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ connectTime, tlsTime: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ connectTime: Date.now() - start, tlsTime: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ connectTime: Date.now() - start, tlsTime: 0 });
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRedirects(
|
||||||
|
url: string,
|
||||||
|
maxRedirects: number = MAX_REDIRECTS,
|
||||||
|
): Promise<{
|
||||||
|
finalUrl: string;
|
||||||
|
redirectChain: RedirectHop[];
|
||||||
|
html: string;
|
||||||
|
headers: Headers;
|
||||||
|
statusCode: number;
|
||||||
|
timing: DetailedTiming;
|
||||||
|
}> {
|
||||||
|
const redirectChain: RedirectHop[] = [];
|
||||||
|
let currentUrl = url;
|
||||||
|
let finalHeaders: Headers | null = null;
|
||||||
|
let finalStatusCode = 0;
|
||||||
|
let finalHtml = '';
|
||||||
|
const totalStartTime = Date.now();
|
||||||
|
|
||||||
|
// Measure DNS lookup and connection time for the first request only
|
||||||
|
let dnsTime = 0;
|
||||||
|
let connectTime = 0;
|
||||||
|
let tlsTime = 0;
|
||||||
|
let ttfbTime = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(currentUrl);
|
||||||
|
const hostname = urlObj.hostname;
|
||||||
|
const port = urlObj.port
|
||||||
|
? Number.parseInt(urlObj.port, 10)
|
||||||
|
: urlObj.protocol === 'https:'
|
||||||
|
? 443
|
||||||
|
: 80;
|
||||||
|
|
||||||
|
// Measure DNS lookup
|
||||||
|
const dnsStart = Date.now();
|
||||||
|
await dns.resolve4(hostname).catch(() => {});
|
||||||
|
dnsTime = Date.now() - dnsStart;
|
||||||
|
|
||||||
|
// Measure connection and TLS (only for first request)
|
||||||
|
if (port === 443 || port === 80) {
|
||||||
|
const connTiming = await measureConnectionTime(hostname, port);
|
||||||
|
connectTime = connTiming.connectTime;
|
||||||
|
tlsTime = connTiming.tlsTime;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If DNS/connection measurement fails, continue anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstRequestStartTime = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRedirects; i++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hopStartTime = Date.now();
|
||||||
|
if (i === 0) {
|
||||||
|
firstRequestStartTime = hopStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (compatible; OpenPanel-SiteChecker/1.0; +https://openpanel.dev)',
|
||||||
|
Accept:
|
||||||
|
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const hopResponseTime = Date.now() - hopStartTime;
|
||||||
|
|
||||||
|
// Measure TTFB (time to first byte) - time until headers are received
|
||||||
|
// TTFB is measured from when fetch starts until headers are received
|
||||||
|
// Note: This includes DNS lookup, connection, TLS handshake, and server processing
|
||||||
|
if (i === 0 && ttfbTime === 0) {
|
||||||
|
const headersReceivedTime = Date.now();
|
||||||
|
ttfbTime = headersReceivedTime - firstRequestStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
finalHeaders = response.headers;
|
||||||
|
finalStatusCode = response.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectChain.push({
|
||||||
|
url: currentUrl,
|
||||||
|
status: response.status,
|
||||||
|
responseTime: hopResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (location) {
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final response
|
||||||
|
finalHtml = await response.text();
|
||||||
|
finalHeaders = response.headers;
|
||||||
|
finalStatusCode = response.status;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timeout');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTime = Date.now() - totalStartTime;
|
||||||
|
|
||||||
|
// Ensure TTFB is reasonable (should be less than total time)
|
||||||
|
// If TTFB wasn't measured or is invalid, estimate it
|
||||||
|
if (ttfbTime === 0 || ttfbTime > totalTime) {
|
||||||
|
// Estimate TTFB as total time minus body download time
|
||||||
|
// Body download is roughly total - (DNS + Connect + TLS + some overhead)
|
||||||
|
const estimatedBodyTime = Math.max(0, totalTime * 0.3); // Assume ~30% is body download
|
||||||
|
ttfbTime = Math.max(50, totalTime - estimatedBodyTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalUrl: currentUrl,
|
||||||
|
redirectChain,
|
||||||
|
html: finalHtml,
|
||||||
|
headers: finalHeaders || new Headers(),
|
||||||
|
statusCode: finalStatusCode,
|
||||||
|
timing: {
|
||||||
|
dns: dnsTime,
|
||||||
|
connect: connectTime,
|
||||||
|
tls: tlsTime,
|
||||||
|
ttfb: ttfbTime,
|
||||||
|
total: totalTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSecurityScore(security: SiteCheckResult['security']): number {
|
||||||
|
let score = 0;
|
||||||
|
if (security.csp) score += 25;
|
||||||
|
if (security.xFrameOptions) score += 15;
|
||||||
|
if (security.xContentTypeOptions) score += 15;
|
||||||
|
if (security.hsts) score += 25;
|
||||||
|
// Additional points for proper values
|
||||||
|
if (
|
||||||
|
security.xFrameOptions?.toLowerCase() === 'deny' ||
|
||||||
|
security.xFrameOptions?.toLowerCase() === 'sameorigin'
|
||||||
|
) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
if (security.xContentTypeOptions?.toLowerCase() === 'nosniff') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
return Math.min(100, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const urlParam = searchParams.get('url');
|
||||||
|
|
||||||
|
if (!urlParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'URL parameter is required' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const { ip } = getClientIpFromHeaders(request.headers);
|
||||||
|
if (ip && !checkRateLimit(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(urlParam);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure protocol
|
||||||
|
if (!url.protocol || !url.protocol.startsWith('http')) {
|
||||||
|
url = new URL(`https://${urlParam}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { finalUrl, redirectChain, html, headers, statusCode, timing } =
|
||||||
|
await fetchWithRedirects(url.toString());
|
||||||
|
|
||||||
|
const finalUrlObj = new URL(finalUrl);
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
// SEO extraction
|
||||||
|
const title = $('title').first().text().trim();
|
||||||
|
const description =
|
||||||
|
$('meta[name="description"]').attr('content') ||
|
||||||
|
$('meta[property="og:description"]').attr('content') ||
|
||||||
|
'';
|
||||||
|
const canonical =
|
||||||
|
$('link[rel="canonical"]').attr('href') ||
|
||||||
|
$('meta[property="og:url"]').attr('content') ||
|
||||||
|
null;
|
||||||
|
const h1Tags = $('h1')
|
||||||
|
.map((_, el) => $(el).text().trim())
|
||||||
|
.get()
|
||||||
|
.filter(Boolean);
|
||||||
|
const robotsMeta =
|
||||||
|
$('meta[name="robots"]').attr('content') ||
|
||||||
|
$('meta[name="googlebot"]').attr('content') ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// Social extraction
|
||||||
|
const ogTitle = $('meta[property="og:title"]').attr('content') || null;
|
||||||
|
const ogDescription =
|
||||||
|
$('meta[property="og:description"]').attr('content') || null;
|
||||||
|
const ogImage = $('meta[property="og:image"]').attr('content') || null;
|
||||||
|
const ogUrl = $('meta[property="og:url"]').attr('content') || null;
|
||||||
|
const ogType = $('meta[property="og:type"]').attr('content') || null;
|
||||||
|
|
||||||
|
const twitterCard = $('meta[name="twitter:card"]').attr('content') || null;
|
||||||
|
const twitterTitle =
|
||||||
|
$('meta[name="twitter:title"]').attr('content') || null;
|
||||||
|
const twitterDescription =
|
||||||
|
$('meta[name="twitter:description"]').attr('content') || null;
|
||||||
|
const twitterImage =
|
||||||
|
$('meta[name="twitter:image"]').attr('content') || null;
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
const csp = headers.get('content-security-policy');
|
||||||
|
const xFrameOptions = headers.get('x-frame-options');
|
||||||
|
const xContentTypeOptions = headers.get('x-content-type-options');
|
||||||
|
const hsts = headers.get('strict-transport-security');
|
||||||
|
|
||||||
|
// Technical info
|
||||||
|
const contentType = headers.get('content-type') || 'unknown';
|
||||||
|
const server = headers.get('server');
|
||||||
|
const pageSize = new Blob([html]).size;
|
||||||
|
|
||||||
|
// Hosting info
|
||||||
|
const serverIp = await resolveHostname(finalUrlObj.hostname);
|
||||||
|
const geo = serverIp ? await getGeoLocation(serverIp) : null;
|
||||||
|
const ipInfo = serverIp
|
||||||
|
? await getIPInfo(serverIp)
|
||||||
|
: { isp: null, asn: null, organization: null };
|
||||||
|
const cdn = detectCDN(headers);
|
||||||
|
|
||||||
|
// SSL info
|
||||||
|
const ssl = await getSSLInfo(finalUrlObj.hostname);
|
||||||
|
|
||||||
|
// Robots.txt check
|
||||||
|
const robotsTxtStatus = await checkRobotsTxt(
|
||||||
|
finalUrl.toString(),
|
||||||
|
finalUrlObj.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sitemap check
|
||||||
|
const hasSitemap = await checkSitemap(finalUrl.toString());
|
||||||
|
|
||||||
|
const security = {
|
||||||
|
csp,
|
||||||
|
xFrameOptions,
|
||||||
|
xContentTypeOptions,
|
||||||
|
hsts,
|
||||||
|
score: 0,
|
||||||
|
};
|
||||||
|
security.score = calculateSecurityScore(security);
|
||||||
|
|
||||||
|
const result: SiteCheckResult = {
|
||||||
|
url: url.toString(),
|
||||||
|
finalUrl,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
seo: {
|
||||||
|
title: { value: title, length: title.length },
|
||||||
|
description: { value: description, length: description.length },
|
||||||
|
canonical: canonical ? new URL(canonical, finalUrl).toString() : null,
|
||||||
|
h1: h1Tags,
|
||||||
|
robotsMeta,
|
||||||
|
robotsTxtStatus,
|
||||||
|
hasSitemap,
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
og: {
|
||||||
|
title: ogTitle,
|
||||||
|
description: ogDescription,
|
||||||
|
image: ogImage ? new URL(ogImage, finalUrl).toString() : null,
|
||||||
|
url: ogUrl ? new URL(ogUrl, finalUrl).toString() : null,
|
||||||
|
type: ogType,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: twitterCard,
|
||||||
|
title: twitterTitle,
|
||||||
|
description: twitterDescription,
|
||||||
|
image: twitterImage
|
||||||
|
? new URL(twitterImage, finalUrl).toString()
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
technical: {
|
||||||
|
statusCode,
|
||||||
|
redirectChain,
|
||||||
|
responseTime: timing,
|
||||||
|
contentType,
|
||||||
|
pageSize,
|
||||||
|
server,
|
||||||
|
ssl,
|
||||||
|
},
|
||||||
|
hosting: {
|
||||||
|
ip: serverIp,
|
||||||
|
location: geo
|
||||||
|
? {
|
||||||
|
country: geo.country || '',
|
||||||
|
city: geo.city || '',
|
||||||
|
region: geo.region || null,
|
||||||
|
timezone: null, // GeoLite2-City doesn't include timezone in current implementation
|
||||||
|
latitude: geo.latitude || null,
|
||||||
|
longitude: geo.longitude || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
isp: ipInfo.isp,
|
||||||
|
asn: ipInfo.asn,
|
||||||
|
organization: ipInfo.organization,
|
||||||
|
cdn,
|
||||||
|
},
|
||||||
|
security,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Site checker error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Failed to analyze site',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/public/src/app/tools/ip-lookup/layout.tsx
Normal file
17
apps/public/src/app/tools/ip-lookup/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getPageMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = getPageMetadata({
|
||||||
|
url: '/tools/ip-lookup',
|
||||||
|
title: 'IP Lookup - Free IP Address Geolocation Tool',
|
||||||
|
description:
|
||||||
|
'Find your IP address and get detailed geolocation information including country, city, ISP, ASN, and coordinates. Free IP lookup tool with map preview.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function IPLookupLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
699
apps/public/src/app/tools/ip-lookup/page.tsx
Normal file
699
apps/public/src/app/tools/ip-lookup/page.tsx
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FaqItem, Faqs } from '@/components/faq';
|
||||||
|
import { FeatureCardContainer } from '@/components/feature-card';
|
||||||
|
import { SectionHeader } from '@/components/section';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Building2,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
MapPin,
|
||||||
|
Network,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface IPInfo {
|
||||||
|
ip: string;
|
||||||
|
location: {
|
||||||
|
country: string | undefined;
|
||||||
|
city: string | undefined;
|
||||||
|
region: string | undefined;
|
||||||
|
latitude: number | undefined;
|
||||||
|
longitude: number | undefined;
|
||||||
|
};
|
||||||
|
isp: string | null;
|
||||||
|
asn: string | null;
|
||||||
|
organization: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
isLocalhost: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPLookupPage() {
|
||||||
|
const [ip, setIp] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [autoDetecting, setAutoDetecting] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isRateLimited, setIsRateLimited] = useState(false);
|
||||||
|
const [result, setResult] = useState<IPInfo | null>(null);
|
||||||
|
|
||||||
|
// Auto-detect IP on page load
|
||||||
|
useEffect(() => {
|
||||||
|
const detectIP = async () => {
|
||||||
|
setAutoDetecting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tools/ip-lookup');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
setIsRateLimited(true);
|
||||||
|
throw new Error(
|
||||||
|
'Rate limit exceeded. Please wait a minute before trying again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsRateLimited(false);
|
||||||
|
throw new Error(data.error || 'Failed to detect IP');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRateLimited(false);
|
||||||
|
setResult(data);
|
||||||
|
setIp(data.ip);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to detect IP');
|
||||||
|
} finally {
|
||||||
|
setAutoDetecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
detectIP();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ip.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/tools/ip-lookup?ip=${encodeURIComponent(ip.trim())}`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
setIsRateLimited(true);
|
||||||
|
throw new Error(
|
||||||
|
'Rate limit exceeded. Please wait a minute before trying again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsRateLimited(false);
|
||||||
|
throw new Error(data.error || 'Failed to lookup IP');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRateLimited(false);
|
||||||
|
|
||||||
|
setResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoCard = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<FeatureCardContainer
|
||||||
|
className={cn('p-4 flex items-start gap-3', className)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground mt-0.5">{icon}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold break-words">
|
||||||
|
{value || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FeatureCardContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCountryFlag = (countryCode?: string): string => {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return '🌐';
|
||||||
|
// Convert country code to flag emoji
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((char) => 127397 + char.charCodeAt(0));
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<SectionHeader
|
||||||
|
title="IP Lookup Tool"
|
||||||
|
description="Find detailed information about any IP address including geolocation, ISP, and network details."
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-8">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter IP address or leave empty to detect yours"
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={loading || autoDetecting} size="lg">
|
||||||
|
{loading || autoDetecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{autoDetecting ? 'Detecting...' : 'Looking up...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="size-4" />
|
||||||
|
Lookup
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-4 p-4 rounded-lg border',
|
||||||
|
isRateLimited
|
||||||
|
? 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'bg-destructive/10 border-destructive/20 text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="size-5 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{error}</div>
|
||||||
|
{isRateLimited && (
|
||||||
|
<div className="text-sm mt-1 opacity-90">
|
||||||
|
You can make up to 20 requests per minute. Please try again
|
||||||
|
shortly.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
{/* IP Address Display */}
|
||||||
|
<FeatureCardContainer>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Globe className="size-6" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{autoDetecting ? 'Detected IP Address' : 'IP Address'}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold font-mono">{result.ip}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(result.isLocalhost || result.isPrivate) && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<span>
|
||||||
|
{result.isLocalhost
|
||||||
|
? 'This is a localhost address'
|
||||||
|
: 'This is a private IP address'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FeatureCardContainer>
|
||||||
|
|
||||||
|
{/* Location Information */}
|
||||||
|
{result.location.country && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="size-5" />
|
||||||
|
Location Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InfoCard
|
||||||
|
icon={<Globe className="size-5" />}
|
||||||
|
label="Country"
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">
|
||||||
|
{getCountryFlag(result.location.country)}
|
||||||
|
</span>
|
||||||
|
<span>{result.location.country}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{result.location.city && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<MapPin className="size-5" />}
|
||||||
|
label="City"
|
||||||
|
value={result.location.city}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.location.region && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<MapPin className="size-5" />}
|
||||||
|
label="Region/State"
|
||||||
|
value={result.location.region}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.location.latitude && result.location.longitude && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<MapPin className="size-5" />}
|
||||||
|
label="Coordinates"
|
||||||
|
value={`${result.location.latitude.toFixed(4)}, ${result.location.longitude.toFixed(4)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Network Information */}
|
||||||
|
{(result.isp ||
|
||||||
|
result.asn ||
|
||||||
|
result.organization ||
|
||||||
|
result.hostname) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Network className="size-5" />
|
||||||
|
Network Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{result.isp && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<Building2 className="size-5" />}
|
||||||
|
label="ISP"
|
||||||
|
value={result.isp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.asn && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<Network className="size-5" />}
|
||||||
|
label="ASN"
|
||||||
|
value={result.asn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.organization && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<Building2 className="size-5" />}
|
||||||
|
label="Organization"
|
||||||
|
value={result.organization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.hostname && (
|
||||||
|
<InfoCard
|
||||||
|
icon={<Server className="size-5" />}
|
||||||
|
label="Hostname"
|
||||||
|
value={result.hostname}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map Preview */}
|
||||||
|
{result.location.latitude && result.location.longitude && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="size-5" />
|
||||||
|
Map Location
|
||||||
|
</h3>
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden bg-card">
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
frameBorder="0"
|
||||||
|
scrolling="no"
|
||||||
|
marginHeight={0}
|
||||||
|
marginWidth={0}
|
||||||
|
src={`https://www.openstreetmap.org/export/embed.html?bbox=${result.location.longitude - 0.1},${result.location.latitude - 0.1},${result.location.longitude + 0.1},${result.location.latitude + 0.1}&layer=mapnik&marker=${result.location.latitude},${result.location.longitude}`}
|
||||||
|
className="w-full aspect-video"
|
||||||
|
title="Map location"
|
||||||
|
/>
|
||||||
|
<div className="p-2 bg-muted border-t border-border text-xs text-center text-muted-foreground flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
©{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org/copyright"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
OpenStreetMap
|
||||||
|
</a>{' '}
|
||||||
|
contributors
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://www.openstreetmap.org/?mlat=${result.location.latitude}&mlon=${result.location.longitude}#map=12/${result.location.latitude}/${result.location.longitude}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View Larger Map
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
|
<div className="pt-4 border-t text-xs text-muted-foreground space-y-2">
|
||||||
|
<div>
|
||||||
|
<strong>Location data:</strong> Powered by{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.maxmind.com/en/geoip2-services-and-databases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
MaxMind GeoLite2
|
||||||
|
</a>{' '}
|
||||||
|
database
|
||||||
|
</div>
|
||||||
|
{(result.isp || result.asn) && (
|
||||||
|
<div>
|
||||||
|
<strong>Network data:</strong> ISP/ASN information from{' '}
|
||||||
|
<a
|
||||||
|
href="https://ip-api.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
ip-api.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SEO Content Section */}
|
||||||
|
<div className="mt-16 prose prose-neutral dark:prose-invert max-w-none">
|
||||||
|
<article className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-4">
|
||||||
|
Free IP Lookup Tool - Find Your IP Address and Geolocation
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground mb-6">
|
||||||
|
Discover your IP address instantly and get detailed geolocation
|
||||||
|
information including country, city, ISP, ASN, and network
|
||||||
|
details. Our free IP lookup tool provides accurate location data
|
||||||
|
powered by MaxMind GeoLite2 database.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
What is an IP Address?
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
An IP (Internet Protocol) address is a unique numerical identifier
|
||||||
|
assigned to every device connected to a computer network. Think of
|
||||||
|
it as a mailing address for your device on the internet. There are
|
||||||
|
two main types:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>IPv4:</strong> The most common format, consisting of
|
||||||
|
four numbers separated by dots (e.g., 192.168.1.1)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>IPv6:</strong> The newer format designed to replace
|
||||||
|
IPv4, using hexadecimal notation (e.g.,
|
||||||
|
2001:0db8:85a3:0000:0000:8a2e:0370:7334)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
How IP Geolocation Works
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
IP geolocation determines the approximate physical location of an
|
||||||
|
IP address by analyzing routing information and regional IP
|
||||||
|
address allocations. Our tool uses:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>MaxMind GeoLite2 Database:</strong> Industry-standard
|
||||||
|
geolocation database providing accurate city and country-level
|
||||||
|
data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>ISP Information:</strong> Internet Service Provider
|
||||||
|
details from ip-api.com
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>ASN Data:</strong> Autonomous System Number identifying
|
||||||
|
the network operator
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Reverse DNS:</strong> Hostname lookup for additional
|
||||||
|
network information
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mb-4">
|
||||||
|
<strong>Important Note:</strong> IP geolocation provides an
|
||||||
|
approximate location, typically accurate to the city level. It
|
||||||
|
shows where your ISP's network infrastructure is located, not
|
||||||
|
necessarily your exact physical address.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Understanding IP Lookup Results
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<h4 className="text-xl font-semibold mt-6 mb-3">Location Data</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Our IP lookup provides detailed geographic information:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>Country:</strong> The country where the IP address is
|
||||||
|
registered or routed through
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>City:</strong> The city-level location (when available)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Region/State:</strong> State or province information
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Coordinates:</strong> Latitude and longitude for mapping
|
||||||
|
purposes
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="text-xl font-semibold mt-6 mb-3">
|
||||||
|
Network Information
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">Technical details about the network:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>ISP (Internet Service Provider):</strong> The company
|
||||||
|
providing internet service for this IP address
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>ASN (Autonomous System Number):</strong> A unique number
|
||||||
|
identifying the network operator's routing domain
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Organization:</strong> The registered organization name
|
||||||
|
for the IP address
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Hostname:</strong> Reverse DNS lookup result showing the
|
||||||
|
domain name associated with the IP
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Common Use Cases for IP Lookup
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>Security:</strong> Identify suspicious login attempts or
|
||||||
|
track potential security threats
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Content Localization:</strong> Display region-specific
|
||||||
|
content based on user location
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Analytics:</strong> Understand where your website
|
||||||
|
visitors are located
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Compliance:</strong> Ensure content restrictions based
|
||||||
|
on geographic regulations
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Network Troubleshooting:</strong> Diagnose routing
|
||||||
|
issues and network problems
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fraud Prevention:</strong> Detect unusual patterns or
|
||||||
|
verify user locations
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Public vs Private IP Addresses
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
Understanding the difference between public and private IP
|
||||||
|
addresses:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="text-xl font-semibold mt-6 mb-3">
|
||||||
|
Public IP Address
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
A public IP address is assigned by your ISP and is visible to the
|
||||||
|
internet. This is what websites see when you visit them. Our tool
|
||||||
|
detects your public IP address automatically.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 className="text-xl font-semibold mt-6 mb-3">
|
||||||
|
Private IP Address
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Private IP addresses are used within local networks (home, office,
|
||||||
|
etc.) and are not routable on the public internet. Common private
|
||||||
|
IP ranges include:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>10.0.0.0 to 10.255.255.255</li>
|
||||||
|
<li>172.16.0.0 to 172.31.255.255</li>
|
||||||
|
<li>192.168.0.0 to 192.168.255.255</li>
|
||||||
|
<li>127.0.0.1 (localhost)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mb-4">
|
||||||
|
Our tool will indicate if an IP address is private or localhost.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Privacy and IP Addresses
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
Your IP address reveals some information about your location and
|
||||||
|
network, but it doesn't expose your exact physical address or
|
||||||
|
personal identity. Here's what you should know:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
IP addresses show general location (city/region level), not
|
||||||
|
exact addresses
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Your ISP assigns IP addresses, which may change periodically
|
||||||
|
</li>
|
||||||
|
<li>Using a VPN can mask your real IP address and location</li>
|
||||||
|
<li>Websites can see your IP address when you visit them</li>
|
||||||
|
<li>
|
||||||
|
IP addresses alone cannot identify individual users without
|
||||||
|
additional data
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
How to Use Our IP Lookup Tool
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-3 mb-6 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>Auto-Detection:</strong> When you visit the page, your
|
||||||
|
IP address is automatically detected and displayed
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Manual Lookup:</strong> Enter any IP address (IPv4 or
|
||||||
|
IPv6) in the input field to look up its details
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>View Results:</strong> See comprehensive information
|
||||||
|
including location, ISP, ASN, and network details
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Map View:</strong> Click on the map preview to view the
|
||||||
|
location on Google Maps
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Faqs>
|
||||||
|
<FaqItem question="How accurate is IP geolocation?">
|
||||||
|
IP geolocation is typically accurate to the city level, with
|
||||||
|
accuracy varying by region. It shows where your ISP's network
|
||||||
|
infrastructure is located, not your exact physical address.
|
||||||
|
</FaqItem>
|
||||||
|
|
||||||
|
<FaqItem question="Can I hide my IP address?">
|
||||||
|
Yes, you can use a VPN (Virtual Private Network) or proxy
|
||||||
|
service to mask your real IP address. This will show the VPN
|
||||||
|
server's IP address instead of yours.
|
||||||
|
</FaqItem>
|
||||||
|
|
||||||
|
<FaqItem question="Why does my IP location seem wrong?">
|
||||||
|
Your IP location reflects where your ISP's network equipment is
|
||||||
|
located, which may be in a different city than your actual
|
||||||
|
location. This is especially common with mobile networks or
|
||||||
|
certain ISPs.
|
||||||
|
</FaqItem>
|
||||||
|
|
||||||
|
<FaqItem question="Is IP lookup free?">
|
||||||
|
Yes, our IP lookup tool is completely free to use. We limit
|
||||||
|
requests to 20 per minute per IP to ensure fair usage.
|
||||||
|
</FaqItem>
|
||||||
|
|
||||||
|
<FaqItem question="What is ASN?">
|
||||||
|
ASN (Autonomous System Number) is a unique identifier for a
|
||||||
|
network operator's routing domain on the internet. It helps
|
||||||
|
identify which organization controls a particular IP address
|
||||||
|
range.
|
||||||
|
</FaqItem>
|
||||||
|
</Faqs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t pt-8 mt-8">
|
||||||
|
<h3 className="text-2xl font-semibold mb-4">
|
||||||
|
Start Using Our Free IP Lookup Tool
|
||||||
|
</h3>
|
||||||
|
<p className="mb-6">
|
||||||
|
Discover your IP address and explore detailed geolocation
|
||||||
|
information instantly. Our tool automatically detects your IP when
|
||||||
|
you visit, or you can look up any IP address manually. Get
|
||||||
|
comprehensive network and location data powered by
|
||||||
|
industry-leading databases.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<strong>Tip:</strong> Bookmark this page to quickly check your IP
|
||||||
|
address anytime. Useful for troubleshooting network issues or
|
||||||
|
understanding your online presence.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/public/src/app/tools/layout.tsx
Normal file
25
apps/public/src/app/tools/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Footer } from '@/components/footer';
|
||||||
|
import Navbar from '@/components/navbar';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import ToolsSidebar from './tools-sidebar';
|
||||||
|
|
||||||
|
export default function ToolsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<div className="min-h-screen mt-12 md:mt-32">
|
||||||
|
<div className="container py-8 md:py-12">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
<main className="lg:col-span-3">{children}</main>
|
||||||
|
<ToolsSidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/public/src/app/tools/tools-sidebar.tsx
Normal file
45
apps/public/src/app/tools/tools-sidebar.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { TOOLS } from './tools';
|
||||||
|
|
||||||
|
export default function ToolsSidebar(): React.ReactElement {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside>
|
||||||
|
<div className="lg:sticky lg:top-24">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{TOOLS.map((tool) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
const isActive = pathname === tool.url;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tool.url}
|
||||||
|
href={tool.url}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50 text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/public/src/app/tools/tools.tsx
Normal file
23
apps/public/src/app/tools/tools.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
GlobeIcon,
|
||||||
|
Link2Icon,
|
||||||
|
QrCodeIcon,
|
||||||
|
SearchIcon,
|
||||||
|
TimerIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const TOOLS = [
|
||||||
|
{
|
||||||
|
name: 'URL Checker',
|
||||||
|
url: '/tools/url-checker',
|
||||||
|
icon: SearchIcon,
|
||||||
|
description:
|
||||||
|
'Analyze any URL for SEO, social, technical, and security data',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IP Lookup',
|
||||||
|
url: '/tools/ip-lookup',
|
||||||
|
icon: GlobeIcon,
|
||||||
|
description: 'Find your IP address and geolocation data',
|
||||||
|
},
|
||||||
|
];
|
||||||
17
apps/public/src/app/tools/url-checker/layout.tsx
Normal file
17
apps/public/src/app/tools/url-checker/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getPageMetadata } from '@/lib/metadata';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = getPageMetadata({
|
||||||
|
url: '/tools/url-checker',
|
||||||
|
title: 'URL Checker - Free Website & SEO Analysis Tool',
|
||||||
|
description:
|
||||||
|
'Analyze any website for SEO, social media, technical, and security information. Check meta tags, Open Graph, redirects, SSL certificates, and more.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function IPLookupLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
1225
apps/public/src/app/tools/url-checker/page.tsx
Normal file
1225
apps/public/src/app/tools/url-checker/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
76
apps/public/src/app/tools/url-checker/social-preview.tsx
Normal file
76
apps/public/src/app/tools/url-checker/social-preview.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SocialPreviewProps {
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null;
|
||||||
|
url: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialPreview({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
domain,
|
||||||
|
}: SocialPreviewProps) {
|
||||||
|
const displayTitle = title || 'No title set';
|
||||||
|
const displayDescription = description || 'No description set';
|
||||||
|
const hasImage = !!image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden bg-card shadow-sm">
|
||||||
|
{/* Platform header */}
|
||||||
|
<div className="px-3 py-2 bg-muted border-b border-border flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={`https://api.openpanel.dev/misc/favicon?url=${encodeURIComponent(url)}`}
|
||||||
|
alt="Favicon"
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold text-foreground">{domain}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
{hasImage ? (
|
||||||
|
<div className="relative w-full aspect-[1.91/1] bg-muted">
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
const parent = target.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.innerHTML =
|
||||||
|
'<div class="w-full h-full flex items-center justify-center text-muted-foreground text-sm">Image failed to load</div>';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-[1.91/1] bg-muted flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground text-sm">No image</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
{domain}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-foreground line-clamp-2">
|
||||||
|
{displayTitle}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{displayDescription}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground pt-1">
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
<span className="truncate">{url}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TOOLS } from '@/app/tools/tools';
|
||||||
import { baseOptions } from '@/lib/layout.shared';
|
import { baseOptions } from '@/lib/layout.shared';
|
||||||
import { articleSource, compareSource } from '@/lib/source';
|
import { articleSource, compareSource } from '@/lib/source';
|
||||||
import { MailIcon } from 'lucide-react';
|
import { MailIcon } from 'lucide-react';
|
||||||
@@ -39,6 +40,14 @@ export async function Footer() {
|
|||||||
{ title: 'Compare', url: '/compare' },
|
{ title: 'Compare', url: '/compare' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<div className="h-5" />
|
||||||
|
<h3 className="font-medium">Tools</h3>
|
||||||
|
<Links
|
||||||
|
data={TOOLS.map((tool) => ({
|
||||||
|
title: tool.name,
|
||||||
|
url: tool.url,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col gap-3">
|
<div className="col gap-3">
|
||||||
|
|||||||
39
apps/public/src/components/ui/input.tsx
Normal file
39
apps/public/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const inputVariants = cva(
|
||||||
|
'flex w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
default: 'h-10',
|
||||||
|
sm: 'h-8 text-xs',
|
||||||
|
lg: 'h-12 text-base',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
|
||||||
|
VariantProps<typeof inputVariants> {
|
||||||
|
ref?: React.RefObject<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = ({ className, type, size, ref, ...props }: InputProps) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(inputVariants({ size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Input, inputVariants };
|
||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -271,6 +271,9 @@ importers:
|
|||||||
'@openpanel/common':
|
'@openpanel/common':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/common
|
version: link:../../packages/common
|
||||||
|
'@openpanel/geo':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/geo
|
||||||
'@openpanel/nextjs':
|
'@openpanel/nextjs':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
version: 1.1.1(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||||
@@ -295,6 +298,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: 1.2.8
|
specifier: 1.2.8
|
||||||
version: 1.2.8(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
version: 1.2.8(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||||
|
cheerio:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.1.2
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -25412,7 +25418,7 @@ snapshots:
|
|||||||
css-what: 6.1.0
|
css-what: 6.1.0
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
domutils: 3.1.0
|
domutils: 3.2.2
|
||||||
|
|
||||||
cheerio@1.0.0-rc.12:
|
cheerio@1.0.0-rc.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -25794,7 +25800,7 @@ snapshots:
|
|||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
css-what: 6.1.0
|
css-what: 6.1.0
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
domutils: 3.1.0
|
domutils: 3.2.2
|
||||||
nth-check: 2.1.1
|
nth-check: 2.1.1
|
||||||
|
|
||||||
css-tree@3.1.0:
|
css-tree@3.1.0:
|
||||||
@@ -28136,7 +28142,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
domutils: 3.1.0
|
domutils: 3.2.2
|
||||||
entities: 4.5.0
|
entities: 4.5.0
|
||||||
|
|
||||||
http-cache-semantics@4.1.1: {}
|
http-cache-semantics@4.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user