mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 09:16:05 -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 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 ContactDisplay from "@/rdap/components/ContactDisplay";
|
||||
import CopyButton from "@/components/CopyButton";
|
||||
import StatusBadge from "@/components/StatusBadge";
|
||||
|
||||
@@ -84,9 +84,9 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
|
||||
)}
|
||||
</DataList.Root>
|
||||
|
||||
{entity.vcardArray && (
|
||||
{(entity.vcardArray || entity.jscard) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<VCardDisplay vcardArray={entity.vcardArray} />
|
||||
<ContactDisplay entity={entity} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 ContactDisplay from "@/rdap/components/ContactDisplay";
|
||||
import Events from "@/rdap/components/Events";
|
||||
import LinksSection from "@/rdap/components/LinksSection";
|
||||
import RemarksSection from "@/rdap/components/RemarksSection";
|
||||
@@ -102,9 +102,9 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
)}
|
||||
{data.vcardArray && (
|
||||
{(data.vcardArray || data.jscard) && (
|
||||
<Property title="Contact Information">
|
||||
<VCardDisplay vcardArray={data.vcardArray} />
|
||||
<ContactDisplay entity={data} />
|
||||
</Property>
|
||||
)}
|
||||
{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={() => {
|
||||
isFocusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Don't clear focus state if we're loading (input is being disabled)
|
||||
// so we can restore focus when loading completes
|
||||
if (!isLoading) {
|
||||
isFocusedRef.current = false;
|
||||
}
|
||||
}}
|
||||
{...register("target", {
|
||||
required: true,
|
||||
onBlur: () => {
|
||||
// Don't clear focus state if we're loading (input is being disabled)
|
||||
// so we can restore focus when loading completes
|
||||
if (!isLoading) {
|
||||
isFocusedRef.current = false;
|
||||
}
|
||||
},
|
||||
onChange: () => {
|
||||
const targetValue = getValues("target");
|
||||
const oldIsEmpty = inputValue.trim() === "";
|
||||
|
||||
@@ -79,7 +79,9 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
}, [uriType, warningHandler]);
|
||||
|
||||
async function submitInternal(
|
||||
target: string
|
||||
target: string,
|
||||
requestJSContact: boolean,
|
||||
followReferral: boolean
|
||||
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
||||
if (target == null || target.length == 0)
|
||||
return Result.err(new Error("A target must be given in order to execute a lookup."));
|
||||
@@ -115,25 +117,28 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
targetType = detectedType.value;
|
||||
}
|
||||
|
||||
// Prepare query parameters for RDAP requests
|
||||
const queryParams = { jsContact: requestJSContact, followReferral };
|
||||
|
||||
switch (targetType) {
|
||||
// Block scoped case to allow url const reuse
|
||||
case "ip4": {
|
||||
await loadBootstrap("ip4");
|
||||
const url = getRegistryURL(targetType, target);
|
||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
||||
const url = getRegistryURL(targetType, target, queryParams);
|
||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
||||
if (result.isErr) return Result.err(result.error);
|
||||
return Result.ok({ data: result.value, url });
|
||||
}
|
||||
case "ip6": {
|
||||
await loadBootstrap("ip6");
|
||||
const url = getRegistryURL(targetType, target);
|
||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
||||
const url = getRegistryURL(targetType, target, queryParams);
|
||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
||||
if (result.isErr) return Result.err(result.error);
|
||||
return Result.ok({ data: result.value, url });
|
||||
}
|
||||
case "domain": {
|
||||
await loadBootstrap("domain");
|
||||
const url = getRegistryURL(targetType, target);
|
||||
const url = getRegistryURL(targetType, target, queryParams);
|
||||
|
||||
// HTTP
|
||||
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);
|
||||
|
||||
return Result.ok({ data: result.value, url });
|
||||
}
|
||||
case "autnum": {
|
||||
await loadBootstrap("autnum");
|
||||
const url = getRegistryURL(targetType, target);
|
||||
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
|
||||
const url = getRegistryURL(targetType, target, queryParams);
|
||||
const result = await getAndParse<AutonomousNumber>(
|
||||
url,
|
||||
AutonomousNumberSchema,
|
||||
followReferral
|
||||
);
|
||||
if (result.isErr) return Result.err(result.error);
|
||||
return Result.ok({ data: result.value, url });
|
||||
}
|
||||
case "tld": {
|
||||
// remove the leading dot
|
||||
const value = target.startsWith(".") ? target.slice(1) : target;
|
||||
const url = `https://root.rdap.org/domain/${value}`;
|
||||
const result = await getAndParse<Domain>(url, DomainSchema);
|
||||
const params = new URLSearchParams();
|
||||
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);
|
||||
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 {
|
||||
// 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) {
|
||||
setError(response.error.message);
|
||||
|
||||
@@ -86,6 +86,116 @@ export const RemarkSchema = z.object({
|
||||
// Format: ["vcard", [properties...]]
|
||||
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({
|
||||
v4: z.array(z.string()).optional(),
|
||||
v6: z.array(z.string()).optional(),
|
||||
@@ -166,6 +276,7 @@ const BaseEntitySchema = z.object({
|
||||
objectClassName: z.literal("entity"),
|
||||
handle: z.string().optional(),
|
||||
vcardArray: VCardArraySchema.optional(),
|
||||
jscard: JSCardSchema.optional(),
|
||||
roles: z.array(z.string()).optional(),
|
||||
publicIds: z
|
||||
.array(
|
||||
@@ -263,6 +374,13 @@ export type Event = z.infer<typeof EventSchema>;
|
||||
export type Notice = z.infer<typeof NoticeSchema>;
|
||||
export type Remark = z.infer<typeof RemarkSchema>;
|
||||
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 DSData = z.infer<typeof DSDataSchema>;
|
||||
export type KeyData = z.infer<typeof KeyDataSchema>;
|
||||
|
||||
@@ -3,9 +3,18 @@ import { Result } from "true-myth";
|
||||
|
||||
/**
|
||||
* 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>> {
|
||||
const response = await fetch(url);
|
||||
export async function getAndParse<T>(
|
||||
url: string,
|
||||
schema: ZodSchema<T>,
|
||||
followRedirects = false
|
||||
): Promise<Result<T, Error>> {
|
||||
const response = await fetch(url, {
|
||||
redirect: followRedirects ? "follow" : "manual",
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = schema.safeParse(await response.json());
|
||||
|
||||
@@ -3,10 +3,19 @@ import { getCachedRegistry } from "@/rdap/services/registry";
|
||||
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
||||
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
|
||||
*/
|
||||
export function getRegistryURL(type: RootRegistryType, lookupTarget: string): string {
|
||||
export function getRegistryURL(
|
||||
type: RootRegistryType,
|
||||
lookupTarget: string,
|
||||
queryParams?: URLQueryParams
|
||||
): string {
|
||||
const bootstrap = getCachedRegistry(type);
|
||||
if (bootstrap == null)
|
||||
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
|
||||
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