4 Commits

Author SHA1 Message Date
typist
24c154a759 0.0.11
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m15s
2025-10-29 06:36:32 +08:00
typist
edf87370d9 feat: add IP Query tool
- Introduced a new tool for querying IP information, including location and risk assessment.
- Updated the tool index to include the new IP Query component and its corresponding icon.
- Added IPQuery component with functionality to validate and fetch IP data from external APIs.
2025-10-29 06:36:20 +08:00
typist
ae0f9447ea 0.0.10
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
2025-10-29 06:09:10 +08:00
typist
972b6c7f22 feat: enhance input handling for network tools
- Added domain and URL normalization on blur for DNS, Ping, Speedtest, and TCPing components.
- Improved user experience by ensuring valid input formats and extracting ports where applicable.
2025-10-29 06:08:47 +08:00
8 changed files with 435 additions and 22 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "litek", "name": "litek",
"private": true, "private": true,
"version": "0.0.9", "version": "0.0.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi } from 'lucide-react' import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
import UUID from './uuid' import UUID from './uuid'
import JSON from './json' import JSON from './json'
import Base64 from './base64' import Base64 from './base64'
import { DNS, Ping, TCPing, SpeedTest } from './network' import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network'
export interface Tool { export interface Tool {
path: string; path: string;
@@ -71,6 +71,13 @@ export const tools: Tool[] = [
icon: <Gauge />, icon: <Gauge />,
component: <SpeedTest />, component: <SpeedTest />,
}, },
{
path: "ipquery",
name: "IP Query",
description: "Query IP location, quality and risk info",
icon: <MapPin />,
component: <IPQuery />,
},
], ],
}, },
]; ];

View File

