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:
2025-10-22 12:21:00 -05:00
parent 7073936e6c
commit 0e9336df1d
36 changed files with 926 additions and 658 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { asnInRange } from "./helpers";
import { asnInRange } from "@/lib/network";
describe("asnInRange", () => {
describe("basic matching", () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { ipv4InCIDR, ipv6InCIDR } from "./helpers";
import { ipv4InCIDR, ipv6InCIDR } from "@/lib/network";
describe("ipv4InCIDR", () => {
describe("basic matching", () => {

View File

@@ -1,8 +1,8 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { getType } from "./rdap";
import type { Register, RootRegistryType } from "./types";
import { registryURLs } from "./constants";
import { getType } from "@/rdap/utils";
import type { Register, RootRegistryType } from "@/rdap/schemas";
import { registryURLs } from "@/rdap/constants";
// Integration tests that fetch real IANA bootstrap data
// These are slower but test against actual registries

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { getType } from "./rdap";
import type { Register } from "./types";
import { getType } from "@/rdap/utils";
import type { Register } from "@/rdap/schemas";
// Mock registry getter (matches real IANA structure: [email, tags, urls])
const mockRegistry: Register = {

View File

@@ -1,13 +1,13 @@
import { useForm, Controller } from "react-hook-form";
import type { FunctionComponent } from "react";
import { useState } from "react";
import { onPromise, preventDefault } from "@/helpers";
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/types";
import { TargetTypeEnum } from "@/schema";
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 "@/constants";
import { placeholders } from "@/rdap/constants";
/**
* Props for the LookupInput component.

View File

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

View File

@@ -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;

View File

@@ -33,3 +33,30 @@ export function findASN(asn: number, ranges: string[]) {
}
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
View 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
View 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
View 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;
}
}

View File

@@ -1,6 +1,55 @@
import type { SyntheticEvent } from "react";
import { type ClassValue, clsx } from "clsx";
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[]) {
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();
}

View File

@@ -6,7 +6,7 @@ import "@fontsource-variable/inter";
import "@fontsource/ibm-plex-mono/400.css";
import "@radix-ui/themes/styles.css";
import "../styles/globals.css";
import "@/styles/globals.css";
const MyApp: AppType = ({ Component, pageProps }) => {
return (

View File

@@ -1,14 +1,14 @@
import { type NextPage } from "next";
import Head from "next/head";
import { useState } from "react";
import Generic from "@/components/lookup/Generic";
import type { MetaParsedGeneric } from "@/hooks/useLookup";
import useLookup from "@/hooks/useLookup";
import LookupInput from "@/components/form/LookupInput";
import ErrorCard from "@/components/common/ErrorCard";
import { ThemeToggle } from "@/components/common/ThemeToggle";
import Generic from "@/rdap/components/Generic";
import type { MetaParsedGeneric } from "@/rdap/hooks/useLookup";
import useLookup from "@/rdap/hooks/useLookup";
import LookupInput from "@/rdap/components/LookupInput";
import ErrorCard from "@/components/ErrorCard";
import { ThemeToggle } from "@/components/ThemeToggle";
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";
const Index: NextPage = () => {

View File

@@ -1,10 +1,10 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { AutonomousNumber } from "@/types";
import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
import type { AutonomousNumber } from "@/rdap/schemas";
import Events from "@/rdap/components/Events";
import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList";
import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type AutnumCardProps = {

View File

@@ -1,11 +1,11 @@
import type { FunctionComponent } from "react";
import React from "react";
import { rdapStatusInfo } from "@/constants";
import type { Domain } from "@/types";
import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
import { rdapStatusInfo } from "@/rdap/constants";
import type { Domain } from "@/rdap/schemas";
import Events from "@/rdap/components/Events";
import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList";
import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type DomainProps = {

View File

@@ -1,9 +1,9 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Entity } from "@/types";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
import type { Entity } from "@/rdap/schemas";
import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList";
import AbstractCard from "@/components/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
export type EntityCardProps = {

View File

@@ -1,6 +1,6 @@
import type { FunctionComponent } from "react";
import type { Event } from "@/types";
import DynamicDate from "@/components/common/DynamicDate";
import type { Event } from "@/rdap/schemas";
import DynamicDate from "@/components/DynamicDate";
import { Table, Text } from "@radix-ui/themes";
export type EventsProps = {

View File

@@ -1,11 +1,11 @@
import type { FunctionComponent } from "react";
import DomainCard from "@/components/lookup/DomainCard";
import IPCard from "@/components/lookup/IPCard";
import AutnumCard from "@/components/lookup/AutnumCard";
import EntityCard from "@/components/lookup/EntityCard";
import NameserverCard from "@/components/lookup/NameserverCard";
import type { Domain, AutonomousNumber, Entity, Nameserver, IpNetwork } from "@/types";
import AbstractCard from "@/components/common/AbstractCard";
import DomainCard from "@/rdap/components/DomainCard";
import IPCard from "@/rdap/components/IPCard";
import AutnumCard from "@/rdap/components/AutnumCard";
import EntityCard from "@/rdap/components/EntityCard";
import NameserverCard from "@/rdap/components/NameserverCard";
import type { Domain, AutonomousNumber, Entity, Nameserver, IpNetwork } from "@/rdap/schemas";
import AbstractCard from "@/components/AbstractCard";
export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | IpNetwork;

View File

@@ -1,10 +1,10 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { IpNetwork } from "@/types";
import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
import type { IpNetwork } from "@/rdap/schemas";
import Events from "@/rdap/components/Events";
import Property from "@/components/Property";
import PropertyList from "@/components/PropertyList";
import AbstractCard from "@/components/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type IPCardProps = {

View 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&apos;s RDAP record
</Text>
</Flex>
</Flex>
</Flex>
</form>
);
};
export default LookupInput;

View File

@@ -1,8 +1,8 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Nameserver } from "@/types";
import Property from "@/components/common/Property";
import AbstractCard from "@/components/common/AbstractCard";
import type { Nameserver } from "@/rdap/schemas";
import Property from "@/components/Property";
import AbstractCard from "@/components/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
export type NameserverCardProps = {

View File

@@ -1,5 +1,5 @@
// 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> = {
validated:

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

View File

@@ -1,5 +1,9 @@
import { z } from "zod";
// ============================================================================
// Enums
// ============================================================================
export const TargetTypeEnum = z.enum([
"autnum",
"domain",
@@ -51,6 +55,10 @@ export const StatusEnum = z.enum([
"transfer period",
]);
// ============================================================================
// Schemas
// ============================================================================
export const LinkSchema = z.object({
value: 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(),
links: z.array(LinkSchema).optional(),
});
export type Notice = z.infer<typeof NoticeSchema>;
export const IpNetworkSchema = z.object({
objectClassName: z.literal("ip network"),
@@ -158,3 +165,33 @@ export const RegisterSchema = z.object({
services: z.array(RegistrarSchema),
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;
};

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

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

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

View File

@@ -1,4 +1,4 @@
import type { Register, RootRegistryType, TargetType } from "@/types";
import type { Register, RootRegistryType, TargetType } from "@/rdap/schemas";
import { Result } from "true-myth";
export function domainMatchPredicate(domain: string): (tld: string) => boolean {

View File

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