diff --git a/src/rdap/components/ContactDisplay.tsx b/src/rdap/components/ContactDisplay.tsx new file mode 100644 index 0000000..95bb165 --- /dev/null +++ b/src/rdap/components/ContactDisplay.tsx @@ -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 = ({ entity }) => { + // Check for JSContact format first (preferred modern format) + if (entity.jscard) { + return ; + } + + // Fall back to vCard format if available + if (entity.vcardArray) { + return ; + } + + // No contact information available + return null; +}; + +export default ContactDisplay; diff --git a/src/rdap/components/EntitiesSection.tsx b/src/rdap/components/EntitiesSection.tsx index 6b43852..4b12926 100644 --- a/src/rdap/components/EntitiesSection.tsx +++ b/src/rdap/components/EntitiesSection.tsx @@ -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 = ({ entities }) )} - {entity.vcardArray && ( + {(entity.vcardArray || entity.jscard) && ( - + )} diff --git a/src/rdap/components/EntityCard.tsx b/src/rdap/components/EntityCard.tsx index 2de2e17..d44ce92 100644 --- a/src/rdap/components/EntityCard.tsx +++ b/src/rdap/components/EntityCard.tsx @@ -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 = ({ data, url }: EntityCar )} - {data.vcardArray && ( + {(data.vcardArray || data.jscard) && ( - + )} {data.entities && data.entities.length > 0 && ( diff --git a/src/rdap/components/JSContactDisplay.tsx b/src/rdap/components/JSContactDisplay.tsx new file mode 100644 index 0000000..4523c57 --- /dev/null +++ b/src/rdap/components/JSContactDisplay.tsx @@ -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 = ({ 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; + 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; + contexts?: Record; + 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; + 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; + 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; + pref?: number; + label?: string; + }, + ] + >) + : []; + + return ( + + + {displayName && ( + + Name + + + {displayName} + + + + + )} + + {jscard.kind && jscard.kind !== "individual" && ( + + Kind + + {jscard.kind} + + + )} + + {organizations.length > 0 && ( + + Organization + + + {organizations.map((org, index) => ( + + {org} + + + ))} + + + + )} + + {titles.length > 0 && ( + + Title/Role + + + {titles.map((title, index) => ( + + {title.name} + {title.kind && title.kind !== "title" && ( + + {title.kind} + + )} + + ))} + + + + )} + + {emails.length > 0 && ( + + Email + + + {emails.map(([key, email]) => ( + + + {email.address} + + {email.label && ( + + {email.label} + + )} + {email.contexts && + Object.keys(email.contexts).length > 0 && ( + + {Object.keys(email.contexts).join(", ")} + + )} + + + ))} + + + + )} + + {phones.length > 0 && ( + + Phone + + + {phones.map(([key, phone]) => ( + + + {phone.number} + + {phone.features && + Object.keys(phone.features).length > 0 && ( + + {Object.keys(phone.features).join(", ")} + + )} + {phone.label && ( + + {phone.label} + + )} + + + ))} + + + + )} + + {addresses.length > 0 && ( + + Address + + + {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 ( + + {addressText} + {details.length > 0 && ( + + {details.map((detail, idx) => ( + + {detail} + + ))} + + )} + + ); + })} + + + + )} + + {onlineServices.length > 0 && ( + + Online + + + {onlineServices.map(([key, service]) => ( + + {service.uri && ( + + + {service.service || service.user || service.uri} + + + )} + {!service.uri && ( + + {service.service || service.user || key} + + )} + {service.service && ( + + {service.service} + + )} + {service.uri && } + + ))} + + + + )} + + {links.length > 0 && ( + + Links + + + {links.map(([key, link]) => ( + + + {link.label || link.uri} + + + + ))} + + + + )} + + + ); +}; + +export default JSContactDisplay; diff --git a/src/rdap/components/LookupInput.tsx b/src/rdap/components/LookupInput.tsx index 99df1bf..980d891 100644 --- a/src/rdap/components/LookupInput.tsx +++ b/src/rdap/components/LookupInput.tsx @@ -160,15 +160,15 @@ const LookupInput: FunctionComponent = ({ 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() === ""; diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx index d9fd1ed..74a58a7 100644 --- a/src/rdap/hooks/useLookup.tsx +++ b/src/rdap/hooks/useLookup.tsx @@ -79,7 +79,9 @@ const useLookup = (warningHandler?: WarningHandler) => { }, [uriType, warningHandler]); async function submitInternal( - target: string + target: string, + requestJSContact: boolean, + followReferral: boolean ): Promise> { 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(url, IpNetworkSchema); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse(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(url, IpNetworkSchema); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse(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(url, DomainSchema); + const result = await getAndParse(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(url, AutonomousNumberSchema); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse( + 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(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(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> { + async function submit({ + target, + requestJSContact, + followReferral, + }: SubmitProps): Promise> { try { // target is already set in state, but it's also provided by the form callback, so we'll use it. - const response = await submitInternal(target); + const response = await submitInternal(target, requestJSContact, followReferral); if (response.isErr) { setError(response.error.message); diff --git a/src/rdap/schemas.ts b/src/rdap/schemas.ts index 352a2dd..8b889ee 100644 --- a/src/rdap/schemas.ts +++ b/src/rdap/schemas.ts @@ -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; export type Notice = z.infer; export type Remark = z.infer; export type VCardArray = z.infer; +export type JSCard = z.infer; +export type JSContactName = z.infer; +export type JSContactEmail = z.infer; +export type JSContactPhone = z.infer; +export type JSContactAddress = z.infer; +export type JSContactOrganization = z.infer; +export type JSContactTitle = z.infer; export type IPAddresses = z.infer; export type DSData = z.infer; export type KeyData = z.infer; diff --git a/src/rdap/services/rdap-api.ts b/src/rdap/services/rdap-api.ts index c88d00f..4ba58fb 100644 --- a/src/rdap/services/rdap-api.ts +++ b/src/rdap/services/rdap-api.ts @@ -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(url: string, schema: ZodSchema): Promise> { - const response = await fetch(url); +export async function getAndParse( + url: string, + schema: ZodSchema, + followRedirects = false +): Promise> { + const response = await fetch(url, { + redirect: followRedirects ? "follow" : "manual", + }); if (response.status === 200) { const result = schema.safeParse(await response.json()); diff --git a/src/rdap/services/url-resolver.ts b/src/rdap/services/url-resolver.ts index 8bbffe1..6705efa 100644 --- a/src/rdap/services/url-resolver.ts +++ b/src/rdap/services/url-resolver.ts @@ -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; }