mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-05 23:15:58 -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 { 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
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