docs: add new tools

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-10 13:20:28 +01:00
parent 9bedd39e48
commit ae383001bc
14 changed files with 3116 additions and 4 deletions

View 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 },
);
}
}

View 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 },
);
}
}

View 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;
}

View 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>
);
}

View 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 />
</>
);
}

View 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>
);
}

View 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',
},
];

View 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;
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -1,3 +1,4 @@
import { TOOLS } from '@/app/tools/tools';
import { baseOptions } from '@/lib/layout.shared';
import { articleSource, compareSource } from '@/lib/source';
import { MailIcon } from 'lucide-react';
@@ -39,6 +40,14 @@ export async function Footer() {
{ 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 className="col gap-3">

View 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 };