mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-11 10:08:20 -06:00
refactor: reorganize project structure and consolidate network utilities
Major restructuring to improve codebase organization: - Moved test files to src/__tests__/ directory - Reorganized UI components from src/components/common to src/components/ui - Consolidated RDAP-related code into src/rdap/ directory structure - Split network helpers into modular files (asn.ts, ipv4.ts, ipv6.ts) - Created centralized exports via src/lib/network/index.ts - Migrated utility functions from src/helpers.ts to src/lib/utils.ts - Separated RDAP services into dedicated modules (rdap-api.ts, registry.ts, url-resolver.ts) This refactoring enhances code maintainability and follows a clearer separation of concerns.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { asnInRange } from "./helpers";
|
import { asnInRange } from "@/lib/network";
|
||||||
|
|
||||||
describe("asnInRange", () => {
|
describe("asnInRange", () => {
|
||||||
describe("basic matching", () => {
|
describe("basic matching", () => {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ipv4InCIDR, ipv6InCIDR } from "./helpers";
|
import { ipv4InCIDR, ipv6InCIDR } from "@/lib/network";
|
||||||
|
|
||||||
describe("ipv4InCIDR", () => {
|
describe("ipv4InCIDR", () => {
|
||||||
describe("basic matching", () => {
|
describe("basic matching", () => {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { getType } from "./rdap";
|
import { getType } from "@/rdap/utils";
|
||||||
import type { Register, RootRegistryType } from "./types";
|
import type { Register, RootRegistryType } from "@/rdap/schemas";
|
||||||
import { registryURLs } from "./constants";
|
import { registryURLs } from "@/rdap/constants";
|
||||||
|
|
||||||
// Integration tests that fetch real IANA bootstrap data
|
// Integration tests that fetch real IANA bootstrap data
|
||||||
// These are slower but test against actual registries
|
// These are slower but test against actual registries
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { getType } from "./rdap";
|
import { getType } from "@/rdap/utils";
|
||||||
import type { Register } from "./types";
|
import type { Register } from "@/rdap/schemas";
|
||||||
|
|
||||||
// Mock registry getter (matches real IANA structure: [email, tags, urls])
|
// Mock registry getter (matches real IANA structure: [email, tags, urls])
|
||||||
const mockRegistry: Register = {
|
const mockRegistry: Register = {
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { onPromise, preventDefault } from "@/helpers";
|
import { onPromise, preventDefault } from "@/lib/utils";
|
||||||
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/types";
|
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||||
import { TargetTypeEnum } from "@/schema";
|
import { TargetTypeEnum } from "@/rdap/schemas";
|
||||||
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||||
import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes";
|
import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes";
|
||||||
import type { Maybe } from "true-myth";
|
import type { Maybe } from "true-myth";
|
||||||
import { placeholders } from "@/constants";
|
import { placeholders } from "@/rdap/constants";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the LookupInput component.
|
* Props for the LookupInput component.
|
||||||
|
|||||||
163
src/helpers.ts
163
src/helpers.ts
@@ -1,163 +0,0 @@
|
|||||||
import type { SyntheticEvent } from "react";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface ObjectConstructor {
|
|
||||||
entries<T extends object>(o: T): [keyof T, T[keyof T]][];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truthy(value: string | null | undefined) {
|
|
||||||
if (value == undefined) return false;
|
|
||||||
return value.toLowerCase() == "true" || value == "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onPromise<T>(promise: (event: SyntheticEvent) => Promise<T>) {
|
|
||||||
return (event: SyntheticEvent) => {
|
|
||||||
if (promise) {
|
|
||||||
promise(event).catch((error) => {
|
|
||||||
console.log("Unexpected error", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate a string dynamically to ensure maxLength is not exceeded & an ellipsis is used.
|
|
||||||
* Behavior undefined when ellipsis exceeds {maxLength}.
|
|
||||||
* @param input The input string
|
|
||||||
* @param maxLength A positive number representing the maximum length the input string should be.
|
|
||||||
* @param ellipsis A string representing what should be placed on the end when the max length is hit.
|
|
||||||
*/
|
|
||||||
export function truncated(input: string, maxLength: number, ellipsis = "...") {
|
|
||||||
if (maxLength <= 0) return "";
|
|
||||||
if (input.length <= maxLength) return input;
|
|
||||||
return input.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preventDefault(event: SyntheticEvent | Event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an IPv4 address string to a 32-bit integer
|
|
||||||
*/
|
|
||||||
function ipv4ToInt(ip: string): number {
|
|
||||||
const parts = ip.split(".").map(Number);
|
|
||||||
if (parts.length !== 4) return 0;
|
|
||||||
const [a, b, c, d] = parts;
|
|
||||||
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
|
|
||||||
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an IPv4 address falls within a CIDR range
|
|
||||||
* @param ip The IP address to check (e.g., "192.168.1.1")
|
|
||||||
* @param cidr The CIDR range to check against (e.g., "192.168.0.0/16")
|
|
||||||
* @returns true if the IP is within the CIDR range
|
|
||||||
*/
|
|
||||||
export function ipv4InCIDR(ip: string, cidr: string): boolean {
|
|
||||||
const [rangeIp, prefixLenStr] = cidr.split("/");
|
|
||||||
const prefixLen = parseInt(prefixLenStr ?? "", 10);
|
|
||||||
|
|
||||||
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case: /0 matches all IPs
|
|
||||||
if (prefixLen === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipInt = ipv4ToInt(ip);
|
|
||||||
const rangeInt = ipv4ToInt(rangeIp);
|
|
||||||
const mask = (0xffffffff << (32 - prefixLen)) >>> 0;
|
|
||||||
|
|
||||||
return (ipInt & mask) === (rangeInt & mask);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an IPv6 address to a BigInt representation
|
|
||||||
*/
|
|
||||||
function ipv6ToBigInt(ip: string): bigint {
|
|
||||||
// Expand :: notation
|
|
||||||
const expandedIp = expandIPv6(ip);
|
|
||||||
const parts = expandedIp.split(":");
|
|
||||||
|
|
||||||
let result = BigInt(0);
|
|
||||||
for (const part of parts) {
|
|
||||||
result = (result << BigInt(16)) | BigInt(parseInt(part, 16));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand IPv6 address shorthand notation
|
|
||||||
*/
|
|
||||||
function expandIPv6(ip: string): string {
|
|
||||||
if (ip.includes("::")) {
|
|
||||||
const [left, right] = ip.split("::");
|
|
||||||
const leftParts = left ? left.split(":") : [];
|
|
||||||
const rightParts = right ? right.split(":") : [];
|
|
||||||
const missingParts = 8 - leftParts.length - rightParts.length;
|
|
||||||
const middleParts: string[] = Array(missingParts).fill("0") as string[];
|
|
||||||
const allParts = [...leftParts, ...middleParts, ...rightParts];
|
|
||||||
return allParts.map((p: string) => p.padStart(4, "0")).join(":");
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
.split(":")
|
|
||||||
.map((p: string) => p.padStart(4, "0"))
|
|
||||||
.join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an IPv6 address falls within a CIDR range
|
|
||||||
* @param ip The IPv6 address to check (e.g., "2001:db8::1")
|
|
||||||
* @param cidr The CIDR range to check against (e.g., "2001:db8::/32")
|
|
||||||
* @returns true if the IP is within the CIDR range
|
|
||||||
*/
|
|
||||||
export function ipv6InCIDR(ip: string, cidr: string): boolean {
|
|
||||||
const [rangeIp, prefixLenStr] = cidr.split("/");
|
|
||||||
const prefixLen = parseInt(prefixLenStr ?? "", 10);
|
|
||||||
|
|
||||||
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 128) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ipInt = ipv6ToBigInt(ip);
|
|
||||||
const rangeInt = ipv6ToBigInt(rangeIp);
|
|
||||||
const maxMask = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
|
|
||||||
const mask = (maxMask << BigInt(128 - prefixLen)) & maxMask;
|
|
||||||
|
|
||||||
return (ipInt & mask) === (rangeInt & mask);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an ASN falls within a range
|
|
||||||
* @param asn The ASN number to check (e.g., 13335 for Cloudflare)
|
|
||||||
* @param range The range to check against (e.g., "13312-18431")
|
|
||||||
* @returns true if the ASN is within the range
|
|
||||||
*/
|
|
||||||
export function asnInRange(asn: number, range: string): boolean {
|
|
||||||
const parts = range.split("-");
|
|
||||||
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = parseInt(parts[0] ?? "", 10);
|
|
||||||
const end = parseInt(parts[1] ?? "", 10);
|
|
||||||
|
|
||||||
if (isNaN(start) || isNaN(end) || start < 0 || end < 0 || start > end) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asn < 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return asn >= start && asn <= end;
|
|
||||||
}
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { domainMatchPredicate, getBestURL, getType } from "@/rdap";
|
|
||||||
import type {
|
|
||||||
AutonomousNumber,
|
|
||||||
Domain,
|
|
||||||
IpNetwork,
|
|
||||||
Register,
|
|
||||||
RootRegistryType,
|
|
||||||
SubmitProps,
|
|
||||||
TargetType,
|
|
||||||
} from "@/types";
|
|
||||||
import { registryURLs } from "@/constants";
|
|
||||||
import {
|
|
||||||
AutonomousNumberSchema,
|
|
||||||
DomainSchema,
|
|
||||||
IpNetworkSchema,
|
|
||||||
RegisterSchema,
|
|
||||||
RootRegistryEnum,
|
|
||||||
} from "@/schema";
|
|
||||||
import { truncated, ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/helpers";
|
|
||||||
import type { ZodSchema } from "zod";
|
|
||||||
import type { ParsedGeneric } from "@/components/lookup/Generic";
|
|
||||||
import { Maybe, Result } from "true-myth";
|
|
||||||
|
|
||||||
export type WarningHandler = (warning: { message: string }) => void;
|
|
||||||
export type MetaParsedGeneric = {
|
|
||||||
data: ParsedGeneric;
|
|
||||||
url: string;
|
|
||||||
completeTime: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
// An array of schemas to try and parse unknown JSON data with.
|
|
||||||
const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema];
|
|
||||||
|
|
||||||
const useLookup = (warningHandler?: WarningHandler) => {
|
|
||||||
/**
|
|
||||||
* A reference to the registry data, which is used to cache the registry data in memory.
|
|
||||||
* This uses TargetType as the key, meaning v4/v6 IP/CIDR lookups are differentiated.
|
|
||||||
*/
|
|
||||||
const registryDataRef = useRef<Record<RootRegistryType, Register | null>>({
|
|
||||||
autnum: null,
|
|
||||||
domain: null,
|
|
||||||
ip4: null,
|
|
||||||
ip6: null,
|
|
||||||
entity: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [target, setTarget] = useState<string>("");
|
|
||||||
const [uriType, setUriType] = useState<Maybe<TargetType>>(Maybe.nothing());
|
|
||||||
|
|
||||||
// Used by a callback on LookupInput to forcibly set the type of the lookup.
|
|
||||||
const [currentType, setTargetType] = useState<TargetType | null>(null);
|
|
||||||
|
|
||||||
// Used to allow repeatable lookups when weird errors happen.
|
|
||||||
const repeatableRef = useRef<string>("");
|
|
||||||
|
|
||||||
useCallback(async () => {
|
|
||||||
if (currentType != null) return Maybe.just(currentType);
|
|
||||||
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) =>
|
|
||||||
Maybe.just(type)
|
|
||||||
);
|
|
||||||
setUriType(uri);
|
|
||||||
}, [target, currentType, getTypeEasy]);
|
|
||||||
|
|
||||||
// Fetch & load a specific registry's data into memory.
|
|
||||||
async function loadBootstrap(type: RootRegistryType, force = false) {
|
|
||||||
// Early preload exit condition
|
|
||||||
if (registryDataRef.current[type] != null && !force) return;
|
|
||||||
|
|
||||||
// Fetch the bootstrapping file from the registry
|
|
||||||
const response = await fetch(registryURLs[type]);
|
|
||||||
if (response.status != 200) throw new Error(`Error: ${response.statusText}`);
|
|
||||||
|
|
||||||
// Parse it, so we don't make any false assumptions during development & while maintaining the tool.
|
|
||||||
const parsedRegister = RegisterSchema.safeParse(await response.json());
|
|
||||||
if (!parsedRegister.success)
|
|
||||||
throw new Error(`Could not parse IANA bootstrap response (type: ${type}).`);
|
|
||||||
|
|
||||||
// Set it in state so we can use it.
|
|
||||||
registryDataRef.current = {
|
|
||||||
...registryDataRef.current,
|
|
||||||
[type]: parsedRegister.data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRegistry(type: RootRegistryType): Promise<Register> {
|
|
||||||
if (registryDataRef.current[type] == null) await loadBootstrap(type);
|
|
||||||
const registry = registryDataRef.current[type];
|
|
||||||
if (registry == null)
|
|
||||||
throw new Error(`Could not load bootstrap data for ${type} registry.`);
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTypeEasy(target: string): Promise<Result<TargetType, Error>> {
|
|
||||||
return getType(target, getRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRegistryURL(type: RootRegistryType, lookupTarget: string): string {
|
|
||||||
const bootstrap = registryDataRef.current[type];
|
|
||||||
if (bootstrap == null)
|
|
||||||
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
|
|
||||||
|
|
||||||
let url: string | null = null;
|
|
||||||
|
|
||||||
typeSwitch: switch (type) {
|
|
||||||
case "domain":
|
|
||||||
for (const bootstrapItem of bootstrap.services) {
|
|
||||||
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
|
|
||||||
// min length of 1 is validated in zod schema
|
|
||||||
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
||||||
break typeSwitch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No matching domain found.`);
|
|
||||||
case "ip4": {
|
|
||||||
// Extract the IP address without CIDR suffix for matching
|
|
||||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
|
||||||
for (const bootstrapItem of bootstrap.services) {
|
|
||||||
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
|
|
||||||
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
|
|
||||||
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
||||||
break typeSwitch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No matching IPv4 registry found for ${lookupTarget}.`);
|
|
||||||
}
|
|
||||||
case "ip6": {
|
|
||||||
// Extract the IP address without CIDR suffix for matching
|
|
||||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
|
||||||
for (const bootstrapItem of bootstrap.services) {
|
|
||||||
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
|
|
||||||
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
|
|
||||||
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
||||||
break typeSwitch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
|
|
||||||
}
|
|
||||||
case "autnum": {
|
|
||||||
// Extract ASN number from "AS12345" format
|
|
||||||
const asnMatch = lookupTarget.match(/^AS(\d+)$/i);
|
|
||||||
if (!asnMatch || !asnMatch[1]) {
|
|
||||||
throw new Error(`Invalid ASN format: ${lookupTarget}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const asnNumber = parseInt(asnMatch[1], 10);
|
|
||||||
if (isNaN(asnNumber)) {
|
|
||||||
throw new Error(`Invalid ASN number: ${lookupTarget}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const bootstrapItem of bootstrap.services) {
|
|
||||||
// bootstrapItem[0] contains ASN ranges like ["64512-65534", "13312-18431"]
|
|
||||||
if (bootstrapItem[0].some((range) => asnInRange(asnNumber, range))) {
|
|
||||||
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
||||||
break typeSwitch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`No matching registry found for ${lookupTarget}.`);
|
|
||||||
}
|
|
||||||
case "entity":
|
|
||||||
throw new Error(`No matching entity found.`);
|
|
||||||
default:
|
|
||||||
throw new Error("Invalid lookup target provided.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url == null) throw new Error("No lookup target was resolved.");
|
|
||||||
|
|
||||||
// Map internal types to RDAP endpoint paths
|
|
||||||
// ip4 and ip6 both use the 'ip' endpoint in RDAP
|
|
||||||
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
|
|
||||||
|
|
||||||
return `${url}${rdapPath}/${lookupTarget}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const preload = async () => {
|
|
||||||
if (uriType.isNothing) return;
|
|
||||||
|
|
||||||
const registryUri = RootRegistryEnum.safeParse(uriType.value);
|
|
||||||
if (!registryUri.success) return;
|
|
||||||
if (registryDataRef.current[registryUri.data] != null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadBootstrap(registryUri.data);
|
|
||||||
} catch (e) {
|
|
||||||
if (warningHandler != undefined) {
|
|
||||||
const message = e instanceof Error ? `(${truncated(e.message, 15)})` : ".";
|
|
||||||
warningHandler({
|
|
||||||
message: `Failed to preload registry${message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
preload().catch(console.error);
|
|
||||||
}, [target, uriType, warningHandler]);
|
|
||||||
|
|
||||||
async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (response.status == 200) {
|
|
||||||
const result = schema.safeParse(await response.json());
|
|
||||||
|
|
||||||
if (result.success === false) {
|
|
||||||
// flatten the errors to make them more readable and simple
|
|
||||||
const flatErrors = result.error.flatten(function (issue) {
|
|
||||||
const path = issue.path.map((value) => value.toString()).join(".");
|
|
||||||
return `${path}: ${issue.message}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// combine them all, wrap them in a new error, and return it
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
[
|
|
||||||
"Could not parse the response from the registry.",
|
|
||||||
...flatErrors.formErrors,
|
|
||||||
...Object.values(flatErrors.fieldErrors).flat(),
|
|
||||||
].join("\n\t")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(result.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (response.status) {
|
|
||||||
case 302:
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry indicated that the resource requested is available at a different location."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case 400:
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry indicated that the request was malformed or could not be processed. Check that you typed in the correct information and try again."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case 403:
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry indicated that the request was forbidden. This could be due to rate limiting, abusive behavior, or other reasons. Try again later or contact the registry for more information."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
case 404:
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry indicated that the resource requested could not be found; the resource either does not exist, or is something that the registry does not track (i.e. this software queried incorrectly, which is unlikely)."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case 500:
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry indicated that an internal server error occurred. This could be due to a misconfiguration, a bug, or other reasons. Try again later or contact the registry for more information."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return Result.err(
|
|
||||||
new Error(`The registry did not return an OK status code: ${response.status}.`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitInternal(
|
|
||||||
target: string
|
|
||||||
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
|
||||||
if (target == null || target.length == 0)
|
|
||||||
return Result.err(new Error("A target must be given in order to execute a lookup."));
|
|
||||||
|
|
||||||
const targetType = await getTypeEasy(target);
|
|
||||||
|
|
||||||
if (targetType.isErr) {
|
|
||||||
return Result.err(
|
|
||||||
new Error("Unable to determine type, unable to send query", {
|
|
||||||
cause: targetType.error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (targetType.value) {
|
|
||||||
// Block scoped case to allow url const reuse
|
|
||||||
case "ip4": {
|
|
||||||
await loadBootstrap("ip4");
|
|
||||||
const url = getRegistryURL(targetType.value, target);
|
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
|
||||||
if (result.isErr) return Result.err(result.error);
|
|
||||||
return Result.ok({ data: result.value, url });
|
|
||||||
}
|
|
||||||
case "ip6": {
|
|
||||||
await loadBootstrap("ip6");
|
|
||||||
const url = getRegistryURL(targetType.value, target);
|
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
|
||||||
if (result.isErr) return Result.err(result.error);
|
|
||||||
return Result.ok({ data: result.value, url });
|
|
||||||
}
|
|
||||||
case "domain": {
|
|
||||||
await loadBootstrap("domain");
|
|
||||||
const url = getRegistryURL(targetType.value, target);
|
|
||||||
|
|
||||||
// HTTP
|
|
||||||
if (url.startsWith("http://") && url != repeatableRef.current) {
|
|
||||||
repeatableRef.current = url;
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
"The registry this domain belongs to uses HTTP, which is not secure. " +
|
|
||||||
"In order to prevent a cryptic error from appearing due to mixed active content, " +
|
|
||||||
"or worse, a CORS error, this lookup has been blocked. Try again to force the lookup."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema);
|
|
||||||
if (result.isErr) return Result.err(result.error);
|
|
||||||
|
|
||||||
return Result.ok({ data: result.value, url });
|
|
||||||
}
|
|
||||||
case "autnum": {
|
|
||||||
await loadBootstrap("autnum");
|
|
||||||
const url = getRegistryURL(targetType.value, target);
|
|
||||||
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
|
|
||||||
if (result.isErr) return Result.err(result.error);
|
|
||||||
return Result.ok({ data: result.value, url });
|
|
||||||
}
|
|
||||||
case "tld": {
|
|
||||||
// remove the leading dot
|
|
||||||
const value = target.startsWith(".") ? target.slice(1) : target;
|
|
||||||
const url = `https://root.rdap.org/domain/${value}`;
|
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema);
|
|
||||||
if (result.isErr) return Result.err(result.error);
|
|
||||||
return Result.ok({ data: result.value, url });
|
|
||||||
}
|
|
||||||
case "url": {
|
|
||||||
const response = await fetch(target);
|
|
||||||
|
|
||||||
if (response.status != 200)
|
|
||||||
return Result.err(
|
|
||||||
new Error(
|
|
||||||
`The URL provided returned a non-200 status code: ${response.status}.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Try each schema until one works
|
|
||||||
for (const schema of schemas) {
|
|
||||||
const result = schema.safeParse(data);
|
|
||||||
if (result.success) return Result.ok({ data: result.data, url: target });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.err(new Error("No schema was able to parse the response."));
|
|
||||||
}
|
|
||||||
case "json": {
|
|
||||||
const data = JSON.parse(target);
|
|
||||||
for (const schema of schemas) {
|
|
||||||
const result = schema.safeParse(data);
|
|
||||||
if (result.success) return Result.ok({ data: result.data, url: "" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "registrar": {
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return Result.err(new Error("The type detected has not been implemented."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit({ target }: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
|
|
||||||
try {
|
|
||||||
// target is already set in state, but it's also provided by the form callback, so we'll use it.
|
|
||||||
const response = await submitInternal(target);
|
|
||||||
|
|
||||||
if (response.isErr) {
|
|
||||||
setError(response.error.message);
|
|
||||||
console.error(response.error);
|
|
||||||
} else setError(null);
|
|
||||||
|
|
||||||
return response.isOk
|
|
||||||
? Maybe.just({
|
|
||||||
data: response.value.data,
|
|
||||||
url: response.value.url,
|
|
||||||
completeTime: new Date(),
|
|
||||||
})
|
|
||||||
: Maybe.nothing();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error)) setError("An unknown, unprocessable error has occurred.");
|
|
||||||
else setError(e.message);
|
|
||||||
console.error(e);
|
|
||||||
return Maybe.nothing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
error,
|
|
||||||
setTarget,
|
|
||||||
setTargetType,
|
|
||||||
submit,
|
|
||||||
currentType: uriType,
|
|
||||||
getType: getTypeEasy,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useLookup;
|
|
||||||
@@ -33,3 +33,30 @@ export function findASN(asn: number, ranges: string[]) {
|
|||||||
}
|
}
|
||||||
return -1; // Failure case
|
return -1; // Failure case
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an ASN falls within a range
|
||||||
|
* @param asn The ASN number to check (e.g., 13335 for Cloudflare)
|
||||||
|
* @param range The range to check against (e.g., "13312-18431")
|
||||||
|
* @returns true if the ASN is within the range
|
||||||
|
*/
|
||||||
|
export function asnInRange(asn: number, range: string): boolean {
|
||||||
|
const parts = range.split("-");
|
||||||
|
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parseInt(parts[0] ?? "", 10);
|
||||||
|
const end = parseInt(parts[1] ?? "", 10);
|
||||||
|
|
||||||
|
if (isNaN(start) || isNaN(end) || start < 0 || end < 0 || start > end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asn < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asn >= start && asn <= end;
|
||||||
|
}
|
||||||
3
src/lib/network/index.ts
Normal file
3
src/lib/network/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ipv4InCIDR } from "@/lib/network/ipv4";
|
||||||
|
export { ipv6InCIDR } from "@/lib/network/ipv6";
|
||||||
|
export { findASN, asnInRange } from "@/lib/network/asn";
|
||||||
36
src/lib/network/ipv4.ts
Normal file
36
src/lib/network/ipv4.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Convert an IPv4 address string to a 32-bit integer
|
||||||
|
*/
|
||||||
|
function ipv4ToInt(ip: string): number {
|
||||||
|
const parts = ip.split(".").map(Number);
|
||||||
|
if (parts.length !== 4) return 0;
|
||||||
|
const [a, b, c, d] = parts;
|
||||||
|
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
|
||||||
|
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IPv4 address falls within a CIDR range
|
||||||
|
* @param ip The IP address to check (e.g., "192.168.1.1")
|
||||||
|
* @param cidr The CIDR range to check against (e.g., "192.168.0.0/16")
|
||||||
|
* @returns true if the IP is within the CIDR range
|
||||||
|
*/
|
||||||
|
export function ipv4InCIDR(ip: string, cidr: string): boolean {
|
||||||
|
const [rangeIp, prefixLenStr] = cidr.split("/");
|
||||||
|
const prefixLen = parseInt(prefixLenStr ?? "", 10);
|
||||||
|
|
||||||
|
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: /0 matches all IPs
|
||||||
|
if (prefixLen === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipInt = ipv4ToInt(ip);
|
||||||
|
const rangeInt = ipv4ToInt(rangeIp);
|
||||||
|
const mask = (0xffffffff << (32 - prefixLen)) >>> 0;
|
||||||
|
|
||||||
|
return (ipInt & mask) === (rangeInt & mask);
|
||||||
|
}
|
||||||
59
src/lib/network/ipv6.ts
Normal file
59
src/lib/network/ipv6.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Expand IPv6 address shorthand notation
|
||||||
|
*/
|
||||||
|
function expandIPv6(ip: string): string {
|
||||||
|
if (ip.includes("::")) {
|
||||||
|
const [left, right] = ip.split("::");
|
||||||
|
const leftParts = left ? left.split(":") : [];
|
||||||
|
const rightParts = right ? right.split(":") : [];
|
||||||
|
const missingParts = 8 - leftParts.length - rightParts.length;
|
||||||
|
const middleParts: string[] = Array(missingParts).fill("0") as string[];
|
||||||
|
const allParts = [...leftParts, ...middleParts, ...rightParts];
|
||||||
|
return allParts.map((p: string) => p.padStart(4, "0")).join(":");
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
.split(":")
|
||||||
|
.map((p: string) => p.padStart(4, "0"))
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an IPv6 address to a BigInt representation
|
||||||
|
*/
|
||||||
|
function ipv6ToBigInt(ip: string): bigint {
|
||||||
|
// Expand :: notation
|
||||||
|
const expandedIp = expandIPv6(ip);
|
||||||
|
const parts = expandedIp.split(":");
|
||||||
|
|
||||||
|
let result = BigInt(0);
|
||||||
|
for (const part of parts) {
|
||||||
|
result = (result << BigInt(16)) | BigInt(parseInt(part, 16));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IPv6 address falls within a CIDR range
|
||||||
|
* @param ip The IPv6 address to check (e.g., "2001:db8::1")
|
||||||
|
* @param cidr The CIDR range to check against (e.g., "2001:db8::/32")
|
||||||
|
* @returns true if the IP is within the CIDR range
|
||||||
|
*/
|
||||||
|
export function ipv6InCIDR(ip: string, cidr: string): boolean {
|
||||||
|
const [rangeIp, prefixLenStr] = cidr.split("/");
|
||||||
|
const prefixLen = parseInt(prefixLenStr ?? "", 10);
|
||||||
|
|
||||||
|
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 128) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ipInt = ipv6ToBigInt(ip);
|
||||||
|
const rangeInt = ipv6ToBigInt(rangeIp);
|
||||||
|
const maxMask = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
|
||||||
|
const mask = (maxMask << BigInt(128 - prefixLen)) & maxMask;
|
||||||
|
|
||||||
|
return (ipInt & mask) === (rangeInt & mask);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,55 @@
|
|||||||
|
import type { SyntheticEvent } from "react";
|
||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends the global ObjectConstructor interface to allow for stronger typing
|
||||||
|
* of Object.entries, ensuring that it returns an array of key-value pairs
|
||||||
|
* where keys are limited to the keys of the provided object and values are properly typed.
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
interface ObjectConstructor {
|
||||||
|
entries<T extends object>(o: T): [keyof T, T[keyof T]][];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function truthy(value: string | null | undefined) {
|
||||||
|
if (value == undefined) return false;
|
||||||
|
return value.toLowerCase() == "true" || value == "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPromise<T>(promise: (event: SyntheticEvent) => Promise<T>) {
|
||||||
|
return (event: SyntheticEvent) => {
|
||||||
|
if (promise) {
|
||||||
|
promise(event).catch((error) => {
|
||||||
|
console.log("Unexpected error", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string dynamically to ensure maxLength is not exceeded & an ellipsis is used.
|
||||||
|
* Behavior undefined when ellipsis exceeds {maxLength}.
|
||||||
|
* @param input The input string
|
||||||
|
* @param maxLength A positive number representing the maximum length the input string should be.
|
||||||
|
* @param ellipsis A string representing what should be placed on the end when the max length is hit.
|
||||||
|
*/
|
||||||
|
export function truncated(input: string, maxLength: number, ellipsis = "...") {
|
||||||
|
if (maxLength <= 0) return "";
|
||||||
|
if (input.length <= maxLength) return input;
|
||||||
|
return input.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A functional form of `event.preventDefault()`.
|
||||||
|
* @param event The event to prevent the default action of.
|
||||||
|
* @returns Nothing.
|
||||||
|
*/
|
||||||
|
export function preventDefault(event: SyntheticEvent | Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "@fontsource-variable/inter";
|
|||||||
import "@fontsource/ibm-plex-mono/400.css";
|
import "@fontsource/ibm-plex-mono/400.css";
|
||||||
import "@radix-ui/themes/styles.css";
|
import "@radix-ui/themes/styles.css";
|
||||||
|
|
||||||
import "../styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
const MyApp: AppType = ({ Component, pageProps }) => {
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { type NextPage } from "next";
|
import { type NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Generic from "@/components/lookup/Generic";
|
import Generic from "@/rdap/components/Generic";
|
||||||
import type { MetaParsedGeneric } from "@/hooks/useLookup";
|
import type { MetaParsedGeneric } from "@/rdap/hooks/useLookup";
|
||||||
import useLookup from "@/hooks/useLookup";
|
import useLookup from "@/rdap/hooks/useLookup";
|
||||||
import LookupInput from "@/components/form/LookupInput";
|
import LookupInput from "@/rdap/components/LookupInput";
|
||||||
import ErrorCard from "@/components/common/ErrorCard";
|
import ErrorCard from "@/components/ErrorCard";
|
||||||
import { ThemeToggle } from "@/components/common/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { Maybe } from "true-myth";
|
import { Maybe } from "true-myth";
|
||||||
import type { TargetType } from "@/types";
|
import type { TargetType } from "@/rdap/schemas";
|
||||||
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
|
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
|
||||||
|
|
||||||
const Index: NextPage = () => {
|
const Index: NextPage = () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { AutonomousNumber } from "@/types";
|
import type { AutonomousNumber } from "@/rdap/schemas";
|
||||||
import Events from "@/components/lookup/Events";
|
import Events from "@/rdap/components/Events";
|
||||||
import Property from "@/components/common/Property";
|
import Property from "@/components/Property";
|
||||||
import PropertyList from "@/components/common/PropertyList";
|
import PropertyList from "@/components/PropertyList";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type AutnumCardProps = {
|
export type AutnumCardProps = {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { rdapStatusInfo } from "@/constants";
|
import { rdapStatusInfo } from "@/rdap/constants";
|
||||||
import type { Domain } from "@/types";
|
import type { Domain } from "@/rdap/schemas";
|
||||||
import Events from "@/components/lookup/Events";
|
import Events from "@/rdap/components/Events";
|
||||||
import Property from "@/components/common/Property";
|
import Property from "@/components/Property";
|
||||||
import PropertyList from "@/components/common/PropertyList";
|
import PropertyList from "@/components/PropertyList";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type DomainProps = {
|
export type DomainProps = {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity } from "@/types";
|
import type { Entity } from "@/rdap/schemas";
|
||||||
import Property from "@/components/common/Property";
|
import Property from "@/components/Property";
|
||||||
import PropertyList from "@/components/common/PropertyList";
|
import PropertyList from "@/components/PropertyList";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type EntityCardProps = {
|
export type EntityCardProps = {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import type { Event } from "@/types";
|
import type { Event } from "@/rdap/schemas";
|
||||||
import DynamicDate from "@/components/common/DynamicDate";
|
import DynamicDate from "@/components/DynamicDate";
|
||||||
import { Table, Text } from "@radix-ui/themes";
|
import { Table, Text } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type EventsProps = {
|
export type EventsProps = {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import DomainCard from "@/components/lookup/DomainCard";
|
import DomainCard from "@/rdap/components/DomainCard";
|
||||||
import IPCard from "@/components/lookup/IPCard";
|
import IPCard from "@/rdap/components/IPCard";
|
||||||
import AutnumCard from "@/components/lookup/AutnumCard";
|
import AutnumCard from "@/rdap/components/AutnumCard";
|
||||||
import EntityCard from "@/components/lookup/EntityCard";
|
import EntityCard from "@/rdap/components/EntityCard";
|
||||||
import NameserverCard from "@/components/lookup/NameserverCard";
|
import NameserverCard from "@/rdap/components/NameserverCard";
|
||||||
import type { Domain, AutonomousNumber, Entity, Nameserver, IpNetwork } from "@/types";
|
import type { Domain, AutonomousNumber, Entity, Nameserver, IpNetwork } from "@/rdap/schemas";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
|
|
||||||
export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | IpNetwork;
|
export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | IpNetwork;
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { IpNetwork } from "@/types";
|
import type { IpNetwork } from "@/rdap/schemas";
|
||||||
import Events from "@/components/lookup/Events";
|
import Events from "@/rdap/components/Events";
|
||||||
import Property from "@/components/common/Property";
|
import Property from "@/components/Property";
|
||||||
import PropertyList from "@/components/common/PropertyList";
|
import PropertyList from "@/components/PropertyList";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type IPCardProps = {
|
export type IPCardProps = {
|
||||||
246
src/rdap/components/LookupInput.tsx
Normal file
246
src/rdap/components/LookupInput.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { onPromise, preventDefault } from "@/lib/utils";
|
||||||
|
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||||
|
import { TargetTypeEnum } from "@/rdap/schemas";
|
||||||
|
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||||
|
import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes";
|
||||||
|
import type { Maybe } from "true-myth";
|
||||||
|
import { placeholders } from "@/rdap/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the LookupInput component.
|
||||||
|
*/
|
||||||
|
type LookupInputProps = {
|
||||||
|
isLoading?: boolean;
|
||||||
|
/**
|
||||||
|
* Callback function called when a type of registry is detected when a user changes their input.
|
||||||
|
* @param type - The detected type of registry.
|
||||||
|
* @returns A promise.
|
||||||
|
*/
|
||||||
|
onRegistry?: (type: TargetType) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Callback function called when a user hits submit.
|
||||||
|
* @param props - The submit props.
|
||||||
|
* @returns A promise.
|
||||||
|
*/
|
||||||
|
onSubmit?: (props: SubmitProps) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Callback function called when a user changes their input (text search) or explicitly changes the type of search.
|
||||||
|
* @param target - The target object containing the search target and target type.
|
||||||
|
* @returns Nothing.
|
||||||
|
*/
|
||||||
|
onChange?: (target: { target: string; targetType: TargetType | null }) => Promise<void>;
|
||||||
|
detectedType: Maybe<TargetType>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||||
|
isLoading,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
detectedType,
|
||||||
|
}: LookupInputProps) => {
|
||||||
|
const { register, handleSubmit, getValues, control } = useForm<SubmitProps>({
|
||||||
|
defaultValues: {
|
||||||
|
target: "",
|
||||||
|
// Not used at this time.
|
||||||
|
followReferral: false,
|
||||||
|
requestJSContact: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mapping of available (simple) target types to their long-form human-readable names.
|
||||||
|
*/
|
||||||
|
const objectNames: Record<SimplifiedTargetType | "auto", string> = {
|
||||||
|
auto: "Autodetect",
|
||||||
|
domain: "Domain",
|
||||||
|
ip: "IP/CIDR", // IPv4/IPv6 are combined into this option
|
||||||
|
tld: "TLD",
|
||||||
|
autnum: "AS Number",
|
||||||
|
entity: "Entity Handle",
|
||||||
|
registrar: "Registrar",
|
||||||
|
url: "URL",
|
||||||
|
json: "JSON",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of precise target types to their simplified short-form names.
|
||||||
|
*/
|
||||||
|
const targetShortNames: Record<TargetType, string> = {
|
||||||
|
domain: "Domain",
|
||||||
|
tld: "TLD",
|
||||||
|
ip4: "IPv4",
|
||||||
|
ip6: "IPv6",
|
||||||
|
autnum: "ASN",
|
||||||
|
entity: "Entity",
|
||||||
|
registrar: "Registrar",
|
||||||
|
url: "URL",
|
||||||
|
json: "JSON",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the selected value in the LookupInput component.
|
||||||
|
*/
|
||||||
|
const [selected, setSelected] = useState<SimplifiedTargetType | "auto">("auto");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the target type based on the provided value.
|
||||||
|
* @param value - The value to retrieve the target type for.
|
||||||
|
* @returns The target type as ObjectType or null.
|
||||||
|
*/
|
||||||
|
function retrieveTargetType(value?: string | null): TargetType | null {
|
||||||
|
// If the value is null and the selected value is null, return null.
|
||||||
|
if (value == null) value = selected;
|
||||||
|
|
||||||
|
// 'auto' means 'do whatever' so we return null.
|
||||||
|
if (value == "auto") return null;
|
||||||
|
|
||||||
|
// Validate the value is a valid TargetType
|
||||||
|
const result = TargetTypeEnum.safeParse(value);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="pb-2.5"
|
||||||
|
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
|
||||||
|
>
|
||||||
|
<Flex direction="column" gap="3">
|
||||||
|
<label htmlFor="search" className="sr-only">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<Flex gap="0" style={{ position: "relative" }}>
|
||||||
|
<TextField.Root
|
||||||
|
size="3"
|
||||||
|
placeholder={placeholders[selected]}
|
||||||
|
disabled={isLoading}
|
||||||
|
{...register("target", {
|
||||||
|
required: true,
|
||||||
|
onChange: () => {
|
||||||
|
if (onChange != undefined)
|
||||||
|
void onChange({
|
||||||
|
target: getValues("target"),
|
||||||
|
targetType: retrieveTargetType(null),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
border: "1px solid var(--gray-7)",
|
||||||
|
borderRight: "none",
|
||||||
|
boxShadow: "none",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField.Slot side="left">
|
||||||
|
<IconButton
|
||||||
|
size="1"
|
||||||
|
variant="ghost"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ cursor: isLoading ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ReloadIcon className="animate-spin" width="16" height="16" />
|
||||||
|
) : (
|
||||||
|
<MagnifyingGlassIcon width="16" height="16" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</TextField.Slot>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
<Select.Root
|
||||||
|
value={selected}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelected(value as SimplifiedTargetType | "auto");
|
||||||
|
|
||||||
|
if (onChange != undefined)
|
||||||
|
void onChange({
|
||||||
|
target: getValues("target"),
|
||||||
|
targetType: retrieveTargetType(value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
size="3"
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
|
||||||
|
minWidth: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected == "auto" ? (
|
||||||
|
detectedType.isJust ? (
|
||||||
|
<Text>Auto ({targetShortNames[detectedType.value]})</Text>
|
||||||
|
) : (
|
||||||
|
objectNames["auto"]
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Flex align="center" gap="2">
|
||||||
|
<LockClosedIcon width="14" height="14" />
|
||||||
|
{objectNames[selected]}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Content position="popper">
|
||||||
|
{Object.entries(objectNames).map(([key, value]) => (
|
||||||
|
<Select.Item key={key} value={key}>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="between"
|
||||||
|
gap="2"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Flex>
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex pl="3" gapX="5" gapY="2" wrap="wrap">
|
||||||
|
<Flex asChild align="center" gap="2">
|
||||||
|
<Text as="label" size="2">
|
||||||
|
<Controller
|
||||||
|
name="requestJSContact"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Request JSContact
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex asChild align="center" gap="2">
|
||||||
|
<Text as="label" size="2">
|
||||||
|
<Controller
|
||||||
|
name="followReferral"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Follow referral to registrar's RDAP record
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LookupInput;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Nameserver } from "@/types";
|
import type { Nameserver } from "@/rdap/schemas";
|
||||||
import Property from "@/components/common/Property";
|
import Property from "@/components/Property";
|
||||||
import AbstractCard from "@/components/common/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
||||||
|
|
||||||
export type NameserverCardProps = {
|
export type NameserverCardProps = {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// see https://www.iana.org/assignments/rdap-json-values
|
// see https://www.iana.org/assignments/rdap-json-values
|
||||||
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/types";
|
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/rdap/schemas";
|
||||||
|
|
||||||
export const rdapStatusInfo: Record<RdapStatusType, string> = {
|
export const rdapStatusInfo: Record<RdapStatusType, string> = {
|
||||||
validated:
|
validated:
|
||||||
208
src/rdap/hooks/useLookup.tsx
Normal file
208
src/rdap/hooks/useLookup.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { getType } from "@/rdap/utils";
|
||||||
|
import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||||
|
import {
|
||||||
|
AutonomousNumberSchema,
|
||||||
|
DomainSchema,
|
||||||
|
IpNetworkSchema,
|
||||||
|
RootRegistryEnum,
|
||||||
|
} from "@/rdap/schemas";
|
||||||
|
import { truncated } from "@/lib/utils";
|
||||||
|
import type { ParsedGeneric } from "@/rdap/components/Generic";
|
||||||
|
import { Maybe, Result } from "true-myth";
|
||||||
|
import { loadBootstrap, getRegistry } from "@/rdap/services/registry";
|
||||||
|
import { getRegistryURL } from "@/rdap/services/url-resolver";
|
||||||
|
import { getAndParse } from "@/rdap/services/rdap-api";
|
||||||
|
|
||||||
|
export type WarningHandler = (warning: { message: string }) => void;
|
||||||
|
export type MetaParsedGeneric = {
|
||||||
|
data: ParsedGeneric;
|
||||||
|
url: string;
|
||||||
|
completeTime: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An array of schemas to try and parse unknown JSON data with.
|
||||||
|
const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema];
|
||||||
|
|
||||||
|
const useLookup = (warningHandler?: WarningHandler) => {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [target, setTarget] = useState<string>("");
|
||||||
|
const [uriType, setUriType] = useState<Maybe<TargetType>>(Maybe.nothing());
|
||||||
|
|
||||||
|
// Used by a callback on LookupInput to forcibly set the type of the lookup.
|
||||||
|
const [currentType, setTargetType] = useState<TargetType | null>(null);
|
||||||
|
|
||||||
|
// Used to allow repeatable lookups when weird errors happen.
|
||||||
|
const repeatableRef = useRef<string>("");
|
||||||
|
|
||||||
|
const getTypeEasy = useCallback(async (target: string): Promise<Result<TargetType, Error>> => {
|
||||||
|
return getType(target, getRegistry);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useCallback(async () => {
|
||||||
|
if (currentType != null) return Maybe.just(currentType);
|
||||||
|
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) =>
|
||||||
|
Maybe.just(type)
|
||||||
|
);
|
||||||
|
setUriType(uri);
|
||||||
|
}, [target, currentType, getTypeEasy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preload = async () => {
|
||||||
|
if (uriType.isNothing) return;
|
||||||
|
|
||||||
|
const registryUri = RootRegistryEnum.safeParse(uriType.value);
|
||||||
|
if (!registryUri.success) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadBootstrap(registryUri.data);
|
||||||
|
} catch (e) {
|
||||||
|
if (warningHandler != undefined) {
|
||||||
|
const message = e instanceof Error ? `(${truncated(e.message, 15)})` : ".";
|
||||||
|
warningHandler({
|
||||||
|
message: `Failed to preload registry${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
preload().catch(console.error);
|
||||||
|
}, [target, uriType, warningHandler]);
|
||||||
|
|
||||||
|
async function submitInternal(
|
||||||
|
target: string
|
||||||
|
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
||||||
|
if (target == null || target.length == 0)
|
||||||
|
return Result.err(new Error("A target must be given in order to execute a lookup."));
|
||||||
|
|
||||||
|
const targetType = await getTypeEasy(target);
|
||||||
|
|
||||||
|
if (targetType.isErr) {
|
||||||
|
return Result.err(
|
||||||
|
new Error("Unable to determine type, unable to send query", {
|
||||||
|
cause: targetType.error,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (targetType.value) {
|
||||||
|
// Block scoped case to allow url const reuse
|
||||||
|
case "ip4": {
|
||||||
|
await loadBootstrap("ip4");
|
||||||
|
const url = getRegistryURL(targetType.value, target);
|
||||||
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
||||||
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
return Result.ok({ data: result.value, url });
|
||||||
|
}
|
||||||
|
case "ip6": {
|
||||||
|
await loadBootstrap("ip6");
|
||||||
|
const url = getRegistryURL(targetType.value, target);
|
||||||
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
||||||
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
return Result.ok({ data: result.value, url });
|
||||||
|
}
|
||||||
|
case "domain": {
|
||||||
|
await loadBootstrap("domain");
|
||||||
|
const url = getRegistryURL(targetType.value, target);
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
if (url.startsWith("http://") && url != repeatableRef.current) {
|
||||||
|
repeatableRef.current = url;
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry this domain belongs to uses HTTP, which is not secure. " +
|
||||||
|
"In order to prevent a cryptic error from appearing due to mixed active content, " +
|
||||||
|
"or worse, a CORS error, this lookup has been blocked. Try again to force the lookup."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await getAndParse<Domain>(url, DomainSchema);
|
||||||
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
|
||||||
|
return Result.ok({ data: result.value, url });
|
||||||
|
}
|
||||||
|
case "autnum": {
|
||||||
|
await loadBootstrap("autnum");
|
||||||
|
const url = getRegistryURL(targetType.value, target);
|
||||||
|
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
|
||||||
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
return Result.ok({ data: result.value, url });
|
||||||
|
}
|
||||||
|
case "tld": {
|
||||||
|
// remove the leading dot
|
||||||
|
const value = target.startsWith(".") ? target.slice(1) : target;
|
||||||
|
const url = `https://root.rdap.org/domain/${value}`;
|
||||||
|
const result = await getAndParse<Domain>(url, DomainSchema);
|
||||||
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
return Result.ok({ data: result.value, url });
|
||||||
|
}
|
||||||
|
case "url": {
|
||||||
|
const response = await fetch(target);
|
||||||
|
|
||||||
|
if (response.status != 200)
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
`The URL provided returned a non-200 status code: ${response.status}.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Try each schema until one works
|
||||||
|
for (const schema of schemas) {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (result.success) return Result.ok({ data: result.data, url: target });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.err(new Error("No schema was able to parse the response."));
|
||||||
|
}
|
||||||
|
case "json": {
|
||||||
|
const data = JSON.parse(target);
|
||||||
|
for (const schema of schemas) {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (result.success) return Result.ok({ data: result.data, url: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "registrar": {
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Result.err(new Error("The type detected has not been implemented."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit({ target }: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
|
||||||
|
try {
|
||||||
|
// target is already set in state, but it's also provided by the form callback, so we'll use it.
|
||||||
|
const response = await submitInternal(target);
|
||||||
|
|
||||||
|
if (response.isErr) {
|
||||||
|
setError(response.error.message);
|
||||||
|
console.error(response.error);
|
||||||
|
} else setError(null);
|
||||||
|
|
||||||
|
return response.isOk
|
||||||
|
? Maybe.just({
|
||||||
|
data: response.value.data,
|
||||||
|
url: response.value.url,
|
||||||
|
completeTime: new Date(),
|
||||||
|
})
|
||||||
|
: Maybe.nothing();
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) setError("An unknown, unprocessable error has occurred.");
|
||||||
|
else setError(e.message);
|
||||||
|
console.error(e);
|
||||||
|
return Maybe.nothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
setTarget,
|
||||||
|
setTargetType,
|
||||||
|
submit,
|
||||||
|
currentType: uriType,
|
||||||
|
getType: getTypeEasy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useLookup;
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enums
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export const TargetTypeEnum = z.enum([
|
export const TargetTypeEnum = z.enum([
|
||||||
"autnum",
|
"autnum",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -51,6 +55,10 @@ export const StatusEnum = z.enum([
|
|||||||
"transfer period",
|
"transfer period",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export const LinkSchema = z.object({
|
export const LinkSchema = z.object({
|
||||||
value: z.string().optional(), // de-facto optional
|
value: z.string().optional(), // de-facto optional
|
||||||
rel: z.string().optional(), // de-facto optional
|
rel: z.string().optional(), // de-facto optional
|
||||||
@@ -91,7 +99,6 @@ export const NoticeSchema = z.object({
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
links: z.array(LinkSchema).optional(),
|
links: z.array(LinkSchema).optional(),
|
||||||
});
|
});
|
||||||
export type Notice = z.infer<typeof NoticeSchema>;
|
|
||||||
|
|
||||||
export const IpNetworkSchema = z.object({
|
export const IpNetworkSchema = z.object({
|
||||||
objectClassName: z.literal("ip network"),
|
objectClassName: z.literal("ip network"),
|
||||||
@@ -158,3 +165,33 @@ export const RegisterSchema = z.object({
|
|||||||
services: z.array(RegistrarSchema),
|
services: z.array(RegistrarSchema),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TypeScript Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// All precise target types that can be placed in the search bar.
|
||||||
|
export type TargetType = z.infer<typeof TargetTypeEnum>;
|
||||||
|
|
||||||
|
// Target types that can be selected by the user; IPv4 and IPv6 are combined into a single type for simplicity (IP/CIDR)
|
||||||
|
export type SimplifiedTargetType = Exclude<TargetType, "ip4" | "ip6"> | "ip";
|
||||||
|
|
||||||
|
// Root registry types that associate with a bootstrap file provided by the RDAP registry.
|
||||||
|
export type RootRegistryType = z.infer<typeof RootRegistryEnum>;
|
||||||
|
|
||||||
|
export type RdapStatusType = z.infer<typeof StatusEnum>;
|
||||||
|
export type Link = z.infer<typeof LinkSchema>;
|
||||||
|
export type Entity = z.infer<typeof EntitySchema>;
|
||||||
|
export type Nameserver = z.infer<typeof NameserverSchema>;
|
||||||
|
export type Event = z.infer<typeof EventSchema>;
|
||||||
|
export type Notice = z.infer<typeof NoticeSchema>;
|
||||||
|
export type IpNetwork = z.infer<typeof IpNetworkSchema>;
|
||||||
|
export type AutonomousNumber = z.infer<typeof AutonomousNumberSchema>;
|
||||||
|
export type Register = z.infer<typeof RegisterSchema>;
|
||||||
|
export type Domain = z.infer<typeof DomainSchema>;
|
||||||
|
|
||||||
|
export type SubmitProps = {
|
||||||
|
target: string;
|
||||||
|
requestJSContact: boolean;
|
||||||
|
followReferral: boolean;
|
||||||
|
};
|
||||||
72
src/rdap/services/rdap-api.ts
Normal file
72
src/rdap/services/rdap-api.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ZodSchema } from "zod";
|
||||||
|
import { Result } from "true-myth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse RDAP data from a URL
|
||||||
|
*/
|
||||||
|
export async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status == 200) {
|
||||||
|
const result = schema.safeParse(await response.json());
|
||||||
|
|
||||||
|
if (result.success === false) {
|
||||||
|
// flatten the errors to make them more readable and simple
|
||||||
|
const flatErrors = result.error.flatten(function (issue) {
|
||||||
|
const path = issue.path.map((value) => value.toString()).join(".");
|
||||||
|
return `${path}: ${issue.message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// combine them all, wrap them in a new error, and return it
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
[
|
||||||
|
"Could not parse the response from the registry.",
|
||||||
|
...flatErrors.formErrors,
|
||||||
|
...Object.values(flatErrors.fieldErrors).flat(),
|
||||||
|
].join("\n\t")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 302:
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry indicated that the resource requested is available at a different location."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry indicated that the request was malformed or could not be processed. Check that you typed in the correct information and try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case 403:
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry indicated that the request was forbidden. This could be due to rate limiting, abusive behavior, or other reasons. Try again later or contact the registry for more information."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry indicated that the resource requested could not be found; the resource either does not exist, or is something that the registry does not track (i.e. this software queried incorrectly, which is unlikely)."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case 500:
|
||||||
|
return Result.err(
|
||||||
|
new Error(
|
||||||
|
"The registry indicated that an internal server error occurred. This could be due to a misconfiguration, a bug, or other reasons. Try again later or contact the registry for more information."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return Result.err(
|
||||||
|
new Error(`The registry did not return an OK status code: ${response.status}.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/rdap/services/registry.ts
Normal file
51
src/rdap/services/registry.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Register, RootRegistryType } from "@/rdap/schemas";
|
||||||
|
import { RegisterSchema } from "@/rdap/schemas";
|
||||||
|
import { registryURLs } from "@/rdap/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry cache to avoid re-fetching bootstrap data
|
||||||
|
*/
|
||||||
|
const registryCache: Record<RootRegistryType, Register | null> = {
|
||||||
|
autnum: null,
|
||||||
|
domain: null,
|
||||||
|
ip4: null,
|
||||||
|
ip6: null,
|
||||||
|
entity: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch & load a specific registry's bootstrap data
|
||||||
|
*/
|
||||||
|
export async function loadBootstrap(type: RootRegistryType, force = false): Promise<void> {
|
||||||
|
// Early exit if already loaded and not forcing
|
||||||
|
if (registryCache[type] != null && !force) return;
|
||||||
|
|
||||||
|
// Fetch the bootstrapping file from the registry
|
||||||
|
const response = await fetch(registryURLs[type]);
|
||||||
|
if (response.status != 200) throw new Error(`Error: ${response.statusText}`);
|
||||||
|
|
||||||
|
// Parse it to ensure data integrity
|
||||||
|
const parsedRegister = RegisterSchema.safeParse(await response.json());
|
||||||
|
if (!parsedRegister.success)
|
||||||
|
throw new Error(`Could not parse IANA bootstrap response (type: ${type}).`);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
registryCache[type] = parsedRegister.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a registry's bootstrap data, loading it if necessary
|
||||||
|
*/
|
||||||
|
export async function getRegistry(type: RootRegistryType): Promise<Register> {
|
||||||
|
if (registryCache[type] == null) await loadBootstrap(type);
|
||||||
|
const registry = registryCache[type];
|
||||||
|
if (registry == null) throw new Error(`Could not load bootstrap data for ${type} registry.`);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cached registry data (or null if not loaded)
|
||||||
|
*/
|
||||||
|
export function getCachedRegistry(type: RootRegistryType): Register | null {
|
||||||
|
return registryCache[type];
|
||||||
|
}
|
||||||
84
src/rdap/services/url-resolver.ts
Normal file
84
src/rdap/services/url-resolver.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { RootRegistryType } from "@/rdap/schemas";
|
||||||
|
import { getCachedRegistry } from "@/rdap/services/registry";
|
||||||
|
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
||||||
|
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the RDAP URL for a given registry type and lookup target
|
||||||
|
*/
|
||||||
|
export function getRegistryURL(type: RootRegistryType, lookupTarget: string): string {
|
||||||
|
const bootstrap = getCachedRegistry(type);
|
||||||
|
if (bootstrap == null)
|
||||||
|
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
|
||||||
|
|
||||||
|
let url: string | null = null;
|
||||||
|
|
||||||
|
typeSwitch: switch (type) {
|
||||||
|
case "domain":
|
||||||
|
for (const bootstrapItem of bootstrap.services) {
|
||||||
|
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
|
||||||
|
// min length of 1 is validated in zod schema
|
||||||
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
||||||
|
break typeSwitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No matching domain found.`);
|
||||||
|
case "ip4": {
|
||||||
|
// Extract the IP address without CIDR suffix for matching
|
||||||
|
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||||
|
for (const bootstrapItem of bootstrap.services) {
|
||||||
|
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
|
||||||
|
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
|
||||||
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
||||||
|
break typeSwitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No matching IPv4 registry found for ${lookupTarget}.`);
|
||||||
|
}
|
||||||
|
case "ip6": {
|
||||||
|
// Extract the IP address without CIDR suffix for matching
|
||||||
|
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||||
|
for (const bootstrapItem of bootstrap.services) {
|
||||||
|
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
|
||||||
|
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
|
||||||
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
||||||
|
break typeSwitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
|
||||||
|
}
|
||||||
|
case "autnum": {
|
||||||
|
// Extract ASN number from "AS12345" format
|
||||||
|
const asnMatch = lookupTarget.match(/^AS(\d+)$/i);
|
||||||
|
if (!asnMatch || !asnMatch[1]) {
|
||||||
|
throw new Error(`Invalid ASN format: ${lookupTarget}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asnNumber = parseInt(asnMatch[1], 10);
|
||||||
|
if (isNaN(asnNumber)) {
|
||||||
|
throw new Error(`Invalid ASN number: ${lookupTarget}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bootstrapItem of bootstrap.services) {
|
||||||
|
// bootstrapItem[0] contains ASN ranges like ["64512-65534", "13312-18431"]
|
||||||
|
if (bootstrapItem[0].some((range) => asnInRange(asnNumber, range))) {
|
||||||
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
||||||
|
break typeSwitch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No matching registry found for ${lookupTarget}.`);
|
||||||
|
}
|
||||||
|
case "entity":
|
||||||
|
throw new Error(`No matching entity found.`);
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid lookup target provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url == null) throw new Error("No lookup target was resolved.");
|
||||||
|
|
||||||
|
// Map internal types to RDAP endpoint paths
|
||||||
|
// ip4 and ip6 both use the 'ip' endpoint in RDAP
|
||||||
|
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
|
||||||
|
|
||||||
|
return `${url}${rdapPath}/${lookupTarget}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Register, RootRegistryType, TargetType } from "@/types";
|
import type { Register, RootRegistryType, TargetType } from "@/rdap/schemas";
|
||||||
import { Result } from "true-myth";
|
import { Result } from "true-myth";
|
||||||
|
|
||||||
export function domainMatchPredicate(domain: string): (tld: string) => boolean {
|
export function domainMatchPredicate(domain: string): (tld: string) => boolean {
|
||||||
39
src/types.ts
39
src/types.ts
@@ -1,39 +0,0 @@
|
|||||||
import type { z } from "zod";
|
|
||||||
import type {
|
|
||||||
AutonomousNumberSchema,
|
|
||||||
DomainSchema,
|
|
||||||
EntitySchema,
|
|
||||||
EventSchema,
|
|
||||||
IpNetworkSchema,
|
|
||||||
LinkSchema,
|
|
||||||
NameserverSchema,
|
|
||||||
TargetTypeEnum,
|
|
||||||
RegisterSchema,
|
|
||||||
StatusEnum,
|
|
||||||
RootRegistryEnum,
|
|
||||||
} from "@/schema";
|
|
||||||
|
|
||||||
// All precise target types that can be placed in the search bar.
|
|
||||||
export type TargetType = z.infer<typeof TargetTypeEnum>;
|
|
||||||
|
|
||||||
// Target types that can be selected by the user; IPv4 and IPv6 are combined into a single type for simplicity (IP/CIDR)
|
|
||||||
export type SimplifiedTargetType = Exclude<TargetType, "ip4" | "ip6"> | "ip";
|
|
||||||
|
|
||||||
// Root registry types that associate with a bootstrap file provided by the RDAP registry.
|
|
||||||
export type RootRegistryType = z.infer<typeof RootRegistryEnum>;
|
|
||||||
|
|
||||||
export type RdapStatusType = z.infer<typeof StatusEnum>;
|
|
||||||
export type Link = z.infer<typeof LinkSchema>;
|
|
||||||
export type Entity = z.infer<typeof EntitySchema>;
|
|
||||||
export type Nameserver = z.infer<typeof NameserverSchema>;
|
|
||||||
export type Event = z.infer<typeof EventSchema>;
|
|
||||||
export type IpNetwork = z.infer<typeof IpNetworkSchema>;
|
|
||||||
export type AutonomousNumber = z.infer<typeof AutonomousNumberSchema>;
|
|
||||||
export type Register = z.infer<typeof RegisterSchema>;
|
|
||||||
export type Domain = z.infer<typeof DomainSchema>;
|
|
||||||
|
|
||||||
export type SubmitProps = {
|
|
||||||
target: string;
|
|
||||||
requestJSContact: boolean;
|
|
||||||
followReferral: boolean;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user