diff --git a/src/components/AbstractCard.tsx b/src/components/AbstractCard.tsx index 9b9549b..3ba0202 100644 --- a/src/components/AbstractCard.tsx +++ b/src/components/AbstractCard.tsx @@ -4,6 +4,8 @@ import { useBoolean } from "usehooks-ts"; import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons"; import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import type { ParsedGeneric } from "@/rdap/components/Generic"; +import { generateDownloadFilename } from "@/utils/generateFilename"; type AbstractCardProps = { children?: ReactNode; @@ -11,6 +13,7 @@ type AbstractCardProps = { footer?: ReactNode; data?: object; url?: string; + queryTimestamp?: Date; }; const AbstractCard: FunctionComponent = ({ @@ -19,6 +22,7 @@ const AbstractCard: FunctionComponent = ({ header, footer, data, + queryTimestamp, }) => { const { value: showRaw, toggle: toggleRaw } = useBoolean(false); @@ -97,7 +101,19 @@ const AbstractCard: FunctionComponent = ({ const anchor = document.createElement("a"); anchor.href = URL.createObjectURL(file); - anchor.download = "response.json"; + + // Generate filename based on data and timestamp + const filename = + data != null && + typeof data === "object" && + "objectClassName" in data + ? generateDownloadFilename( + data as ParsedGeneric, + queryTimestamp + ) + : "response.json"; + + anchor.download = filename; anchor.click(); }} aria-label="Download JSON" diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5794e48..33a19e2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -117,7 +117,11 @@ const Index: NextPage = () => { /> ) : null} {response.isJust ? ( - + ) : null} diff --git a/src/rdap/components/AutnumCard.tsx b/src/rdap/components/AutnumCard.tsx index 2db157b..7999b67 100644 --- a/src/rdap/components/AutnumCard.tsx +++ b/src/rdap/components/AutnumCard.tsx @@ -14,9 +14,14 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type AutnumCardProps = { data: AutonomousNumber; url?: string; + queryTimestamp?: Date; }; -const AutnumCard: FunctionComponent = ({ data, url }: AutnumCardProps) => { +const AutnumCard: FunctionComponent = ({ + data, + url, + queryTimestamp, +}: AutnumCardProps) => { const asnRange = data.startAutnum === data.endAutnum ? `AS${data.startAutnum}` @@ -26,6 +31,7 @@ const AutnumCard: FunctionComponent = ({ data, url }: AutnumCar {asnRange} diff --git a/src/rdap/components/DomainCard.tsx b/src/rdap/components/DomainCard.tsx index ec77f77..cfcdd1c 100644 --- a/src/rdap/components/DomainCard.tsx +++ b/src/rdap/components/DomainCard.tsx @@ -16,13 +16,15 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type DomainProps = { data: Domain; url?: string; + queryTimestamp?: Date; }; -const DomainCard: FunctionComponent = ({ data, url }: DomainProps) => { +const DomainCard: FunctionComponent = ({ data, url, queryTimestamp }: DomainProps) => { return ( {data.ldhName ?? data.unicodeName} diff --git a/src/rdap/components/EntityCard.tsx b/src/rdap/components/EntityCard.tsx index d44ce92..02c1b34 100644 --- a/src/rdap/components/EntityCard.tsx +++ b/src/rdap/components/EntityCard.tsx @@ -15,13 +15,19 @@ import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; export type EntityCardProps = { data: Entity; url?: string; + queryTimestamp?: Date; }; -const EntityCard: FunctionComponent = ({ data, url }: EntityCardProps) => { +const EntityCard: FunctionComponent = ({ + data, + url, + queryTimestamp, +}: EntityCardProps) => { return ( diff --git a/src/rdap/components/Generic.tsx b/src/rdap/components/Generic.tsx index 7b4abea..35058aa 100644 --- a/src/rdap/components/Generic.tsx +++ b/src/rdap/components/Generic.tsx @@ -12,25 +12,26 @@ export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | Ip export type ObjectProps = { data: ParsedGeneric; url?: string; + queryTimestamp?: Date; }; -const Generic: FunctionComponent = ({ data, url }: ObjectProps) => { +const Generic: FunctionComponent = ({ data, url, queryTimestamp }: ObjectProps) => { const objectClassName = data.objectClassName; switch (objectClassName) { case "domain": - return ; + return ; case "ip network": - return ; + return ; case "autnum": - return ; + return ; case "entity": - return ; + return ; case "nameserver": - return ; + return ; default: return ( - + Not implemented. (
{objectClassName ?? "null"}
)
); diff --git a/src/rdap/components/IPCard.tsx b/src/rdap/components/IPCard.tsx index df4ae86..5a8deab 100644 --- a/src/rdap/components/IPCard.tsx +++ b/src/rdap/components/IPCard.tsx @@ -14,13 +14,15 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; export type IPCardProps = { data: IpNetwork; url?: string; + queryTimestamp?: Date; }; -const IPCard: FunctionComponent = ({ data, url }: IPCardProps) => { +const IPCard: FunctionComponent = ({ data, url, queryTimestamp }: IPCardProps) => { return ( diff --git a/src/rdap/components/NameserverCard.tsx b/src/rdap/components/NameserverCard.tsx index a8928ea..35a53d8 100644 --- a/src/rdap/components/NameserverCard.tsx +++ b/src/rdap/components/NameserverCard.tsx @@ -14,16 +14,19 @@ import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; export type NameserverCardProps = { data: Nameserver; url?: string; + queryTimestamp?: Date; }; const NameserverCard: FunctionComponent = ({ data, url, + queryTimestamp, }: NameserverCardProps) => { return ( {data.ldhName} diff --git a/src/utils/generateFilename.ts b/src/utils/generateFilename.ts new file mode 100644 index 0000000..2fe31b6 --- /dev/null +++ b/src/utils/generateFilename.ts @@ -0,0 +1,142 @@ +import type { ParsedGeneric } from "@/rdap/components/Generic"; + +/** + * Attempts to convert an IP range to CIDR notation + * Returns the CIDR notation if possible, otherwise returns null + */ +function tryConvertToCIDR( + startAddress: string, + endAddress: string, + ipVersion: "v4" | "v6" +): string | null { + if (ipVersion === "v4") { + // Parse IPv4 addresses + const startParts = startAddress.split(".").map(Number); + const endParts = endAddress.split(".").map(Number); + + if ( + startParts.length !== 4 || + endParts.length !== 4 || + startParts.some((part) => part === undefined) || + endParts.some((part) => part === undefined) + ) { + return null; + } + + // Convert to 32-bit integers - TypeScript now knows these are defined + const startInt = + (startParts[0]! << 24) + + (startParts[1]! << 16) + + (startParts[2]! << 8) + + startParts[3]!; + const endInt = + (endParts[0]! << 24) + (endParts[1]! << 16) + (endParts[2]! << 8) + endParts[3]!; + + // Calculate the number of addresses in the range + const rangeSize = endInt - startInt + 1; + + // Check if it's a power of 2 (valid CIDR block) + if ((rangeSize & (rangeSize - 1)) !== 0) return null; + + // Calculate prefix length + const prefixLength = 32 - Math.log2(rangeSize); + + // Verify that startInt is aligned to the CIDR block + const mask = 0xffffffff << (32 - prefixLength); + if ((startInt & mask) !== startInt) return null; + + return `${startAddress}/${prefixLength}`; + } + + // For IPv6, basic implementation - can be extended later + // For now, just return null for IPv6 ranges + return null; +} + +/** + * Extracts a meaningful identifier from the RDAP data + */ +function extractIdentifier(data: ParsedGeneric): string { + switch (data.objectClassName) { + case "domain": + return data.ldhName ?? data.unicodeName ?? data.handle ?? "unknown"; + + case "ip network": { + // Try to convert to CIDR first + const cidr = tryConvertToCIDR(data.startAddress, data.endAddress, data.ipVersion); + if (cidr != null) { + return cidr; + } + // Fall back to range notation with underscore separator + return `${data.startAddress}_${data.endAddress}`; + } + + case "autnum": { + const prefix = + data.startAutnum === data.endAutnum + ? `AS${data.startAutnum}` + : `AS${data.startAutnum}-${data.endAutnum}`; + return prefix; + } + + case "entity": + return data.handle ?? "unknown"; + + case "nameserver": + return data.ldhName ?? data.unicodeName ?? data.handle ?? "unknown"; + + default: + return "unknown"; + } +} + +/** + * Formats a Date object to YYYYMMDD-HHMMSS format + */ +function formatTimestamp(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + + return `${year}${month}${day}-${hours}${minutes}${seconds}`; +} + +/** + * Sanitizes a filename by replacing unsafe characters + * Replaces characters that are problematic in filenames across different OS + */ +function sanitizeFilename(filename: string): string { + return filename + .replace(/[<>:"|?*]/g, "_") // Windows reserved characters + .replace(/\//g, "_") // Path separator + .replace(/\\/g, "_") // Windows path separator + .replace(/\s+/g, "-") // Replace whitespace with dashes + .replace(/[^\x20-\x7E]/g, "_") // Replace non-printable ASCII with underscores + .replace(/_+/g, "_") // Collapse multiple underscores + .replace(/-+/g, "-"); // Collapse multiple dashes +} + +/** + * Generates a descriptive filename for downloading RDAP data + * Format: rdap-{type}-{identifier}-{timestamp}.json + * + * @param data - The RDAP response data + * @param timestamp - The timestamp when the query was completed (optional) + * @returns A sanitized filename for the download + */ +export function generateDownloadFilename(data: ParsedGeneric, timestamp?: Date): string { + const type = data.objectClassName.replace(" ", "-"); + const identifier = extractIdentifier(data); + const timestampStr = timestamp != null ? formatTimestamp(timestamp) : ""; + + const parts = ["rdap", type, identifier]; + if (timestampStr !== "") { + parts.push(timestampStr); + } + + const filename = parts.join("-") + ".json"; + return sanitizeFilename(filename); +}