docs: add new tools
This commit is contained in:
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 { 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">
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user