feat: add dynamic filename generation for RDAP JSON downloads

Implement context-aware filename generation for downloaded RDAP
responses, replacing the generic "response.json" with descriptive
names based on object type, identifier, and query timestamp.
Filenames follow the pattern: rdap-{type}-{identifier}-{timestamp}.json,
with automatic CIDR conversion for IP networks and proper sanitization
of special characters.
This commit is contained in:
2025-10-23 11:12:58 -05:00
parent 9635098102
commit ada17fc9a9
9 changed files with 195 additions and 13 deletions

View File

@@ -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<AbstractCardProps> = ({
@@ -19,6 +22,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
header,
footer,
data,
queryTimestamp,
}) => {
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
@@ -97,7 +101,19 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
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"

View File

@@ -117,7 +117,11 @@ const Index: NextPage = () => {
/>
) : null}
{response.isJust ? (
<Generic url={response.value.url} data={response.value.data} />
<Generic
url={response.value.url}
data={response.value.data}
queryTimestamp={response.value.completeTime}
/>
) : null}
</Section>
</Container>

View File

@@ -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<AutnumCardProps> = ({ data, url }: AutnumCardProps) => {
const AutnumCard: FunctionComponent<AutnumCardProps> = ({
data,
url,
queryTimestamp,
}: AutnumCardProps) => {
const asnRange =
data.startAutnum === data.endAutnum
? `AS${data.startAutnum}`
@@ -26,6 +31,7 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
<AbstractCard
data={data}
url={url}
queryTimestamp={queryTimestamp}
header={
<Flex gap="2" align="center" wrap="wrap">
<Text size="5">{asnRange}</Text>

View File

@@ -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<DomainProps> = ({ data, url }: DomainProps) => {
const DomainCard: FunctionComponent<DomainProps> = ({ data, url, queryTimestamp }: DomainProps) => {
return (
<AbstractCard
data={data}
url={url}
queryTimestamp={queryTimestamp}
header={
<Flex gap="2" align="center" wrap="wrap">
<Text size="5">{data.ldhName ?? data.unicodeName}</Text>

View File

@@ -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<EntityCardProps> = ({ data, url }: EntityCardProps) => {
const EntityCard: FunctionComponent<EntityCardProps> = ({
data,
url,
queryTimestamp,
}: EntityCardProps) => {
return (
<AbstractCard
data={data}
url={url}
queryTimestamp={queryTimestamp}
header={
<Flex gap="2" align="center" wrap="wrap">
<Text size="5">

View File

@@ -12,25 +12,26 @@ export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | Ip
export type ObjectProps = {
data: ParsedGeneric;
url?: string;
queryTimestamp?: Date;
};
const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) => {
const Generic: FunctionComponent<ObjectProps> = ({ data, url, queryTimestamp }: ObjectProps) => {
const objectClassName = data.objectClassName;
switch (objectClassName) {
case "domain":
return <DomainCard url={url} data={data} />;
return <DomainCard url={url} data={data} queryTimestamp={queryTimestamp} />;
case "ip network":
return <IPCard url={url} data={data} />;
return <IPCard url={url} data={data} queryTimestamp={queryTimestamp} />;
case "autnum":
return <AutnumCard url={url} data={data} />;
return <AutnumCard url={url} data={data} queryTimestamp={queryTimestamp} />;
case "entity":
return <EntityCard url={url} data={data} />;
return <EntityCard url={url} data={data} queryTimestamp={queryTimestamp} />;
case "nameserver":
return <NameserverCard url={url} data={data} />;
return <NameserverCard url={url} data={data} queryTimestamp={queryTimestamp} />;
default:
return (
<AbstractCard url={url}>
<AbstractCard url={url} queryTimestamp={queryTimestamp}>
Not implemented. (<pre>{objectClassName ?? "null"}</pre>)
</AbstractCard>
);

View File

@@ -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<IPCardProps> = ({ data, url }: IPCardProps) => {
const IPCard: FunctionComponent<IPCardProps> = ({ data, url, queryTimestamp }: IPCardProps) => {
return (
<AbstractCard
data={data}
url={url}
queryTimestamp={queryTimestamp}
header={
<Flex gap="2" align="center" wrap="wrap">
<Text size="5">

View File

@@ -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<NameserverCardProps> = ({
data,
url,
queryTimestamp,
}: NameserverCardProps) => {
return (
<AbstractCard
data={data}
url={url}
queryTimestamp={queryTimestamp}
header={
<Flex gap="2" align="center" wrap="wrap">
<Text size="5">{data.ldhName}</Text>

View File

@@ -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);
}