mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 01:16:00 -06:00
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:
@@ -4,6 +4,8 @@ import { useBoolean } from "usehooks-ts";
|
|||||||
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons";
|
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons";
|
||||||
import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes";
|
import { Card, Flex, Box, IconButton, Code, Tooltip } from "@radix-ui/themes";
|
||||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
|
import type { ParsedGeneric } from "@/rdap/components/Generic";
|
||||||
|
import { generateDownloadFilename } from "@/utils/generateFilename";
|
||||||
|
|
||||||
type AbstractCardProps = {
|
type AbstractCardProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -11,6 +13,7 @@ type AbstractCardProps = {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
data?: object;
|
data?: object;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||||
@@ -19,6 +22,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
header,
|
header,
|
||||||
footer,
|
footer,
|
||||||
data,
|
data,
|
||||||
|
queryTimestamp,
|
||||||
}) => {
|
}) => {
|
||||||
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
|
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
|
||||||
|
|
||||||
@@ -97,7 +101,19 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
|
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = URL.createObjectURL(file);
|
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();
|
anchor.click();
|
||||||
}}
|
}}
|
||||||
aria-label="Download JSON"
|
aria-label="Download JSON"
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ const Index: NextPage = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{response.isJust ? (
|
{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}
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
|
|||||||
export type AutnumCardProps = {
|
export type AutnumCardProps = {
|
||||||
data: AutonomousNumber;
|
data: AutonomousNumber;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCardProps) => {
|
const AutnumCard: FunctionComponent<AutnumCardProps> = ({
|
||||||
|
data,
|
||||||
|
url,
|
||||||
|
queryTimestamp,
|
||||||
|
}: AutnumCardProps) => {
|
||||||
const asnRange =
|
const asnRange =
|
||||||
data.startAutnum === data.endAutnum
|
data.startAutnum === data.endAutnum
|
||||||
? `AS${data.startAutnum}`
|
? `AS${data.startAutnum}`
|
||||||
@@ -26,6 +31,7 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
|
|||||||
<AbstractCard
|
<AbstractCard
|
||||||
data={data}
|
data={data}
|
||||||
url={url}
|
url={url}
|
||||||
|
queryTimestamp={queryTimestamp}
|
||||||
header={
|
header={
|
||||||
<Flex gap="2" align="center" wrap="wrap">
|
<Flex gap="2" align="center" wrap="wrap">
|
||||||
<Text size="5">{asnRange}</Text>
|
<Text size="5">{asnRange}</Text>
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
|
|||||||
export type DomainProps = {
|
export type DomainProps = {
|
||||||
data: Domain;
|
data: Domain;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps) => {
|
const DomainCard: FunctionComponent<DomainProps> = ({ data, url, queryTimestamp }: DomainProps) => {
|
||||||
return (
|
return (
|
||||||
<AbstractCard
|
<AbstractCard
|
||||||
data={data}
|
data={data}
|
||||||
url={url}
|
url={url}
|
||||||
|
queryTimestamp={queryTimestamp}
|
||||||
header={
|
header={
|
||||||
<Flex gap="2" align="center" wrap="wrap">
|
<Flex gap="2" align="center" wrap="wrap">
|
||||||
<Text size="5">{data.ldhName ?? data.unicodeName}</Text>
|
<Text size="5">{data.ldhName ?? data.unicodeName}</Text>
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
|
|||||||
export type EntityCardProps = {
|
export type EntityCardProps = {
|
||||||
data: Entity;
|
data: Entity;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCardProps) => {
|
const EntityCard: FunctionComponent<EntityCardProps> = ({
|
||||||
|
data,
|
||||||
|
url,
|
||||||
|
queryTimestamp,
|
||||||
|
}: EntityCardProps) => {
|
||||||
return (
|
return (
|
||||||
<AbstractCard
|
<AbstractCard
|
||||||
data={data}
|
data={data}
|
||||||
url={url}
|
url={url}
|
||||||
|
queryTimestamp={queryTimestamp}
|
||||||
header={
|
header={
|
||||||
<Flex gap="2" align="center" wrap="wrap">
|
<Flex gap="2" align="center" wrap="wrap">
|
||||||
<Text size="5">
|
<Text size="5">
|
||||||
|
|||||||
@@ -12,25 +12,26 @@ export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | Ip
|
|||||||
export type ObjectProps = {
|
export type ObjectProps = {
|
||||||
data: ParsedGeneric;
|
data: ParsedGeneric;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) => {
|
const Generic: FunctionComponent<ObjectProps> = ({ data, url, queryTimestamp }: ObjectProps) => {
|
||||||
const objectClassName = data.objectClassName;
|
const objectClassName = data.objectClassName;
|
||||||
|
|
||||||
switch (objectClassName) {
|
switch (objectClassName) {
|
||||||
case "domain":
|
case "domain":
|
||||||
return <DomainCard url={url} data={data} />;
|
return <DomainCard url={url} data={data} queryTimestamp={queryTimestamp} />;
|
||||||
case "ip network":
|
case "ip network":
|
||||||
return <IPCard url={url} data={data} />;
|
return <IPCard url={url} data={data} queryTimestamp={queryTimestamp} />;
|
||||||
case "autnum":
|
case "autnum":
|
||||||
return <AutnumCard url={url} data={data} />;
|
return <AutnumCard url={url} data={data} queryTimestamp={queryTimestamp} />;
|
||||||
case "entity":
|
case "entity":
|
||||||
return <EntityCard url={url} data={data} />;
|
return <EntityCard url={url} data={data} queryTimestamp={queryTimestamp} />;
|
||||||
case "nameserver":
|
case "nameserver":
|
||||||
return <NameserverCard url={url} data={data} />;
|
return <NameserverCard url={url} data={data} queryTimestamp={queryTimestamp} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<AbstractCard url={url}>
|
<AbstractCard url={url} queryTimestamp={queryTimestamp}>
|
||||||
Not implemented. (<pre>{objectClassName ?? "null"}</pre>)
|
Not implemented. (<pre>{objectClassName ?? "null"}</pre>)
|
||||||
</AbstractCard>
|
</AbstractCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
|
|||||||
export type IPCardProps = {
|
export type IPCardProps = {
|
||||||
data: IpNetwork;
|
data: IpNetwork;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
|
const IPCard: FunctionComponent<IPCardProps> = ({ data, url, queryTimestamp }: IPCardProps) => {
|
||||||
return (
|
return (
|
||||||
<AbstractCard
|
<AbstractCard
|
||||||
data={data}
|
data={data}
|
||||||
url={url}
|
url={url}
|
||||||
|
queryTimestamp={queryTimestamp}
|
||||||
header={
|
header={
|
||||||
<Flex gap="2" align="center" wrap="wrap">
|
<Flex gap="2" align="center" wrap="wrap">
|
||||||
<Text size="5">
|
<Text size="5">
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
|
|||||||
export type NameserverCardProps = {
|
export type NameserverCardProps = {
|
||||||
data: Nameserver;
|
data: Nameserver;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
queryTimestamp?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NameserverCard: FunctionComponent<NameserverCardProps> = ({
|
const NameserverCard: FunctionComponent<NameserverCardProps> = ({
|
||||||
data,
|
data,
|
||||||
url,
|
url,
|
||||||
|
queryTimestamp,
|
||||||
}: NameserverCardProps) => {
|
}: NameserverCardProps) => {
|
||||||
return (
|
return (
|
||||||
<AbstractCard
|
<AbstractCard
|
||||||
data={data}
|
data={data}
|
||||||
url={url}
|
url={url}
|
||||||
|
queryTimestamp={queryTimestamp}
|
||||||
header={
|
header={
|
||||||
<Flex gap="2" align="center" wrap="wrap">
|
<Flex gap="2" align="center" wrap="wrap">
|
||||||
<Text size="5">{data.ldhName}</Text>
|
<Text size="5">{data.ldhName}</Text>
|
||||||
|
|||||||
142
src/utils/generateFilename.ts
Normal file
142
src/utils/generateFilename.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user