mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 05:16:08 -06:00
feat: implement JSContact and follow referral features
Add comprehensive support for JSContact (RFC 9553) format as an alternative to vCard in RDAP responses, along with HTTP redirect handling. Features: - Add query parameter support for jsContact and followReferral options - Implement complete JSContact Zod schemas and TypeScript types - Create JSContactDisplay component with full contact data rendering - Add ContactDisplay wrapper for automatic format detection - Wire up form values through lookup hook to RDAP requests - Implement HTTP redirect handling with manual/follow modes - Update EntityCard and EntitiesSection to support both formats
This commit is contained in:
30
src/rdap/components/ContactDisplay.tsx
Normal file
30
src/rdap/components/ContactDisplay.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { Entity } from "@/rdap/schemas";
|
||||||
|
import VCardDisplay from "./VCardDisplay";
|
||||||
|
import JSContactDisplay from "./JSContactDisplay";
|
||||||
|
|
||||||
|
export type ContactDisplayProps = {
|
||||||
|
entity: Entity;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper component that auto-detects contact format and displays appropriately.
|
||||||
|
* Supports both vCard (jCard) and JSContact formats in RDAP responses.
|
||||||
|
*/
|
||||||
|
const ContactDisplay: FunctionComponent<ContactDisplayProps> = ({ entity }) => {
|
||||||
|
// Check for JSContact format first (preferred modern format)
|
||||||
|
if (entity.jscard) {
|
||||||
|
return <JSContactDisplay jscard={entity.jscard} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to vCard format if available
|
||||||
|
if (entity.vcardArray) {
|
||||||
|
return <VCardDisplay vcardArray={entity.vcardArray} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No contact information available
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactDisplay;
|
||||||
@@ -2,7 +2,7 @@ import type { FunctionComponent } from "react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity, RdapStatusType } from "@/rdap/schemas";
|
import type { Entity, RdapStatusType } from "@/rdap/schemas";
|
||||||
import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes";
|
import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes";
|
||||||
import VCardDisplay from "@/rdap/components/VCardDisplay";
|
import ContactDisplay from "@/rdap/components/ContactDisplay";
|
||||||
import CopyButton from "@/components/CopyButton";
|
import CopyButton from "@/components/CopyButton";
|
||||||
import StatusBadge from "@/components/StatusBadge";
|
import StatusBadge from "@/components/StatusBadge";
|
||||||
|
|
||||||
@@ -84,9 +84,9 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
|
|||||||
)}
|
)}
|
||||||
</DataList.Root>
|
</DataList.Root>
|
||||||
|
|
||||||
{entity.vcardArray && (
|
{(entity.vcardArray || entity.jscard) && (
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
<VCardDisplay vcardArray={entity.vcardArray} />
|
<ContactDisplay entity={entity} />
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CopyButton from "@/components/CopyButton";
|
|||||||
import StatusBadge from "@/components/StatusBadge";
|
import StatusBadge from "@/components/StatusBadge";
|
||||||
import AbstractCard from "@/components/AbstractCard";
|
import AbstractCard from "@/components/AbstractCard";
|
||||||
import Property from "@/components/Property";
|
import Property from "@/components/Property";
|
||||||
import VCardDisplay from "@/rdap/components/VCardDisplay";
|
import ContactDisplay from "@/rdap/components/ContactDisplay";
|
||||||
import Events from "@/rdap/components/Events";
|
import Events from "@/rdap/components/Events";
|
||||||
import LinksSection from "@/rdap/components/LinksSection";
|
import LinksSection from "@/rdap/components/LinksSection";
|
||||||
import RemarksSection from "@/rdap/components/RemarksSection";
|
import RemarksSection from "@/rdap/components/RemarksSection";
|
||||||
@@ -102,9 +102,9 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
|
|||||||
</DataList.Value>
|
</DataList.Value>
|
||||||
</DataList.Item>
|
</DataList.Item>
|
||||||
)}
|
)}
|
||||||
{data.vcardArray && (
|
{(data.vcardArray || data.jscard) && (
|
||||||
<Property title="Contact Information">
|
<Property title="Contact Information">
|
||||||
<VCardDisplay vcardArray={data.vcardArray} />
|
<ContactDisplay entity={data} />
|
||||||
</Property>
|
</Property>
|
||||||
)}
|
)}
|
||||||
{data.entities && data.entities.length > 0 && (
|
{data.entities && data.entities.length > 0 && (
|
||||||
|
|||||||
337
src/rdap/components/JSContactDisplay.tsx
Normal file
337
src/rdap/components/JSContactDisplay.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { JSCard } from "@/rdap/schemas";
|
||||||
|
import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes";
|
||||||
|
import CopyButton from "@/components/CopyButton";
|
||||||
|
|
||||||
|
export type JSContactDisplayProps = {
|
||||||
|
jscard: JSCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display component for JSContact (RFC 9553) contact cards in RDAP responses.
|
||||||
|
* JSContact is a JSON-native alternative to vCard/jCard.
|
||||||
|
*/
|
||||||
|
const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => {
|
||||||
|
// Extract full name or build from components
|
||||||
|
const displayName = jscard.name?.full || "";
|
||||||
|
|
||||||
|
// Extract organization names
|
||||||
|
const organizations = jscard.organizations
|
||||||
|
? Object.values(jscard.organizations)
|
||||||
|
.map((org) => {
|
||||||
|
if (org.name && org.units) {
|
||||||
|
return `${org.name} > ${org.units.map((u: { name: string }) => u.name).join(" > ")}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
org.name ||
|
||||||
|
org.units?.map((u: { name: string }) => u.name).join(" > ") ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract titles/roles - cast to proper type
|
||||||
|
const titles = jscard.titles
|
||||||
|
? (Object.values(jscard.titles) as Array<{
|
||||||
|
name: string;
|
||||||
|
kind?: "title" | "role";
|
||||||
|
organizationId?: string;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract emails - cast to proper type
|
||||||
|
const emails = jscard.emails
|
||||||
|
? (Object.entries(jscard.emails) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
address: string;
|
||||||
|
contexts?: Record<string, boolean>;
|
||||||
|
pref?: number;
|
||||||
|
label?: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract phones - cast to proper type
|
||||||
|
const phones = jscard.phones
|
||||||
|
? (Object.entries(jscard.phones) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
number: string;
|
||||||
|
features?: Record<string, boolean>;
|
||||||
|
contexts?: Record<string, boolean>;
|
||||||
|
pref?: number;
|
||||||
|
label?: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract addresses - cast to proper type
|
||||||
|
const addresses = jscard.addresses
|
||||||
|
? (Object.entries(jscard.addresses) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
components?: Array<{ kind?: string; value: string }>;
|
||||||
|
full?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
coordinates?: string;
|
||||||
|
timeZone?: string;
|
||||||
|
contexts?: Record<string, boolean>;
|
||||||
|
pref?: number;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract online services - cast to proper type
|
||||||
|
const onlineServices = jscard.onlineServices
|
||||||
|
? (Object.entries(jscard.onlineServices) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
service?: string;
|
||||||
|
uri?: string;
|
||||||
|
user?: string;
|
||||||
|
contexts?: Record<string, boolean>;
|
||||||
|
pref?: number;
|
||||||
|
label?: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Extract links - cast to proper type
|
||||||
|
const links = jscard.links
|
||||||
|
? (Object.entries(jscard.links) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
uri: string;
|
||||||
|
contexts?: Record<string, boolean>;
|
||||||
|
pref?: number;
|
||||||
|
label?: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Root size="2" variant="surface">
|
||||||
|
<Table.Body>
|
||||||
|
{displayName && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex gap="2" align="center">
|
||||||
|
<Text>{displayName}</Text>
|
||||||
|
<CopyButton value={displayName} />
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jscard.kind && jscard.kind !== "individual" && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="soft">{jscard.kind}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organizations.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{organizations.map((org, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Code variant="ghost">{org}</Code>
|
||||||
|
<CopyButton value={org} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{titles.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{titles.map((title, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Text>{title.name}</Text>
|
||||||
|
{title.kind && title.kind !== "title" && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{title.kind}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emails.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{emails.map(([key, email]) => (
|
||||||
|
<Flex key={key} align="center" gap="2">
|
||||||
|
<Link href={`mailto:${email.address}`}>
|
||||||
|
<Code variant="ghost">{email.address}</Code>
|
||||||
|
</Link>
|
||||||
|
{email.label && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{email.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{email.contexts &&
|
||||||
|
Object.keys(email.contexts).length > 0 && (
|
||||||
|
<Badge size="1" variant="outline">
|
||||||
|
{Object.keys(email.contexts).join(", ")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<CopyButton value={email.address} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phones.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{phones.map(([key, phone]) => (
|
||||||
|
<Flex key={key} align="center" gap="2">
|
||||||
|
<Link href={`tel:${phone.number}`}>
|
||||||
|
<Code variant="ghost">{phone.number}</Code>
|
||||||
|
</Link>
|
||||||
|
{phone.features &&
|
||||||
|
Object.keys(phone.features).length > 0 && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{Object.keys(phone.features).join(", ")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{phone.label && (
|
||||||
|
<Badge size="1" variant="outline">
|
||||||
|
{phone.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<CopyButton value={phone.number} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
{addresses.map(([key, addr]) => {
|
||||||
|
// Use full address if available, otherwise build from components
|
||||||
|
const addressText =
|
||||||
|
addr.full ||
|
||||||
|
(addr.components
|
||||||
|
? addr.components.map((c) => c.value).join(", ")
|
||||||
|
: "");
|
||||||
|
const details = [addr.countryCode, addr.timeZone].filter(
|
||||||
|
Boolean
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex key={key} direction="column" gap="1">
|
||||||
|
<Text size="2">{addressText}</Text>
|
||||||
|
{details.length > 0 && (
|
||||||
|
<Flex gap="1">
|
||||||
|
{details.map((detail, idx) => (
|
||||||
|
<Badge key={idx} size="1" variant="soft">
|
||||||
|
{detail}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onlineServices.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{onlineServices.map(([key, service]) => (
|
||||||
|
<Flex key={key} align="center" gap="2">
|
||||||
|
{service.uri && (
|
||||||
|
<Link
|
||||||
|
href={service.uri}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Code variant="ghost">
|
||||||
|
{service.service || service.user || service.uri}
|
||||||
|
</Code>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!service.uri && (
|
||||||
|
<Code variant="ghost">
|
||||||
|
{service.service || service.user || key}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
{service.service && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{service.service}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{service.uri && <CopyButton value={service.uri} />}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{links.map(([key, link]) => (
|
||||||
|
<Flex key={key} align="center" gap="2">
|
||||||
|
<Link href={link.uri} target="_blank" rel="noreferrer">
|
||||||
|
<Code variant="ghost">{link.label || link.uri}</Code>
|
||||||
|
</Link>
|
||||||
|
<CopyButton value={link.uri} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JSContactDisplay;
|
||||||
@@ -160,15 +160,15 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
|||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
isFocusedRef.current = true;
|
isFocusedRef.current = true;
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
{...register("target", {
|
||||||
|
required: true,
|
||||||
|
onBlur: () => {
|
||||||
// Don't clear focus state if we're loading (input is being disabled)
|
// Don't clear focus state if we're loading (input is being disabled)
|
||||||
// so we can restore focus when loading completes
|
// so we can restore focus when loading completes
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
{...register("target", {
|
|
||||||
required: true,
|
|
||||||
onChange: () => {
|
onChange: () => {
|
||||||
const targetValue = getValues("target");
|
const targetValue = getValues("target");
|
||||||
const oldIsEmpty = inputValue.trim() === "";
|
const oldIsEmpty = inputValue.trim() === "";
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
|||||||
}, [uriType, warningHandler]);
|
}, [uriType, warningHandler]);
|
||||||
|
|
||||||
async function submitInternal(
|
async function submitInternal(
|
||||||
target: string
|
target: string,
|
||||||
|
requestJSContact: boolean,
|
||||||
|
followReferral: boolean
|
||||||
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
||||||
if (target == null || target.length == 0)
|
if (target == null || target.length == 0)
|
||||||
return Result.err(new Error("A target must be given in order to execute a lookup."));
|
return Result.err(new Error("A target must be given in order to execute a lookup."));
|
||||||
@@ -115,25 +117,28 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
|||||||
targetType = detectedType.value;
|
targetType = detectedType.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare query parameters for RDAP requests
|
||||||
|
const queryParams = { jsContact: requestJSContact, followReferral };
|
||||||
|
|
||||||
switch (targetType) {
|
switch (targetType) {
|
||||||
// Block scoped case to allow url const reuse
|
// Block scoped case to allow url const reuse
|
||||||
case "ip4": {
|
case "ip4": {
|
||||||
await loadBootstrap("ip4");
|
await loadBootstrap("ip4");
|
||||||
const url = getRegistryURL(targetType, target);
|
const url = getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
||||||
if (result.isErr) return Result.err(result.error);
|
if (result.isErr) return Result.err(result.error);
|
||||||
return Result.ok({ data: result.value, url });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "ip6": {
|
case "ip6": {
|
||||||
await loadBootstrap("ip6");
|
await loadBootstrap("ip6");
|
||||||
const url = getRegistryURL(targetType, target);
|
const url = getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
||||||
if (result.isErr) return Result.err(result.error);
|
if (result.isErr) return Result.err(result.error);
|
||||||
return Result.ok({ data: result.value, url });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "domain": {
|
case "domain": {
|
||||||
await loadBootstrap("domain");
|
await loadBootstrap("domain");
|
||||||
const url = getRegistryURL(targetType, target);
|
const url = getRegistryURL(targetType, target, queryParams);
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
if (url.startsWith("http://") && url != repeatableRef.current) {
|
if (url.startsWith("http://") && url != repeatableRef.current) {
|
||||||
@@ -146,23 +151,32 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema);
|
const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
|
||||||
if (result.isErr) return Result.err(result.error);
|
if (result.isErr) return Result.err(result.error);
|
||||||
|
|
||||||
return Result.ok({ data: result.value, url });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "autnum": {
|
case "autnum": {
|
||||||
await loadBootstrap("autnum");
|
await loadBootstrap("autnum");
|
||||||
const url = getRegistryURL(targetType, target);
|
const url = getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
|
const result = await getAndParse<AutonomousNumber>(
|
||||||
|
url,
|
||||||
|
AutonomousNumberSchema,
|
||||||
|
followReferral
|
||||||
|
);
|
||||||
if (result.isErr) return Result.err(result.error);
|
if (result.isErr) return Result.err(result.error);
|
||||||
return Result.ok({ data: result.value, url });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "tld": {
|
case "tld": {
|
||||||
// remove the leading dot
|
// remove the leading dot
|
||||||
const value = target.startsWith(".") ? target.slice(1) : target;
|
const value = target.startsWith(".") ? target.slice(1) : target;
|
||||||
const url = `https://root.rdap.org/domain/${value}`;
|
const params = new URLSearchParams();
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema);
|
if (requestJSContact) params.append("jsContact", "1");
|
||||||
|
if (followReferral) params.append("followReferral", "1");
|
||||||
|
const queryString = params.toString();
|
||||||
|
const baseUrl = `https://root.rdap.org/domain/${value}`;
|
||||||
|
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
|
const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
|
||||||
if (result.isErr) return Result.err(result.error);
|
if (result.isErr) return Result.err(result.error);
|
||||||
return Result.ok({ data: result.value, url });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
@@ -204,10 +218,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit({ target }: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
|
async function submit({
|
||||||
|
target,
|
||||||
|
requestJSContact,
|
||||||
|
followReferral,
|
||||||
|
}: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
|
||||||
try {
|
try {
|
||||||
// target is already set in state, but it's also provided by the form callback, so we'll use it.
|
// 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);
|
const response = await submitInternal(target, requestJSContact, followReferral);
|
||||||
|
|
||||||
if (response.isErr) {
|
if (response.isErr) {
|
||||||
setError(response.error.message);
|
setError(response.error.message);
|
||||||
|
|||||||
@@ -86,6 +86,116 @@ export const RemarkSchema = z.object({
|
|||||||
// Format: ["vcard", [properties...]]
|
// Format: ["vcard", [properties...]]
|
||||||
export const VCardArraySchema = z.array(z.any());
|
export const VCardArraySchema = z.array(z.any());
|
||||||
|
|
||||||
|
// JSContact (RFC 9553) - JSON representation of contact data
|
||||||
|
// More structured than vCard, used as alternative in RDAP responses
|
||||||
|
|
||||||
|
export const JSContactNameComponentSchema = z.object({
|
||||||
|
kind: z
|
||||||
|
.enum([
|
||||||
|
"title",
|
||||||
|
"given",
|
||||||
|
"given2",
|
||||||
|
"surname",
|
||||||
|
"surname2",
|
||||||
|
"credential",
|
||||||
|
"generation",
|
||||||
|
"separator",
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactNameSchema = z.object({
|
||||||
|
full: z.string().optional(),
|
||||||
|
components: z.array(JSContactNameComponentSchema).optional(),
|
||||||
|
isOrdered: z.boolean().optional(),
|
||||||
|
sortAs: z.record(z.string(), z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactEmailSchema = z.object({
|
||||||
|
address: z.string(), // addr-spec format per RFC5322
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
pref: z.number().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactPhoneSchema = z.object({
|
||||||
|
number: z.string(), // URI or free text
|
||||||
|
features: z.record(z.string(), z.boolean()).optional(), // mobile, voice, text, video, etc.
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
pref: z.number().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactAddressComponentSchema = z.object({
|
||||||
|
kind: z.string().optional(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactAddressSchema = z.object({
|
||||||
|
components: z.array(JSContactAddressComponentSchema).optional(),
|
||||||
|
full: z.string().optional(),
|
||||||
|
countryCode: z.string().optional(),
|
||||||
|
coordinates: z.string().optional(),
|
||||||
|
timeZone: z.string().optional(),
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
pref: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactOrganizationSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
units: z.array(z.object({ name: z.string() })).optional(),
|
||||||
|
sortAs: z.record(z.string(), z.string()).optional(),
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactTitleSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
kind: z.enum(["title", "role"]).optional(),
|
||||||
|
organizationId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactOnlineServiceSchema = z.object({
|
||||||
|
service: z.string().optional(),
|
||||||
|
uri: z.string().optional(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
pref: z.number().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const JSContactLinkSchema = z.object({
|
||||||
|
uri: z.string(),
|
||||||
|
contexts: z.record(z.string(), z.boolean()).optional(),
|
||||||
|
pref: z.number().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main JSCard object (RFC 9553)
|
||||||
|
export const JSCardSchema = z
|
||||||
|
.object({
|
||||||
|
"@type": z.literal("Card"),
|
||||||
|
version: z.string(), // Should be "1.0"
|
||||||
|
uid: z.string(), // Unique identifier
|
||||||
|
created: z.string().optional(), // UTCDateTime
|
||||||
|
updated: z.string().optional(), // UTCDateTime
|
||||||
|
kind: z
|
||||||
|
.enum(["individual", "group", "org", "location", "device", "application"])
|
||||||
|
.optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
name: JSContactNameSchema.optional(),
|
||||||
|
organizations: z.record(z.string(), JSContactOrganizationSchema).optional(),
|
||||||
|
titles: z.record(z.string(), JSContactTitleSchema).optional(),
|
||||||
|
emails: z.record(z.string(), JSContactEmailSchema).optional(),
|
||||||
|
phones: z.record(z.string(), JSContactPhoneSchema).optional(),
|
||||||
|
onlineServices: z.record(z.string(), JSContactOnlineServiceSchema).optional(),
|
||||||
|
addresses: z.record(z.string(), JSContactAddressSchema).optional(),
|
||||||
|
links: z.record(z.string(), JSContactLinkSchema).optional(),
|
||||||
|
// Allow additional properties for extensibility
|
||||||
|
// JSContact spec allows vendor-specific extensions
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const IPAddressesSchema = z.object({
|
export const IPAddressesSchema = z.object({
|
||||||
v4: z.array(z.string()).optional(),
|
v4: z.array(z.string()).optional(),
|
||||||
v6: z.array(z.string()).optional(),
|
v6: z.array(z.string()).optional(),
|
||||||
@@ -166,6 +276,7 @@ const BaseEntitySchema = z.object({
|
|||||||
objectClassName: z.literal("entity"),
|
objectClassName: z.literal("entity"),
|
||||||
handle: z.string().optional(),
|
handle: z.string().optional(),
|
||||||
vcardArray: VCardArraySchema.optional(),
|
vcardArray: VCardArraySchema.optional(),
|
||||||
|
jscard: JSCardSchema.optional(),
|
||||||
roles: z.array(z.string()).optional(),
|
roles: z.array(z.string()).optional(),
|
||||||
publicIds: z
|
publicIds: z
|
||||||
.array(
|
.array(
|
||||||
@@ -263,6 +374,13 @@ export type Event = z.infer<typeof EventSchema>;
|
|||||||
export type Notice = z.infer<typeof NoticeSchema>;
|
export type Notice = z.infer<typeof NoticeSchema>;
|
||||||
export type Remark = z.infer<typeof RemarkSchema>;
|
export type Remark = z.infer<typeof RemarkSchema>;
|
||||||
export type VCardArray = z.infer<typeof VCardArraySchema>;
|
export type VCardArray = z.infer<typeof VCardArraySchema>;
|
||||||
|
export type JSCard = z.infer<typeof JSCardSchema>;
|
||||||
|
export type JSContactName = z.infer<typeof JSContactNameSchema>;
|
||||||
|
export type JSContactEmail = z.infer<typeof JSContactEmailSchema>;
|
||||||
|
export type JSContactPhone = z.infer<typeof JSContactPhoneSchema>;
|
||||||
|
export type JSContactAddress = z.infer<typeof JSContactAddressSchema>;
|
||||||
|
export type JSContactOrganization = z.infer<typeof JSContactOrganizationSchema>;
|
||||||
|
export type JSContactTitle = z.infer<typeof JSContactTitleSchema>;
|
||||||
export type IPAddresses = z.infer<typeof IPAddressesSchema>;
|
export type IPAddresses = z.infer<typeof IPAddressesSchema>;
|
||||||
export type DSData = z.infer<typeof DSDataSchema>;
|
export type DSData = z.infer<typeof DSDataSchema>;
|
||||||
export type KeyData = z.infer<typeof KeyDataSchema>;
|
export type KeyData = z.infer<typeof KeyDataSchema>;
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ import { Result } from "true-myth";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and parse RDAP data from a URL
|
* Fetch and parse RDAP data from a URL
|
||||||
|
* @param url - The URL to fetch
|
||||||
|
* @param schema - The Zod schema to validate the response
|
||||||
|
* @param followRedirects - Whether to automatically follow HTTP redirects (default: false)
|
||||||
*/
|
*/
|
||||||
export async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
|
export async function getAndParse<T>(
|
||||||
const response = await fetch(url);
|
url: string,
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
followRedirects = false
|
||||||
|
): Promise<Result<T, Error>> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
redirect: followRedirects ? "follow" : "manual",
|
||||||
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
const result = schema.safeParse(await response.json());
|
const result = schema.safeParse(await response.json());
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ import { getCachedRegistry } from "@/rdap/services/registry";
|
|||||||
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
||||||
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
|
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
|
||||||
|
|
||||||
|
export interface URLQueryParams {
|
||||||
|
jsContact?: boolean;
|
||||||
|
followReferral?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the RDAP URL for a given registry type and lookup target
|
* Resolve the RDAP URL for a given registry type and lookup target
|
||||||
*/
|
*/
|
||||||
export function getRegistryURL(type: RootRegistryType, lookupTarget: string): string {
|
export function getRegistryURL(
|
||||||
|
type: RootRegistryType,
|
||||||
|
lookupTarget: string,
|
||||||
|
queryParams?: URLQueryParams
|
||||||
|
): string {
|
||||||
const bootstrap = getCachedRegistry(type);
|
const bootstrap = getCachedRegistry(type);
|
||||||
if (bootstrap == null)
|
if (bootstrap == null)
|
||||||
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
|
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
|
||||||
@@ -82,5 +91,17 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
|
|||||||
// ip4 and ip6 both use the 'ip' endpoint in RDAP
|
// ip4 and ip6 both use the 'ip' endpoint in RDAP
|
||||||
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
|
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
|
||||||
|
|
||||||
return `${url}${rdapPath}/${lookupTarget}`;
|
// Build query parameters string
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (queryParams?.jsContact) {
|
||||||
|
params.append("jsContact", "1");
|
||||||
|
}
|
||||||
|
if (queryParams?.followReferral) {
|
||||||
|
params.append("followReferral", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const baseUrl = `${url}${rdapPath}/${lookupTarget}`;
|
||||||
|
|
||||||
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user