@@ -41,6 +41,26 @@ const Tool: FC = () => {
const [results, setResults] = useState<DNSRecord[]>([]); const [results, setResults] = useState<DNSRecord[]>([]);
const [queryTime, setQueryTime] = useState<number>(0); const [queryTime, setQueryTime] = useState<number>(0);
const handleDomainBlur = () => {
if (!domain.trim()) return;
let input = domain.trim();
let cleanDomain = input;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanDomain = url.hostname;
} catch {
// If parsing fails, fallback to manual cleanup
cleanDomain = input.replace(/^https?:\/\//, "").split("/")[0].split(":")[0];
}
if (cleanDomain !== input) {
setDomain(cleanDomain);
}
};
const queryDNS = async () => { const queryDNS = async () => {
if (!domain.trim()) { if (!domain.trim()) {
toast.error("Please enter a domain name"); toast.error("Please enter a domain name");
@@ -58,7 +78,7 @@ const Tool: FC = () => {
const queries = DNS_RECORD_TYPES.map((recordType) => const queries = DNS_RECORD_TYPES.map((recordType) =>
fetch( fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(
domain domain.trim()
)}&type=${recordType.value}`, )}&type=${recordType.value}`,
{ {
headers: { headers: {
@@ -127,6 +147,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com" placeholder="e.g. example.com"
value={domain} value={domain}
onChange={(e) => setDomain(e.target.value)} onChange={(e) => setDomain(e.target.value)}
onBlur={handleDomainBlur}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={loading} disabled={loading}
/> />

View File

@@ -2,4 +2,5 @@ export { default as DNS } from './dns';
export { default as Ping } from './ping'; export { default as Ping } from './ping';
export { default as TCPing } from './tcping'; export { default as TCPing } from './tcping';
export { default as SpeedTest } from './speedtest'; export { default as SpeedTest } from './speedtest';
export { default as IPQuery } from './ipquery';

View File

@@ -0,0 +1,312 @@
import { useState, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface IPInfo {
ip: string;
city?: string;
region?: string;
country?: string;
countryCode?: string;
loc?: string;
org?: string;
timezone?: string;
isp?: string;
as?: string;
proxy?: boolean;
hosting?: boolean;
query?: string;
}
const Tool: FC = () => {
const [ip, setIp] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null);
const [queryTime, setQueryTime] = useState<number>(0);
const isValidIP = (ip: string): boolean => {
// IPv4 正则
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// IPv6 正则 (简化版)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split('.');
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
}
return ipv6Regex.test(ip);
};
const queryCurrentIP = async () => {
setLoading(true);
setIpInfo(null);
setQueryTime(0);
const startTime = performance.now();
try {
// 使用 ipinfo.io 查询当前IP (免费,无需密钥)
const response = await fetch("https://ipinfo.io/json");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const endTime = performance.now();
setQueryTime(endTime - startTime);
setIpInfo(data);
setIp(data.ip);
toast.success("Successfully queried current IP");
} catch (error) {
if (error instanceof Error) {
toast.error(`Query failed: ${error.message}`);
} else {
toast.error("Query failed");
}
} finally {
setLoading(false);
}
};
const queryIP = async () => {
if (!ip.trim()) {
toast.error("Please enter an IP address");
return;
}
if (!isValidIP(ip.trim())) {
toast.error("Invalid IP address format");
return;
}
setLoading(true);
setIpInfo(null);
setQueryTime(0);
const startTime = performance.now();
try {
// 使用 ip-api.com (免费,功能较全)
const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip.trim())}?fields=status,message,country,countryCode,region,city,lat,lon,timezone,isp,org,as,proxy,hosting,query`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const endTime = performance.now();
setQueryTime(endTime - startTime);
if (data.status === "fail") {
toast.error(data.message || "Query failed");
return;
}
// 转换为统一格式
const ipData: IPInfo = {
ip: data.query,
city: data.city,
region: data.region,
country: data.country,
countryCode: data.countryCode,
loc: data.lat && data.lon ? `${data.lat},${data.lon}` : undefined,
timezone: data.timezone,
isp: data.isp,
org: data.org,
as: data.as,
proxy: data.proxy,
hosting: data.hosting,
};
setIpInfo(ipData);
toast.success("Query successful");
} catch (error) {
if (error instanceof Error) {
toast.error(`Query failed: ${error.message}`);
} else {
toast.error("Query failed");
}
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !loading) {
queryIP();
}
};
const getRiskLevel = () => {
if (!ipInfo) return null;
if (ipInfo.proxy || ipInfo.hosting) {
return {
level: "High",
color: "text-red-500",
reasons: [
ipInfo.proxy && "Proxy/VPN detected",
ipInfo.hosting && "Hosting/Datacenter IP",
].filter(Boolean),
};
}
return {
level: "Low",
color: "text-green-500",
reasons: ["Regular residential IP"],
};
};
const riskInfo = getRiskLevel();
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">IP Address</label>
<Input
placeholder="e.g. 8.8.8.8 or leave empty for current IP"
value={ip}
onChange={(e) => setIp(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
/>
<span className="text-xs text-muted-foreground">
Supports IPv4 and IPv6 addresses
</span>
</div>
<div className="flex gap-2">
<Button onClick={queryIP} disabled={loading} className="flex-1">
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{loading ? "Querying..." : "Query IP"}
</Button>
<Button
onClick={queryCurrentIP}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
Query My IP
</Button>
</div>
</div>
{queryTime > 0 && (
<div className="text-sm text-muted-foreground">
Query time: {queryTime.toFixed(2)} ms
</div>
)}
{ipInfo && (
<div className="flex flex-col gap-3 flex-1 overflow-auto">
<div className="text-sm font-medium">IP Information:</div>
{/* 基本信息 */}
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Basic Information</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">IP Address:</div>
<div className="font-mono">{ipInfo.ip || ipInfo.query}</div>
{ipInfo.country && (
<>
<div className="text-muted-foreground">Country:</div>
<div>{ipInfo.country} ({ipInfo.countryCode})</div>
</>
)}
{ipInfo.region && (
<>
<div className="text-muted-foreground">Region:</div>
<div>{ipInfo.region}</div>
</>
)}
{ipInfo.city && (
<>
<div className="text-muted-foreground">City:</div>
<div>{ipInfo.city}</div>
</>
)}
{ipInfo.loc && (
<>
<div className="text-muted-foreground">Coordinates:</div>
<div className="font-mono">{ipInfo.loc}</div>
</>
)}
{ipInfo.timezone && (
<>
<div className="text-muted-foreground">Timezone:</div>
<div>{ipInfo.timezone}</div>
</>
)}
</div>
</div>
{/* 网络信息 */}
{(ipInfo.isp || ipInfo.org || ipInfo.as) && (
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Network Information</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{ipInfo.isp && (
<>
<div className="text-muted-foreground">ISP:</div>
<div>{ipInfo.isp}</div>
</>
)}
{ipInfo.org && (
<>
<div className="text-muted-foreground">Organization:</div>
<div>{ipInfo.org}</div>
</>
)}
{ipInfo.as && (
<>
<div className="text-muted-foreground">AS Number:</div>
<div className="font-mono">{ipInfo.as}</div>
</>
)}
</div>
</div>
)}
{/* 风险评估 */}
{riskInfo && (
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Risk Assessment</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Risk Level:</div>
<div className={`font-medium ${riskInfo.color}`}>
{riskInfo.level}
</div>
<div className="text-muted-foreground">Details:</div>
<div className="space-y-1">
{riskInfo.reasons.map((reason, idx) => (
<div key={idx} className="text-sm">{reason}</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -36,6 +36,27 @@ const Tool: FC = () => {
const seqRef = useRef<number>(0); const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null); const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const ping = async () => { const ping = async () => {
if (!url.trim()) { if (!url.trim()) {
toast.error("Please enter a URL"); toast.error("Please enter a URL");
@@ -43,12 +64,7 @@ const Tool: FC = () => {
} }
const seq = ++seqRef.current; const seq = ++seqRef.current;
let targetUrl = url.trim(); const targetUrl = url.trim();
// If no protocol prefix, default to https://
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
targetUrl = `https://${targetUrl}`;
}
const startTime = performance.now(); const startTime = performance.now();
@@ -177,6 +193,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com or https://example.com" placeholder="e.g. example.com or https://example.com"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
disabled={running} disabled={running}
/> />
</div> </div>

View File

@@ -142,18 +142,34 @@ const Tool: FC = () => {
} }
}; };
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const startTest = async () => { const startTest = async () => {
if (!url.trim()) { if (!url.trim()) {
toast.error("Please enter a URL"); toast.error("Please enter a URL");
return; return;
} }
let targetUrl = url.trim(); const targetUrl = url.trim();
// If no protocol prefix, default to https://
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
targetUrl = `https://${targetUrl}`;
}
setTesting(true); setTesting(true);
setResult(null); setResult(null);
@@ -218,6 +234,7 @@ const Tool: FC = () => {
placeholder="e.g. https://example.com" placeholder="e.g. https://example.com"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={testing} disabled={testing}
/> />

View File

@@ -37,6 +37,46 @@ const Tool: FC = () => {
const seqRef = useRef<number>(0); const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null); const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleHostBlur = () => {
if (!host.trim()) return;
let input = host.trim();
let cleanHost = input;
let extractedPort: string | null = null;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanHost = url.hostname;
// Extract port if specified in URL
if (url.port) {
extractedPort = url.port;
}
} catch {
// If parsing fails, fallback to manual cleanup
const withoutProtocol = input.replace(/^https?:\/\//, "");
const withoutPath = withoutProtocol.split("/")[0];
// Check for port in the format hostname:port
const portMatch = withoutPath.match(/^(.+):(\d+)$/);
if (portMatch) {
cleanHost = portMatch[1];
extractedPort = portMatch[2];
} else {
cleanHost = withoutPath;
}
}
if (cleanHost !== input) {
setHost(cleanHost);
}
if (extractedPort) {
setPort(extractedPort);
}
};
const tcping = async () => { const tcping = async () => {
if (!host.trim()) { if (!host.trim()) {
toast.error("Please enter a hostname or IP"); toast.error("Please enter a hostname or IP");
@@ -45,14 +85,11 @@ const Tool: FC = () => {
const seq = ++seqRef.current; const seq = ++seqRef.current;
const portNum = parseInt(port) || 443; const portNum = parseInt(port) || 443;
let targetUrl = host.trim(); const targetHost = host.trim();
// 移除协议前缀 // Build test URL
targetUrl = targetUrl.replace(/^https?:\/\//, "");
// 构建测试 URL
const protocol = portNum === 443 ? "https" : "http"; const protocol = portNum === 443 ? "https" : "http";
const url = `${protocol}://${targetUrl}:${portNum}`; const url = `${protocol}://${targetHost}:${portNum}`;
const startTime = performance.now(); const startTime = performance.now();
@@ -182,6 +219,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com or 192.168.1.1" placeholder="e.g. example.com or 192.168.1.1"
value={host} value={host}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
onBlur={handleHostBlur}
disabled={running} disabled={running}
/> />
</div> </div>