diff --git a/src/components/AbstractCard.tsx b/src/components/AbstractCard.tsx index dbc9e2b..73989fe 100644 --- a/src/components/AbstractCard.tsx +++ b/src/components/AbstractCard.tsx @@ -2,7 +2,7 @@ import type { FunctionComponent, ReactNode } from "react"; import React from "react"; import { useBoolean } from "usehooks-ts"; import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons"; -import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes"; +import { Card, Flex, Box, IconButton, Code, ScrollArea, Tooltip } from "@radix-ui/themes"; type AbstractCardProps = { children?: ReactNode; @@ -38,72 +38,85 @@ const AbstractCard: FunctionComponent = ({ {url != undefined && ( - - - - - + + + + + + + )} {data != undefined && ( <> - { - navigator.clipboard - .writeText(JSON.stringify(data, null, 4)) - .then( - () => { - // Successfully copied to clipboard - }, - (err) => { - if (err instanceof Error) - console.error( - `Failed to copy to clipboard (${err.toString()}).` - ); - else - console.error( - "Failed to copy to clipboard." - ); + + { + navigator.clipboard + .writeText(JSON.stringify(data, null, 4)) + .then( + () => { + // Successfully copied to clipboard + }, + (err) => { + if (err instanceof Error) + console.error( + `Failed to copy to clipboard (${err.toString()}).` + ); + else + console.error( + "Failed to copy to clipboard." + ); + } + ); + }} + aria-label="Copy JSON to clipboard" + > + + + + + { + const file = new Blob( + [JSON.stringify(data, null, 4)], + { + type: "application/json", } ); - }} - aria-label="Copy JSON to clipboard" - > - - - { - const file = new Blob([JSON.stringify(data, null, 4)], { - type: "application/json", - }); - const anchor = document.createElement("a"); - anchor.href = URL.createObjectURL(file); - anchor.download = "response.json"; - anchor.click(); - }} - aria-label="Download JSON" + const anchor = document.createElement("a"); + anchor.href = URL.createObjectURL(file); + anchor.download = "response.json"; + anchor.click(); + }} + aria-label="Download JSON" + > + + + + - - - - - + + + + )} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 6cbb664..8965047 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -70,11 +70,14 @@ const Index: NextPage = () => { setTarget(target); setTargetType(targetType); - const detectResult = await getType(target); - if (detectResult.isOk) { - setDetectedType(Maybe.just(detectResult.value)); - } else { - setDetectedType(Maybe.nothing()); + // Only run autodetection when in autodetect mode (targetType is null) + if (targetType === null) { + const detectResult = await getType(target); + if (detectResult.isOk) { + setDetectedType(Maybe.just(detectResult.value)); + } else { + setDetectedType(Maybe.nothing()); + } } }} onSubmit={async function (props) { diff --git a/src/rdap/components/AutnumCard.tsx b/src/rdap/components/AutnumCard.tsx index 9cb1577..2db157b 100644 --- a/src/rdap/components/AutnumCard.tsx +++ b/src/rdap/components/AutnumCard.tsx @@ -6,6 +6,9 @@ import Property from "@/components/Property"; import CopyButton from "@/components/CopyButton"; import StatusBadge from "@/components/StatusBadge"; import AbstractCard from "@/components/AbstractCard"; +import EntitiesSection from "@/rdap/components/EntitiesSection"; +import LinksSection from "@/rdap/components/LinksSection"; +import RemarksSection from "@/rdap/components/RemarksSection"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type AutnumCardProps = { @@ -50,21 +53,51 @@ const AutnumCard: FunctionComponent = ({ data, url }: AutnumCar - {data.type} - {data.country.toUpperCase()} - - - - - Status - - - {data.status.map((status, index) => ( - - ))} - - - + {data.type && {data.type}} + {data.country && {data.country.toUpperCase()}} + {data.status && data.status.length > 0 && ( + + Status + + + {data.status.map((status, index) => ( + + ))} + + + + )} + {data.port43 && ( + + WHOIS Server + + + {data.port43} + + + + + )} + {data.entities && data.entities.length > 0 && ( + + + + )} + {data.events && data.events.length > 0 && ( + + + + )} + {data.links && data.links.length > 0 && ( + + + + )} + {data.remarks && data.remarks.length > 0 && ( + + + + )} ); diff --git a/src/rdap/components/DomainCard.tsx b/src/rdap/components/DomainCard.tsx index 874c9a0..ec77f77 100644 --- a/src/rdap/components/DomainCard.tsx +++ b/src/rdap/components/DomainCard.tsx @@ -6,6 +6,11 @@ import Property from "@/components/Property"; import CopyButton from "@/components/CopyButton"; import StatusBadge from "@/components/StatusBadge"; import AbstractCard from "@/components/AbstractCard"; +import NameserversSection from "@/rdap/components/NameserversSection"; +import SecureDNSSection from "@/rdap/components/SecureDNSSection"; +import EntitiesSection from "@/rdap/components/EntitiesSection"; +import LinksSection from "@/rdap/components/LinksSection"; +import RemarksSection from "@/rdap/components/RemarksSection"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type DomainProps = { @@ -48,28 +53,75 @@ const DomainCard: FunctionComponent = ({ data, url }: DomainProps) - - Handle - - - {data.handle} - - - - - - - - - Status - - - {data.status.map((statusKey, index) => ( - - ))} - - - + {data.handle != undefined ? ( + + Handle + + + {data.handle} + + + + + ) : null} + {data.status && data.status.length > 0 && ( + + Status + + + {data.status.map((statusKey, index) => ( + + ))} + + + + )} + {data.port43 && ( + + WHOIS Server + + + {data.port43} + + + + + )} + {data.nameservers && data.nameservers.length > 0 && ( + + + + )} + {data.secureDNS && ( + + + + )} + {data.entities && data.entities.length > 0 && ( + + + + )} + {data.events && data.events.length > 0 && ( + + + + )} + {data.links && data.links.length > 0 && ( + + + + )} + {data.notices && data.notices.length > 0 && ( + + + + )} + {data.remarks && data.remarks.length > 0 && ( + + + + )} ); diff --git a/src/rdap/components/EntitiesSection.tsx b/src/rdap/components/EntitiesSection.tsx new file mode 100644 index 0000000..6b43852 --- /dev/null +++ b/src/rdap/components/EntitiesSection.tsx @@ -0,0 +1,147 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { Entity, RdapStatusType } from "@/rdap/schemas"; +import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes"; +import VCardDisplay from "@/rdap/components/VCardDisplay"; +import CopyButton from "@/components/CopyButton"; +import StatusBadge from "@/components/StatusBadge"; + +export type EntitiesSectionProps = { + entities: Entity[]; +}; + +const EntitiesSection: FunctionComponent = ({ entities }) => { + if (!entities || entities.length === 0) return null; + + return ( + + {entities.map((entity, index) => { + return ( + + + + {entity.handle && ( + + Handle + + + {entity.handle} + + + + + )} + {entity.roles && entity.roles.length > 0 && ( + + Roles + + + {entity.roles.map( + (role: string, roleIndex: number) => ( + + {role} + + ) + )} + + + + )} + {entity.status && entity.status.length > 0 && ( + + Status + + + {entity.status.map( + ( + status: RdapStatusType, + statusIndex: number + ) => ( + + ) + )} + + + + )} + + + {entity.vcardArray && ( + + + + )} + + {entity.publicIds && entity.publicIds.length > 0 && ( + + + + + Public ID Type + + + Identifier + + + + + {entity.publicIds.map( + ( + publicId: { type: string; identifier: string }, + publicIdIndex: number + ) => ( + + {publicId.type} + + + + {publicId.identifier} + + + + + + ) + )} + + + )} + + {entity.port43 && ( + + + WHOIS Server: + + {entity.port43} + + + )} + + + ); + })} + + ); +}; + +export default EntitiesSection; diff --git a/src/rdap/components/EntityCard.tsx b/src/rdap/components/EntityCard.tsx index cebf49c..2de2e17 100644 --- a/src/rdap/components/EntityCard.tsx +++ b/src/rdap/components/EntityCard.tsx @@ -1,8 +1,15 @@ import type { FunctionComponent } from "react"; import React from "react"; -import type { Entity } from "@/rdap/schemas"; +import type { Entity, RdapStatusType } from "@/rdap/schemas"; import CopyButton from "@/components/CopyButton"; +import StatusBadge from "@/components/StatusBadge"; import AbstractCard from "@/components/AbstractCard"; +import Property from "@/components/Property"; +import VCardDisplay from "@/rdap/components/VCardDisplay"; +import Events from "@/rdap/components/Events"; +import LinksSection from "@/rdap/components/LinksSection"; +import RemarksSection from "@/rdap/components/RemarksSection"; +import EntitiesSection from "@/rdap/components/EntitiesSection"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; export type EntityCardProps = { @@ -17,7 +24,9 @@ const EntityCard: FunctionComponent = ({ data, url }: EntityCar url={url} header={ - {data.handle || data.roles.join(", ")} + + {data.handle || (data.roles && data.roles.join(", ")) || "Entity"} + ENTITY } @@ -34,35 +43,90 @@ const EntityCard: FunctionComponent = ({ data, url }: EntityCar )} - - Roles - - - {data.roles.map((role, index) => ( - - {role} - - ))} - - - - {data.publicIds && data.publicIds.length > 0 && ( - - Public IDs + {data.roles && data.roles.length > 0 && ( + + Roles - - {data.publicIds.map((publicId, index) => ( - - - {publicId.identifier} ({publicId.type}) - - - + + {data.roles.map((role: string, index: number) => ( + + {role} + ))} )} + {data.status && data.status.length > 0 && ( + + Status + + + {data.status.map((status: RdapStatusType, index: number) => ( + + ))} + + + + )} + {data.publicIds && data.publicIds.length > 0 && ( + + Public IDs + + + {data.publicIds.map( + ( + publicId: { type: string; identifier: string }, + index: number + ) => ( + + + {publicId.identifier} ({publicId.type}) + + + + ) + )} + + + + )} + {data.port43 && ( + + WHOIS Server + + + {data.port43} + + + + + )} + {data.vcardArray && ( + + + + )} + {data.entities && data.entities.length > 0 && ( + + + + )} + {data.events && data.events.length > 0 && ( + + + + )} + {data.links && data.links.length > 0 && ( + + + + )} + {data.remarks && data.remarks.length > 0 && ( + + + + )} ); diff --git a/src/rdap/components/Events.tsx b/src/rdap/components/Events.tsx index c659665..fbc9386 100644 --- a/src/rdap/components/Events.tsx +++ b/src/rdap/components/Events.tsx @@ -32,7 +32,7 @@ const Events: FunctionComponent = ({ data }) => { {eventActor} ) : ( - + )} diff --git a/src/rdap/components/IPCard.tsx b/src/rdap/components/IPCard.tsx index 4d11320..df4ae86 100644 --- a/src/rdap/components/IPCard.tsx +++ b/src/rdap/components/IPCard.tsx @@ -6,6 +6,9 @@ import Property from "@/components/Property"; import CopyButton from "@/components/CopyButton"; import StatusBadge from "@/components/StatusBadge"; import AbstractCard from "@/components/AbstractCard"; +import EntitiesSection from "@/rdap/components/EntitiesSection"; +import LinksSection from "@/rdap/components/LinksSection"; +import RemarksSection from "@/rdap/components/RemarksSection"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type IPCardProps = { @@ -57,7 +60,7 @@ const IPCard: FunctionComponent = ({ data, url }: IPCardProps) => { - {data.type} + {data.type && {data.type}} {data.country && {data.country}} {data.parentHandle && ( @@ -70,19 +73,49 @@ const IPCard: FunctionComponent = ({ data, url }: IPCardProps) => { )} - - - - - Status - - - {data.status.map((status, index) => ( - - ))} - - - + {data.status && data.status.length > 0 && ( + + Status + + + {data.status.map((status, index) => ( + + ))} + + + + )} + {data.port43 && ( + + WHOIS Server + + + {data.port43} + + + + + )} + {data.entities && data.entities.length > 0 && ( + + + + )} + {data.events && data.events.length > 0 && ( + + + + )} + {data.links && data.links.length > 0 && ( + + + + )} + {data.remarks && data.remarks.length > 0 && ( + + + + )} ); diff --git a/src/rdap/components/LinksSection.tsx b/src/rdap/components/LinksSection.tsx new file mode 100644 index 0000000..08dc048 --- /dev/null +++ b/src/rdap/components/LinksSection.tsx @@ -0,0 +1,69 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { Link as RdapLink } from "@/rdap/schemas"; +import { Table, Link, Text, Badge } from "@radix-ui/themes"; + +export type LinksSectionProps = { + links: RdapLink[]; +}; + +const LinksSection: FunctionComponent = ({ links }) => { + if (!links || links.length === 0) return null; + + return ( + + + + URL + Relation + Title + Type + + + + {links.map((link, index) => ( + + + + {link.value || link.href} + + + + {link.rel ? ( + + {link.rel} + + ) : ( + + — + + )} + + + {link.title ? ( + {link.title} + ) : ( + + — + + )} + + + {link.type ? ( + + {link.type} + + ) : ( + + — + + )} + + + ))} + + + ); +}; + +export default LinksSection; diff --git a/src/rdap/components/NameserverCard.tsx b/src/rdap/components/NameserverCard.tsx index d653c63..a8928ea 100644 --- a/src/rdap/components/NameserverCard.tsx +++ b/src/rdap/components/NameserverCard.tsx @@ -2,7 +2,13 @@ import type { FunctionComponent } from "react"; import React from "react"; import type { Nameserver } from "@/rdap/schemas"; import CopyButton from "@/components/CopyButton"; +import StatusBadge from "@/components/StatusBadge"; import AbstractCard from "@/components/AbstractCard"; +import Property from "@/components/Property"; +import Events from "@/rdap/components/Events"; +import LinksSection from "@/rdap/components/LinksSection"; +import RemarksSection from "@/rdap/components/RemarksSection"; +import EntitiesSection from "@/rdap/components/EntitiesSection"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; export type NameserverCardProps = { @@ -26,8 +32,19 @@ const NameserverCard: FunctionComponent = ({ } > + {data.unicodeName && data.unicodeName !== data.ldhName && ( + + Unicode Name + + + {data.unicodeName} + + + + + )} - LDH Name + {data.unicodeName ? "LDH Name" : "Name"} {data.ldhName} @@ -35,6 +52,90 @@ const NameserverCard: FunctionComponent = ({ + {data.handle && ( + + Handle + + + {data.handle} + + + + + )} + {data.ipAddresses?.v4 && data.ipAddresses.v4.length > 0 && ( + + IPv4 Addresses + + + {data.ipAddresses.v4.map((ip, index) => ( + + {ip} + + + ))} + + + + )} + {data.ipAddresses?.v6 && data.ipAddresses.v6.length > 0 && ( + + IPv6 Addresses + + + {data.ipAddresses.v6.map((ip, index) => ( + + {ip} + + + ))} + + + + )} + {data.status && data.status.length > 0 && ( + + Status + + + {data.status.map((status, index) => ( + + ))} + + + + )} + {data.port43 && ( + + WHOIS Server + + + {data.port43} + + + + + )} + {data.entities && data.entities.length > 0 && ( + + + + )} + {data.events && data.events.length > 0 && ( + + + + )} + {data.links && data.links.length > 0 && ( + + + + )} + {data.remarks && data.remarks.length > 0 && ( + + + + )} ); diff --git a/src/rdap/components/NameserversSection.tsx b/src/rdap/components/NameserversSection.tsx new file mode 100644 index 0000000..fbd144a --- /dev/null +++ b/src/rdap/components/NameserversSection.tsx @@ -0,0 +1,85 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { Nameserver } from "@/rdap/schemas"; +import { Table, Text, Code, Flex, Badge } from "@radix-ui/themes"; +import CopyButton from "@/components/CopyButton"; + +export type NameserversSectionProps = { + nameservers: Nameserver[]; +}; + +const NameserversSection: FunctionComponent = ({ nameservers }) => { + if (!nameservers || nameservers.length === 0) return null; + + return ( + + + + Nameserver + IPv4 Addresses + IPv6 Addresses + + + + {nameservers.map((ns, index) => ( + + + + {ns.ldhName} + + + {ns.unicodeName && ns.unicodeName !== ns.ldhName && ( + + + Unicode + + + {ns.unicodeName} + + + )} + + + {ns.ipAddresses?.v4 && ns.ipAddresses.v4.length > 0 ? ( + + {ns.ipAddresses.v4.map((ip, ipIndex) => ( + + + {ip} + + + + ))} + + ) : ( + + — + + )} + + + {ns.ipAddresses?.v6 && ns.ipAddresses.v6.length > 0 ? ( + + {ns.ipAddresses.v6.map((ip, ipIndex) => ( + + + {ip} + + + + ))} + + ) : ( + + — + + )} + + + ))} + + + ); +}; + +export default NameserversSection; diff --git a/src/rdap/components/RemarksSection.tsx b/src/rdap/components/RemarksSection.tsx new file mode 100644 index 0000000..42b55d8 --- /dev/null +++ b/src/rdap/components/RemarksSection.tsx @@ -0,0 +1,61 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { Remark, Notice } from "@/rdap/schemas"; +import { Box, Text, Heading, Flex, Badge } from "@radix-ui/themes"; +import LinksSection from "@/rdap/components/LinksSection"; + +export type RemarksSectionProps = { + remarks?: Remark[] | Notice[]; +}; + +const RemarksSection: FunctionComponent = ({ remarks }) => { + if (!remarks || remarks.length === 0) return null; + + return ( + + {remarks.map((remark, index) => ( + + + {remark.title && ( + + {remark.title} + {remark.type && ( + + {remark.type} + + )} + + )} + {remark.description && remark.description.length > 0 && ( + + {remark.description.map((desc, descIndex) => ( + + {desc} + + ))} + + )} + {remark.links && remark.links.length > 0 && ( + + + Related Links + + + + )} + + + ))} + + ); +}; + +export default RemarksSection; diff --git a/src/rdap/components/SecureDNSSection.tsx b/src/rdap/components/SecureDNSSection.tsx new file mode 100644 index 0000000..95ebf94 --- /dev/null +++ b/src/rdap/components/SecureDNSSection.tsx @@ -0,0 +1,152 @@ +import CopyButton from "@/components/CopyButton"; +import type { SecureDNS } from "@/rdap/schemas"; +import { Badge, Box, Code, DataList, Flex, Table, Text } from "@radix-ui/themes"; +import type { FunctionComponent } from "react"; + +export type SecureDNSSectionProps = { + secureDNS: SecureDNS; +}; + +const SecureDNSSection: FunctionComponent = ({ secureDNS }) => { + const hasData = + secureDNS.zoneSigned !== undefined || + secureDNS.delegationSigned !== undefined || + secureDNS.maxSigLife !== undefined || + (secureDNS.dsData && secureDNS.dsData.length > 0) || + (secureDNS.keyData && secureDNS.keyData.length > 0); + + if (!hasData) return null; + + return ( + + + {secureDNS.zoneSigned !== undefined && ( + + Zone Signed + + + {secureDNS.zoneSigned ? "Yes" : "No"} + + + + )} + + {secureDNS.delegationSigned !== undefined && ( + + Delegation Signed + + + {secureDNS.delegationSigned ? "Yes" : "No"} + + + + )} + + {secureDNS.maxSigLife !== undefined && ( + + Max Signature Life + + {secureDNS.maxSigLife} seconds + + + )} + + + {secureDNS.dsData && secureDNS.dsData.length > 0 && ( + + + + Key Tag + Algorithm + Digest Type + Digest + + + + {secureDNS.dsData.map((ds, index) => ( + + + {ds.keyTag} + + + {ds.algorithm} + + + {ds.digestType} + + + + + {ds.digest} + + + + + + ))} + + + )} + + {secureDNS.keyData && secureDNS.keyData.length > 0 && ( + + + Key Data + + + + + Flags + Protocol + Algorithm + Public Key + + + + {secureDNS.keyData.map((key, index) => ( + + + {key.flags} + + + {key.protocol} + + + {key.algorithm} + + + + + {key.publicKey} + + + + + + ))} + + + + )} + + ); +}; + +export default SecureDNSSection; diff --git a/src/rdap/components/VCardDisplay.tsx b/src/rdap/components/VCardDisplay.tsx new file mode 100644 index 0000000..78368c6 --- /dev/null +++ b/src/rdap/components/VCardDisplay.tsx @@ -0,0 +1,239 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { VCardArray } from "@/rdap/schemas"; +import { Table, Flex, Code, Link, Text } from "@radix-ui/themes"; +import CopyButton from "@/components/CopyButton"; + +export type VCardDisplayProps = { + vcardArray: VCardArray; +}; + +type VCardProperty = [string, Record, string, string]; + +type StructuredName = { + family: string; + given: string; + additional: string; + prefix: string; + suffix: string; +}; + +type Address = { + street: string; + locality: string; + region: string; + postal: string; + country: string; +}; + +type ParsedVCard = { + fn?: string; + name?: StructuredName; + org?: string; + emails?: string[]; + phones?: string[]; + addresses?: Address[]; + urls?: string[]; + title?: string; + role?: string; +}; + +/** + * Parses a vCard (jCard) array and extracts common contact properties. + * jCard format: ["vcard", [[prop_name, params, value_type, value], ...]] + */ +const parseVCard = (vcardArray: VCardArray): ParsedVCard => { + const [, properties] = vcardArray; + const result: ParsedVCard = {}; + + properties.forEach((prop: VCardProperty) => { + if (!Array.isArray(prop) || prop.length < 4) return; + + const [name, , , value] = prop as VCardProperty; + const nameLower = name.toLowerCase(); + + switch (nameLower) { + case "fn": // Formatted name + result.fn = value; + break; + case "n": // Structured name [family, given, additional, prefix, suffix] + if (Array.isArray(value)) { + result.name = { + family: value[0], + given: value[1], + additional: value[2], + prefix: value[3], + suffix: value[4], + }; + } + break; + case "org": // Organization + result.org = Array.isArray(value) ? value.join(" > ") : value; + break; + case "email": + if (!result.emails) result.emails = []; + result.emails.push(value); + break; + case "tel": // Telephone + if (!result.phones) result.phones = []; + result.phones.push(value); + break; + case "adr": // Address [PO, extended, street, locality, region, postal, country] + if (Array.isArray(value)) { + const address = { + street: value[2], + locality: value[3], + region: value[4], + postal: value[5], + country: value[6], + }; + if (!result.addresses) result.addresses = []; + result.addresses.push(address); + } + break; + case "url": + if (!result.urls) result.urls = []; + result.urls.push(value); + break; + case "title": + result.title = value; + break; + case "role": + result.role = value; + break; + } + }); + + return result; +}; + +const VCardDisplay: FunctionComponent = ({ vcardArray }) => { + const vcard = parseVCard(vcardArray); + + return ( + + + {vcard.fn && ( + + Name + + + {vcard.fn} + + + + + )} + + {vcard.org && ( + + Organization + + + {vcard.org} + + + + + )} + + {vcard.title && ( + + Title + + {vcard.title} + + + )} + + {vcard.role && ( + + Role + + {vcard.role} + + + )} + + {vcard.emails && vcard.emails.length > 0 && ( + + Email + + + {vcard.emails.map((email: string, index: number) => ( + + + {email} + + + + ))} + + + + )} + + {vcard.phones && vcard.phones.length > 0 && ( + + Phone + + + {vcard.phones.map((phone: string, index: number) => ( + + + {phone} + + + + ))} + + + + )} + + {vcard.addresses && vcard.addresses.length > 0 && ( + + Address + + + {vcard.addresses.map((addr: Address, index: number) => ( + + {[ + addr.street, + addr.locality, + addr.region, + addr.postal, + addr.country, + ] + .filter(Boolean) + .join(", ")} + + ))} + + + + )} + + {vcard.urls && vcard.urls.length > 0 && ( + + URL + + + {vcard.urls.map((url: string, index: number) => ( + + + {url} + + + + ))} + + + + )} + + + ); +}; + +export default VCardDisplay; diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx index b0551c7..57f7204 100644 --- a/src/rdap/hooks/useLookup.tsx +++ b/src/rdap/hooks/useLookup.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { getType } from "@/rdap/utils"; +import { getType, validateInputForType } from "@/rdap/utils"; import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas"; import { AutonomousNumberSchema, @@ -75,35 +75,56 @@ const useLookup = (warningHandler?: WarningHandler) => { 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); + let targetType: TargetType; - if (targetType.isErr) { - return Result.err( - new Error("Unable to determine type, unable to send query", { - cause: targetType.error, - }) - ); + if (currentType != null) { + // User has explicitly selected a type + targetType = currentType; + + // Validate the input matches the selected type + const validation = await validateInputForType(target, currentType, getRegistry); + if (validation.isErr) { + // Show warning but proceed with user's selection + if (warningHandler != undefined) { + warningHandler({ + message: `Warning: ${validation.error}. Proceeding with selected type "${currentType}".`, + }); + } + } + } else { + // Autodetect mode + const detectedType = await getTypeEasy(target); + + if (detectedType.isErr) { + return Result.err( + new Error("Unable to determine type, unable to send query", { + cause: detectedType.error, + }) + ); + } + + targetType = detectedType.value; } - switch (targetType.value) { + switch (targetType) { // Block scoped case to allow url const reuse case "ip4": { await loadBootstrap("ip4"); - const url = getRegistryURL(targetType.value, target); + const url = getRegistryURL(targetType, 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 url = getRegistryURL(targetType, 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); + const url = getRegistryURL(targetType, target); // HTTP if (url.startsWith("http://") && url != repeatableRef.current) { @@ -123,7 +144,7 @@ const useLookup = (warningHandler?: WarningHandler) => { } case "autnum": { await loadBootstrap("autnum"); - const url = getRegistryURL(targetType.value, target); + const url = getRegistryURL(targetType, target); const result = await getAndParse(url, AutonomousNumberSchema); if (result.isErr) return Result.err(result.error); return Result.ok({ data: result.value, url }); diff --git a/src/rdap/schemas.ts b/src/rdap/schemas.ts index e471f07..352a2dd 100644 --- a/src/rdap/schemas.ts +++ b/src/rdap/schemas.ts @@ -1,9 +1,5 @@ import { z } from "zod"; -// ============================================================================ -// Enums -// ============================================================================ - export const TargetTypeEnum = z.enum([ "autnum", "domain", @@ -55,10 +51,6 @@ 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 @@ -69,25 +61,6 @@ export const LinkSchema = z.object({ type: z.string().optional(), }); -export const EntitySchema = z.object({ - objectClassName: z.literal("entity"), - handle: z.string().optional(), - roles: z.array(z.string()), - publicIds: z - .array( - z.object({ - type: z.string(), - identifier: z.string(), - }) - ) - .optional(), -}); - -export const NameserverSchema = z.object({ - objectClassName: z.literal("nameserver"), - ldhName: z.string(), -}); - export const EventSchema = z.object({ eventAction: z.string(), eventActor: z.string().optional(), @@ -97,25 +70,116 @@ export const EventSchema = z.object({ export const NoticeSchema = z.object({ description: z.string().array(), // de jure required title: z.string().optional(), + type: z.string().optional(), links: z.array(LinkSchema).optional(), }); +export const RemarkSchema = z.object({ + description: z.string().array(), // de jure required + title: z.string().optional(), + type: z.string().optional(), + links: z.array(LinkSchema).optional(), +}); + +// vCard 4.0 in jCard format (RFC 7095) +// Simplified schema - full vCard is complex, so we use a loose schema +// Format: ["vcard", [properties...]] +export const VCardArraySchema = z.array(z.any()); + +export const IPAddressesSchema = z.object({ + v4: z.array(z.string()).optional(), + v6: z.array(z.string()).optional(), +}); + +export const DSDataSchema = z.object({ + keyTag: z.number(), + algorithm: z.number(), + digest: z.string(), + digestType: z.number(), +}); + +export const KeyDataSchema = z.object({ + flags: z.number(), + protocol: z.number(), + publicKey: z.string(), + algorithm: z.number(), +}); + +export const SecureDNSSchema = z.object({ + zoneSigned: z.boolean().optional(), + delegationSigned: z.boolean().optional(), + maxSigLife: z.number().optional(), + dsData: z.array(DSDataSchema).optional(), + keyData: z.array(KeyDataSchema).optional(), +}); + +export const VariantSchema = z.object({ + relation: z.array(z.string()).optional(), + idnTable: z.string().optional(), + variantNames: z + .array( + z.object({ + ldhName: z.string(), + unicodeName: z.string().optional(), + }) + ) + .optional(), +}); + +export const NameserverSchema = z.object({ + objectClassName: z.literal("nameserver"), + ldhName: z.string(), + unicodeName: z.string().optional(), + handle: z.string().optional(), + ipAddresses: IPAddressesSchema.optional(), + status: z.array(StatusEnum).optional(), + events: z.array(EventSchema).optional(), + links: z.array(LinkSchema).optional(), + remarks: z.array(RemarkSchema).optional(), + port43: z.string().optional(), + entities: z.lazy(() => z.array(EntitySchema)).optional(), +}); + export const IpNetworkSchema = z.object({ objectClassName: z.literal("ip network"), handle: z.string(), startAddress: z.string(), endAddress: z.string(), ipVersion: z.enum(["v4", "v6"]), - name: z.string(), - type: z.string(), + name: z.string().optional(), + type: z.string().optional(), country: z.string().optional(), parentHandle: z.string().optional(), - status: z.array(StatusEnum), - entities: z.array(EntitySchema).optional(), - remarks: z.any().optional(), - links: z.any().optional(), - port43: z.any().optional(), - events: z.array(EventSchema), + status: z.array(StatusEnum).optional(), + remarks: z.array(RemarkSchema).optional(), + links: z.array(LinkSchema).optional(), + port43: z.string().optional(), + events: z.array(EventSchema).optional(), + // Required for circular reference + get entities() { + return z.array(EntitySchema).optional(); + }, +}); + +// Forward declaration for circular Entity reference +const BaseEntitySchema = z.object({ + objectClassName: z.literal("entity"), + handle: z.string().optional(), + vcardArray: VCardArraySchema.optional(), + roles: z.array(z.string()).optional(), + publicIds: z + .array( + z.object({ + type: z.string(), + identifier: z.string(), + }) + ) + .optional(), + status: z.array(StatusEnum).optional(), + events: z.array(EventSchema).optional(), + links: z.array(LinkSchema).optional(), + remarks: z.array(RemarkSchema).optional(), + port43: z.string().optional(), }); export const AutonomousNumberSchema = z.object({ @@ -128,24 +192,40 @@ export const AutonomousNumberSchema = z.object({ status: z.array(StatusEnum), country: z.string().length(2), events: z.array(EventSchema), - entities: z.array(EntitySchema), - roles: z.array(z.string()), - links: z.array(LinkSchema), + get entities() { + return z.array(EntitySchema).optional(); + }, + links: z.array(LinkSchema).optional(), + remarks: z.array(RemarkSchema).optional(), + port43: z.string().optional(), +}); + +// Full Entity schema with circular references +export const EntitySchema = BaseEntitySchema.extend({ + networks: z.lazy(() => z.array(IpNetworkSchema)).optional(), + autnums: z.lazy(() => z.array(AutonomousNumberSchema)).optional(), + asEventActor: z.array(EventSchema).optional(), + get entities() { + return z.array(EntitySchema).optional(); + }, }); export const DomainSchema = z.object({ objectClassName: z.literal("domain"), - handle: z.string(), + handle: z.string().optional(), ldhName: z.string(), unicodeName: z.string().optional(), + variants: z.array(VariantSchema).optional(), links: z.array(LinkSchema).optional(), - status: z.array(StatusEnum), - entities: z.array(EntitySchema), - events: z.array(EventSchema), - secureDNS: z.any(), // TODO: Complete schema - nameservers: z.array(NameserverSchema), - rdapConformance: z.string().array(), // TODO: Complete - notices: z.array(NoticeSchema), + status: z.array(StatusEnum).optional(), + entities: z.lazy(() => z.array(EntitySchema)).optional(), + events: z.array(EventSchema).optional(), + secureDNS: SecureDNSSchema.optional(), + nameservers: z.array(NameserverSchema).optional(), + rdapConformance: z.string().array().optional(), + notices: z.array(NoticeSchema).optional(), + remarks: z.array(RemarkSchema).optional(), + port43: z.string().optional(), network: IpNetworkSchema.optional(), }); @@ -166,10 +246,6 @@ export const RegisterSchema = z.object({ version: z.string(), }); -// ============================================================================ -// TypeScript Types -// ============================================================================ - // All precise target types that can be placed in the search bar. export type TargetType = z.infer; @@ -185,6 +261,13 @@ export type Entity = z.infer; export type Nameserver = z.infer; export type Event = z.infer; export type Notice = z.infer; +export type Remark = z.infer; +export type VCardArray = z.infer; +export type IPAddresses = z.infer; +export type DSData = z.infer; +export type KeyData = z.infer; +export type SecureDNS = z.infer; +export type Variant = z.infer; export type IpNetwork = z.infer; export type AutonomousNumber = z.infer; export type Register = z.infer; diff --git a/src/rdap/utils.ts b/src/rdap/utils.ts index 39fa13a..92276ce 100644 --- a/src/rdap/utils.ts +++ b/src/rdap/utils.ts @@ -176,3 +176,34 @@ export async function getType( return Result.err(new Error("No patterns matched the input")); } + +/** + * Validates if a given input matches a specific target type. + * Used to warn users when their explicit type selection doesn't match the input format. + * + * @param value - The input value to validate + * @param type - The expected target type + * @param getRegistry - Function to fetch registry data + * @returns A Result containing true if valid, or an error message if invalid + */ +export async function validateInputForType( + value: string, + type: TargetType, + getRegistry: (type: RootRegistryType) => Promise +): Promise> { + const validator = TypeValidators.get(type); + if (!validator) { + return Result.err(`Unknown type: ${type}`); + } + + const result = await validator({ value, getRegistry }); + + if (result === true) { + return Result.ok(true); + } else if (result === false) { + return Result.err(`Input "${value}" does not match the format for type "${type}"`); + } else { + // result is an error message string + return Result.err(result); + } +}