From 0e9336df1d3b44d97d7729cb9a9b52b80578b218 Mon Sep 17 00:00:00 2001 From: Xevion Date: Wed, 22 Oct 2025 12:21:00 -0500 Subject: [PATCH] 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. --- .../asn-helpers.test.ts} | 2 +- .../network-helpers.test.ts} | 2 +- .../rdap-integration.test.ts} | 6 +- .../rdap-utils.test.ts} | 4 +- src/components/{common => }/AbstractCard.tsx | 0 src/components/{common => }/DynamicDate.tsx | 0 src/components/{common => }/ErrorCard.tsx | 0 src/components/{common => }/Property.tsx | 0 src/components/{common => }/PropertyList.tsx | 0 src/components/{common => }/ThemeToggle.tsx | 0 src/components/form/LookupInput.tsx | 8 +- src/helpers.ts | 163 ------- src/hooks/useLookup.tsx | 402 ------------------ src/{bootstrap.ts => lib/network/asn.ts} | 27 ++ src/lib/network/index.ts | 3 + src/lib/network/ipv4.ts | 36 ++ src/lib/network/ipv6.ts | 59 +++ src/lib/utils.ts | 49 +++ src/pages/_app.tsx | 2 +- src/pages/index.tsx | 14 +- .../lookup => rdap/components}/AutnumCard.tsx | 10 +- .../lookup => rdap/components}/DomainCard.tsx | 12 +- .../lookup => rdap/components}/EntityCard.tsx | 8 +- .../lookup => rdap/components}/Events.tsx | 4 +- .../lookup => rdap/components}/Generic.tsx | 14 +- .../lookup => rdap/components}/IPCard.tsx | 10 +- src/rdap/components/LookupInput.tsx | 246 +++++++++++ .../components}/NameserverCard.tsx | 6 +- src/{ => rdap}/constants.ts | 2 +- src/rdap/hooks/useLookup.tsx | 208 +++++++++ src/{schema.ts => rdap/schemas.ts} | 39 +- src/rdap/services/rdap-api.ts | 72 ++++ src/rdap/services/registry.ts | 51 +++ src/rdap/services/url-resolver.ts | 84 ++++ src/{rdap.ts => rdap/utils.ts} | 2 +- src/types.ts | 39 -- 36 files changed, 926 insertions(+), 658 deletions(-) rename src/{helpers.asn.test.ts => __tests__/asn-helpers.test.ts} (99%) rename src/{helpers.test.ts => __tests__/network-helpers.test.ts} (99%) rename src/{rdap.integration.test.ts => __tests__/rdap-integration.test.ts} (91%) rename src/{rdap.test.ts => __tests__/rdap-utils.test.ts} (99%) rename src/components/{common => }/AbstractCard.tsx (100%) rename src/components/{common => }/DynamicDate.tsx (100%) rename src/components/{common => }/ErrorCard.tsx (100%) rename src/components/{common => }/Property.tsx (100%) rename src/components/{common => }/PropertyList.tsx (100%) rename src/components/{common => }/ThemeToggle.tsx (100%) delete mode 100644 src/helpers.ts delete mode 100644 src/hooks/useLookup.tsx rename src/{bootstrap.ts => lib/network/asn.ts} (66%) create mode 100644 src/lib/network/index.ts create mode 100644 src/lib/network/ipv4.ts create mode 100644 src/lib/network/ipv6.ts rename src/{components/lookup => rdap/components}/AutnumCard.tsx (84%) rename src/{components/lookup => rdap/components}/DomainCard.tsx (80%) rename src/{components/lookup => rdap/components}/EntityCard.tsx (85%) rename src/{components/lookup => rdap/components}/Events.tsx (91%) rename src/{components/lookup => rdap/components}/Generic.tsx (71%) rename src/{components/lookup => rdap/components}/IPCard.tsx (85%) create mode 100644 src/rdap/components/LookupInput.tsx rename src/{components/lookup => rdap/components}/NameserverCard.tsx (82%) rename src/{ => rdap}/constants.ts (99%) create mode 100644 src/rdap/hooks/useLookup.tsx rename src/{schema.ts => rdap/schemas.ts} (70%) create mode 100644 src/rdap/services/rdap-api.ts create mode 100644 src/rdap/services/registry.ts create mode 100644 src/rdap/services/url-resolver.ts rename src/{rdap.ts => rdap/utils.ts} (98%) delete mode 100644 src/types.ts diff --git a/src/helpers.asn.test.ts b/src/__tests__/asn-helpers.test.ts similarity index 99% rename from src/helpers.asn.test.ts rename to src/__tests__/asn-helpers.test.ts index 2a20ba7..2264bed 100644 --- a/src/helpers.asn.test.ts +++ b/src/__tests__/asn-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { asnInRange } from "./helpers"; +import { asnInRange } from "@/lib/network"; describe("asnInRange", () => { describe("basic matching", () => { diff --git a/src/helpers.test.ts b/src/__tests__/network-helpers.test.ts similarity index 99% rename from src/helpers.test.ts rename to src/__tests__/network-helpers.test.ts index 10efbe0..ed41221 100644 --- a/src/helpers.test.ts +++ b/src/__tests__/network-helpers.test.ts @@ -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", () => { diff --git a/src/rdap.integration.test.ts b/src/__tests__/rdap-integration.test.ts similarity index 91% rename from src/rdap.integration.test.ts rename to src/__tests__/rdap-integration.test.ts index 6b7c37d..9f6686f 100644 --- a/src/rdap.integration.test.ts +++ b/src/__tests__/rdap-integration.test.ts @@ -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 diff --git a/src/rdap.test.ts b/src/__tests__/rdap-utils.test.ts similarity index 99% rename from src/rdap.test.ts rename to src/__tests__/rdap-utils.test.ts index 6f916a8..f096669 100644 --- a/src/rdap.test.ts +++ b/src/__tests__/rdap-utils.test.ts @@ -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 = { diff --git a/src/components/common/AbstractCard.tsx b/src/components/AbstractCard.tsx similarity index 100% rename from src/components/common/AbstractCard.tsx rename to src/components/AbstractCard.tsx diff --git a/src/components/common/DynamicDate.tsx b/src/components/DynamicDate.tsx similarity index 100% rename from src/components/common/DynamicDate.tsx rename to src/components/DynamicDate.tsx diff --git a/src/components/common/ErrorCard.tsx b/src/components/ErrorCard.tsx similarity index 100% rename from src/components/common/ErrorCard.tsx rename to src/components/ErrorCard.tsx diff --git a/src/components/common/Property.tsx b/src/components/Property.tsx similarity index 100% rename from src/components/common/Property.tsx rename to src/components/Property.tsx diff --git a/src/components/common/PropertyList.tsx b/src/components/PropertyList.tsx similarity index 100% rename from src/components/common/PropertyList.tsx rename to src/components/PropertyList.tsx diff --git a/src/components/common/ThemeToggle.tsx b/src/components/ThemeToggle.tsx similarity index 100% rename from src/components/common/ThemeToggle.tsx rename to src/components/ThemeToggle.tsx diff --git a/src/components/form/LookupInput.tsx b/src/components/form/LookupInput.tsx index d3a7cb7..59cb1e2 100644 --- a/src/components/form/LookupInput.tsx +++ b/src/components/form/LookupInput.tsx @@ -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. diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index 1f4cb41..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { SyntheticEvent } from "react"; - -declare global { - interface ObjectConstructor { - entries(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(promise: (event: SyntheticEvent) => Promise) { - 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; -} diff --git a/src/hooks/useLookup.tsx b/src/hooks/useLookup.tsx deleted file mode 100644 index 5d15dbb..0000000 --- a/src/hooks/useLookup.tsx +++ /dev/null @@ -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>({ - autnum: null, - domain: null, - ip4: null, - ip6: null, - entity: null, - }); - - const [error, setError] = useState(null); - const [target, setTarget] = useState(""); - const [uriType, setUriType] = useState>(Maybe.nothing()); - - // Used by a callback on LookupInput to forcibly set the type of the lookup. - const [currentType, setTargetType] = useState(null); - - // Used to allow repeatable lookups when weird errors happen. - const repeatableRef = useRef(""); - - useCallback(async () => { - if (currentType != null) return Maybe.just(currentType); - const uri: Maybe = (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 { - 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> { - 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(url: string, schema: ZodSchema): Promise> { - 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> { - 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(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(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(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(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(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> { - 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; diff --git a/src/bootstrap.ts b/src/lib/network/asn.ts similarity index 66% rename from src/bootstrap.ts rename to src/lib/network/asn.ts index 4a2536b..567067f 100644 --- a/src/bootstrap.ts +++ b/src/lib/network/asn.ts @@ -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; +} diff --git a/src/lib/network/index.ts b/src/lib/network/index.ts new file mode 100644 index 0000000..07496c8 --- /dev/null +++ b/src/lib/network/index.ts @@ -0,0 +1,3 @@ +export { ipv4InCIDR } from "@/lib/network/ipv4"; +export { ipv6InCIDR } from "@/lib/network/ipv6"; +export { findASN, asnInRange } from "@/lib/network/asn"; diff --git a/src/lib/network/ipv4.ts b/src/lib/network/ipv4.ts new file mode 100644 index 0000000..3200ed8 --- /dev/null +++ b/src/lib/network/ipv4.ts @@ -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); +} diff --git a/src/lib/network/ipv6.ts b/src/lib/network/ipv6.ts new file mode 100644 index 0000000..dc138b2 --- /dev/null +++ b/src/lib/network/ipv6.ts @@ -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; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac680b3..6d472f8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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(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(promise: (event: SyntheticEvent) => Promise) { + 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(); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c349cd8..bc3d5b6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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 ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index b5c6144..6cbb664 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -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 = () => { diff --git a/src/components/lookup/AutnumCard.tsx b/src/rdap/components/AutnumCard.tsx similarity index 84% rename from src/components/lookup/AutnumCard.tsx rename to src/rdap/components/AutnumCard.tsx index 1e2bd77..40282dc 100644 --- a/src/components/lookup/AutnumCard.tsx +++ b/src/rdap/components/AutnumCard.tsx @@ -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 = { diff --git a/src/components/lookup/DomainCard.tsx b/src/rdap/components/DomainCard.tsx similarity index 80% rename from src/components/lookup/DomainCard.tsx rename to src/rdap/components/DomainCard.tsx index aa6c1cd..d015b83 100644 --- a/src/components/lookup/DomainCard.tsx +++ b/src/rdap/components/DomainCard.tsx @@ -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 = { diff --git a/src/components/lookup/EntityCard.tsx b/src/rdap/components/EntityCard.tsx similarity index 85% rename from src/components/lookup/EntityCard.tsx rename to src/rdap/components/EntityCard.tsx index 0c1f3c3..e28710f 100644 --- a/src/components/lookup/EntityCard.tsx +++ b/src/rdap/components/EntityCard.tsx @@ -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 = { diff --git a/src/components/lookup/Events.tsx b/src/rdap/components/Events.tsx similarity index 91% rename from src/components/lookup/Events.tsx rename to src/rdap/components/Events.tsx index 8d1623d..b881082 100644 --- a/src/components/lookup/Events.tsx +++ b/src/rdap/components/Events.tsx @@ -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 = { diff --git a/src/components/lookup/Generic.tsx b/src/rdap/components/Generic.tsx similarity index 71% rename from src/components/lookup/Generic.tsx rename to src/rdap/components/Generic.tsx index 4ba621e..7b4abea 100644 --- a/src/components/lookup/Generic.tsx +++ b/src/rdap/components/Generic.tsx @@ -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; diff --git a/src/components/lookup/IPCard.tsx b/src/rdap/components/IPCard.tsx similarity index 85% rename from src/components/lookup/IPCard.tsx rename to src/rdap/components/IPCard.tsx index 3b7b9f2..5c17847 100644 --- a/src/components/lookup/IPCard.tsx +++ b/src/rdap/components/IPCard.tsx @@ -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 = { diff --git a/src/rdap/components/LookupInput.tsx b/src/rdap/components/LookupInput.tsx new file mode 100644 index 0000000..59cb1e2 --- /dev/null +++ b/src/rdap/components/LookupInput.tsx @@ -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; + /** + * Callback function called when a user hits submit. + * @param props - The submit props. + * @returns A promise. + */ + onSubmit?: (props: SubmitProps) => Promise; + /** + * 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; + detectedType: Maybe; +}; + +const LookupInput: FunctionComponent = ({ + isLoading, + onSubmit, + onChange, + detectedType, +}: LookupInputProps) => { + const { register, handleSubmit, getValues, control } = useForm({ + 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 = { + 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 = { + 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("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 ( +
+ + + + { + 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, + }} + > + + + {isLoading ? ( + + ) : ( + + )} + + + + + { + setSelected(value as SimplifiedTargetType | "auto"); + + if (onChange != undefined) + void onChange({ + target: getValues("target"), + targetType: retrieveTargetType(value), + }); + }} + disabled={isLoading} + size="3" + > + + {selected == "auto" ? ( + detectedType.isJust ? ( + Auto ({targetShortNames[detectedType.value]}) + ) : ( + objectNames["auto"] + ) + ) : ( + + + {objectNames[selected]} + + )} + + + + {Object.entries(objectNames).map(([key, value]) => ( + + + {value} + + + ))} + + + + + + + + ( + + )} + /> + Request JSContact + + + + + ( + + )} + /> + Follow referral to registrar's RDAP record + + + + +
+ ); +}; + +export default LookupInput; diff --git a/src/components/lookup/NameserverCard.tsx b/src/rdap/components/NameserverCard.tsx similarity index 82% rename from src/components/lookup/NameserverCard.tsx rename to src/rdap/components/NameserverCard.tsx index 14eecc7..6119c0a 100644 --- a/src/components/lookup/NameserverCard.tsx +++ b/src/rdap/components/NameserverCard.tsx @@ -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 = { diff --git a/src/constants.ts b/src/rdap/constants.ts similarity index 99% rename from src/constants.ts rename to src/rdap/constants.ts index f9555da..2bf7618 100644 --- a/src/constants.ts +++ b/src/rdap/constants.ts @@ -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 = { validated: diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx new file mode 100644 index 0000000..b0551c7 --- /dev/null +++ b/src/rdap/hooks/useLookup.tsx @@ -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(null); + const [target, setTarget] = useState(""); + const [uriType, setUriType] = useState>(Maybe.nothing()); + + // Used by a callback on LookupInput to forcibly set the type of the lookup. + const [currentType, setTargetType] = useState(null); + + // Used to allow repeatable lookups when weird errors happen. + const repeatableRef = useRef(""); + + const getTypeEasy = useCallback(async (target: string): Promise> => { + return getType(target, getRegistry); + }, []); + + useCallback(async () => { + if (currentType != null) return Maybe.just(currentType); + const uri: Maybe = (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> { + 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(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(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(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(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(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> { + 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; diff --git a/src/schema.ts b/src/rdap/schemas.ts similarity index 70% rename from src/schema.ts rename to src/rdap/schemas.ts index b8c0462..e7e35fa 100644 --- a/src/schema.ts +++ b/src/rdap/schemas.ts @@ -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; 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; + +// 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 | "ip"; + +// Root registry types that associate with a bootstrap file provided by the RDAP registry. +export type RootRegistryType = z.infer; + +export type RdapStatusType = z.infer; +export type Link = z.infer; +export type Entity = z.infer; +export type Nameserver = z.infer; +export type Event = z.infer; +export type Notice = z.infer; +export type IpNetwork = z.infer; +export type AutonomousNumber = z.infer; +export type Register = z.infer; +export type Domain = z.infer; + +export type SubmitProps = { + target: string; + requestJSContact: boolean; + followReferral: boolean; +}; diff --git a/src/rdap/services/rdap-api.ts b/src/rdap/services/rdap-api.ts new file mode 100644 index 0000000..0c705ef --- /dev/null +++ b/src/rdap/services/rdap-api.ts @@ -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(url: string, schema: ZodSchema): Promise> { + 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}.`) + ); + } +} diff --git a/src/rdap/services/registry.ts b/src/rdap/services/registry.ts new file mode 100644 index 0000000..e82d48b --- /dev/null +++ b/src/rdap/services/registry.ts @@ -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 = { + 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 { + // 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 { + 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]; +} diff --git a/src/rdap/services/url-resolver.ts b/src/rdap/services/url-resolver.ts new file mode 100644 index 0000000..7998f7f --- /dev/null +++ b/src/rdap/services/url-resolver.ts @@ -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}`; +} diff --git a/src/rdap.ts b/src/rdap/utils.ts similarity index 98% rename from src/rdap.ts rename to src/rdap/utils.ts index e7dde75..39fa13a 100644 --- a/src/rdap.ts +++ b/src/rdap/utils.ts @@ -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 { diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 0e9afcc..0000000 --- a/src/types.ts +++ /dev/null @@ -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; - -// 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 | "ip"; - -// Root registry types that associate with a bootstrap file provided by the RDAP registry. -export type RootRegistryType = z.infer; - -export type RdapStatusType = z.infer; -export type Link = z.infer; -export type Entity = z.infer; -export type Nameserver = z.infer; -export type Event = z.infer; -export type IpNetwork = z.infer; -export type AutonomousNumber = z.infer; -export type Register = z.infer; -export type Domain = z.infer; - -export type SubmitProps = { - target: string; - requestJSContact: boolean; - followReferral: boolean; -};