mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 01:16:00 -06:00
fix: address code quality issues and critical bugs
- Fix getBestURL HTTPS prioritization bug (critical security issue) - Standardize equality operators to === for status code checks - Add JSON parsing error handling with try-catch - Improve IP address validation with explicit error messages - Fix ARIA label accessibility (add id="search" to TextField) - Remove unused callback hook in useLookup - Add type detection debouncing (150ms) using @mantine/hooks - Remove duplicate LookupInput component from src/components/form/
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@swc/helpers": "^0.5.11",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@fontsource/ibm-plex-mono':
|
||||
specifier: ^5.2.7
|
||||
version: 5.2.7
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(react@19.2.0)
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(react@19.2.0)
|
||||
@@ -602,6 +605,11 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mantine/hooks@8.3.5':
|
||||
resolution: {integrity: sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==}
|
||||
peerDependencies:
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -4349,6 +4357,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mantine/hooks@8.3.5(react@19.2.0)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.6.0
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import type { FunctionComponent } from "react";
|
||||
import { useState } from "react";
|
||||
import { onPromise, preventDefault } from "@/lib/utils";
|
||||
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||
import { TargetTypeEnum } from "@/rdap/schemas";
|
||||
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||
import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes";
|
||||
import type { Maybe } from "true-myth";
|
||||
import { placeholders } from "@/rdap/constants";
|
||||
|
||||
/**
|
||||
* Props for the LookupInput component.
|
||||
*/
|
||||
type LookupInputProps = {
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Callback function called when a type of registry is detected when a user changes their input.
|
||||
* @param type - The detected type of registry.
|
||||
* @returns A promise.
|
||||
*/
|
||||
onRegistry?: (type: TargetType) => Promise<void>;
|
||||
/**
|
||||
* Callback function called when a user hits submit.
|
||||
* @param props - The submit props.
|
||||
* @returns A promise.
|
||||
*/
|
||||
onSubmit?: (props: SubmitProps) => Promise<void>;
|
||||
/**
|
||||
* Callback function called when a user changes their input (text search) or explicitly changes the type of search.
|
||||
* @param target - The target object containing the search target and target type.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
onChange?: (target: { target: string; targetType: TargetType | null }) => Promise<void>;
|
||||
detectedType: Maybe<TargetType>;
|
||||
};
|
||||
|
||||
const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
onChange,
|
||||
detectedType,
|
||||
}: LookupInputProps) => {
|
||||
const { register, handleSubmit, getValues, control } = useForm<SubmitProps>({
|
||||
defaultValues: {
|
||||
target: "",
|
||||
// Not used at this time.
|
||||
followReferral: false,
|
||||
requestJSContact: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A mapping of available (simple) target types to their long-form human-readable names.
|
||||
*/
|
||||
const objectNames: Record<SimplifiedTargetType | "auto", string> = {
|
||||
auto: "Autodetect",
|
||||
domain: "Domain",
|
||||
ip: "IP/CIDR", // IPv4/IPv6 are combined into this option
|
||||
tld: "TLD",
|
||||
autnum: "AS Number",
|
||||
entity: "Entity Handle",
|
||||
registrar: "Registrar",
|
||||
url: "URL",
|
||||
json: "JSON",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of precise target types to their simplified short-form names.
|
||||
*/
|
||||
const targetShortNames: Record<TargetType, string> = {
|
||||
domain: "Domain",
|
||||
tld: "TLD",
|
||||
ip4: "IPv4",
|
||||
ip6: "IPv6",
|
||||
autnum: "ASN",
|
||||
entity: "Entity",
|
||||
registrar: "Registrar",
|
||||
url: "URL",
|
||||
json: "JSON",
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents the selected value in the LookupInput component.
|
||||
*/
|
||||
const [selected, setSelected] = useState<SimplifiedTargetType | "auto">("auto");
|
||||
|
||||
/**
|
||||
* Retrieves the target type based on the provided value.
|
||||
* @param value - The value to retrieve the target type for.
|
||||
* @returns The target type as ObjectType or null.
|
||||
*/
|
||||
function retrieveTargetType(value?: string | null): TargetType | null {
|
||||
// If the value is null and the selected value is null, return null.
|
||||
if (value == null) value = selected;
|
||||
|
||||
// 'auto' means 'do whatever' so we return null.
|
||||
if (value == "auto") return null;
|
||||
|
||||
// Validate the value is a valid TargetType
|
||||
const result = TargetTypeEnum.safeParse(value);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="pb-2.5"
|
||||
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
|
||||
>
|
||||
<Flex direction="column" gap="3">
|
||||
<label htmlFor="search" className="sr-only">
|
||||
Search
|
||||
</label>
|
||||
<Flex gap="0" style={{ position: "relative" }}>
|
||||
<TextField.Root
|
||||
size="3"
|
||||
placeholder={placeholders[selected]}
|
||||
disabled={isLoading}
|
||||
{...register("target", {
|
||||
required: true,
|
||||
onChange: () => {
|
||||
if (onChange != undefined)
|
||||
void onChange({
|
||||
target: getValues("target"),
|
||||
targetType: retrieveTargetType(null),
|
||||
});
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
border: "1px solid var(--gray-7)",
|
||||
borderRight: "none",
|
||||
boxShadow: "none",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<TextField.Slot side="left">
|
||||
<IconButton
|
||||
size="1"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
tabIndex={-1}
|
||||
style={{ cursor: isLoading ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ReloadIcon className="animate-spin" width="16" height="16" />
|
||||
) : (
|
||||
<MagnifyingGlassIcon width="16" height="16" />
|
||||
)}
|
||||
</IconButton>
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<Select.Root
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
setSelected(value as SimplifiedTargetType | "auto");
|
||||
|
||||
if (onChange != undefined)
|
||||
void onChange({
|
||||
target: getValues("target"),
|
||||
targetType: retrieveTargetType(value),
|
||||
});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
size="3"
|
||||
>
|
||||
<Select.Trigger
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
|
||||
minWidth: "150px",
|
||||
}}
|
||||
>
|
||||
{selected == "auto" ? (
|
||||
detectedType.isJust ? (
|
||||
<Text>Auto ({targetShortNames[detectedType.value]})</Text>
|
||||
) : (
|
||||
objectNames["auto"]
|
||||
)
|
||||
) : (
|
||||
<Flex align="center" gap="2">
|
||||
<LockClosedIcon width="14" height="14" />
|
||||
{objectNames[selected]}
|
||||
</Flex>
|
||||
)}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content position="popper">
|
||||
{Object.entries(objectNames).map(([key, value]) => (
|
||||
<Select.Item key={key} value={key}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
gap="2"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{value}
|
||||
</Flex>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex pl="3" gapX="5" gapY="2" wrap="wrap">
|
||||
<Flex asChild align="center" gap="2">
|
||||
<Text as="label" size="2">
|
||||
<Controller
|
||||
name="requestJSContact"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
Request JSContact
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex asChild align="center" gap="2">
|
||||
<Text as="label" size="2">
|
||||
<Controller
|
||||
name="followReferral"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
Follow referral to registrar's RDAP record
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LookupInput;
|
||||
@@ -8,12 +8,10 @@ import LookupInput from "@/rdap/components/LookupInput";
|
||||
import ErrorCard from "@/components/ErrorCard";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Maybe } from "true-myth";
|
||||
import type { TargetType } from "@/rdap/schemas";
|
||||
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
|
||||
|
||||
const Index: NextPage = () => {
|
||||
const { error, setTarget, setTargetType, submit, getType } = useLookup();
|
||||
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(Maybe.nothing());
|
||||
const { error, setTarget, setTargetType, submit, currentType } = useLookup();
|
||||
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
|
||||
@@ -65,20 +63,10 @@ const Index: NextPage = () => {
|
||||
<Section size="2">
|
||||
<LookupInput
|
||||
isLoading={isLoading}
|
||||
detectedType={detectedType}
|
||||
onChange={async ({ target, targetType }) => {
|
||||
detectedType={currentType}
|
||||
onChange={({ target, targetType }) => {
|
||||
setTarget(target);
|
||||
setTargetType(targetType);
|
||||
|
||||
// Only run autodetection when in autodetect mode (targetType is null)
|
||||
if (targetType === null) {
|
||||
const detectResult = await getType(target);
|
||||
if (detectResult.isOk) {
|
||||
setDetectedType(Maybe.just(detectResult.value));
|
||||
} else {
|
||||
setDetectedType(Maybe.nothing());
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={async function (props) {
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,7 @@ type LookupInputProps = {
|
||||
* @param target - The target object containing the search target and target type.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
onChange?: (target: { target: string; targetType: TargetType | null }) => Promise<void>;
|
||||
onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise<void>;
|
||||
detectedType: Maybe<TargetType>;
|
||||
};
|
||||
|
||||
@@ -113,6 +113,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
</label>
|
||||
<Flex gap="0" style={{ position: "relative" }}>
|
||||
<TextField.Root
|
||||
id="search"
|
||||
size="3"
|
||||
placeholder={placeholders[selected]}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { getType, validateInputForType } from "@/rdap/utils";
|
||||
import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema];
|
||||
const useLookup = (warningHandler?: WarningHandler) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<string>("");
|
||||
const [debouncedTarget] = useDebouncedValue(target, 150);
|
||||
const [uriType, setUriType] = useState<Maybe<TargetType>>(Maybe.nothing());
|
||||
|
||||
// Used by a callback on LookupInput to forcibly set the type of the lookup.
|
||||
@@ -39,13 +41,20 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
return getType(target, getRegistry);
|
||||
}, []);
|
||||
|
||||
useCallback(async () => {
|
||||
if (currentType != null) return Maybe.just(currentType);
|
||||
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) =>
|
||||
Maybe.just(type)
|
||||
);
|
||||
setUriType(uri);
|
||||
}, [target, currentType, getTypeEasy]);
|
||||
useEffect(() => {
|
||||
const detectType = async () => {
|
||||
if (currentType != null || debouncedTarget.length === 0) return;
|
||||
|
||||
const detectedType = await getTypeEasy(debouncedTarget);
|
||||
if (detectedType.isOk) {
|
||||
setUriType(Maybe.just(detectedType.value));
|
||||
} else {
|
||||
setUriType(Maybe.nothing());
|
||||
}
|
||||
};
|
||||
|
||||
detectType().catch(console.error);
|
||||
}, [debouncedTarget, currentType, getTypeEasy]);
|
||||
|
||||
useEffect(() => {
|
||||
const preload = async () => {
|
||||
@@ -67,7 +76,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
};
|
||||
|
||||
preload().catch(console.error);
|
||||
}, [target, uriType, warningHandler]);
|
||||
}, [uriType, warningHandler]);
|
||||
|
||||
async function submitInternal(
|
||||
target: string
|
||||
@@ -160,7 +169,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
case "url": {
|
||||
const response = await fetch(target);
|
||||
|
||||
if (response.status != 200)
|
||||
if (response.status !== 200)
|
||||
return Result.err(
|
||||
new Error(
|
||||
`The URL provided returned a non-200 status code: ${response.status}.`
|
||||
@@ -178,10 +187,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
|
||||
return Result.err(new Error("No schema was able to parse the response."));
|
||||
}
|
||||
case "json": {
|
||||
const data = JSON.parse(target);
|
||||
for (const schema of schemas) {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) return Result.ok({ data: result.data, url: "" });
|
||||
try {
|
||||
const data = JSON.parse(target);
|
||||
for (const schema of schemas) {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) return Result.ok({ data: result.data, url: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
return Result.err(new Error("Invalid JSON format", { cause: e }));
|
||||
}
|
||||
}
|
||||
case "registrar": {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Result } from "true-myth";
|
||||
export async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status == 200) {
|
||||
if (response.status === 200) {
|
||||
const result = schema.safeParse(await response.json());
|
||||
|
||||
if (result.success === false) {
|
||||
|
||||
@@ -25,7 +25,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
|
||||
throw new Error(`No matching domain found.`);
|
||||
case "ip4": {
|
||||
// Extract the IP address without CIDR suffix for matching
|
||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||
const [ipAddress] = lookupTarget.split("/");
|
||||
if (!ipAddress) throw new Error(`Invalid IPv4 format: ${lookupTarget}`);
|
||||
for (const bootstrapItem of bootstrap.services) {
|
||||
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
|
||||
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
|
||||
@@ -37,7 +38,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
|
||||
}
|
||||
case "ip6": {
|
||||
// Extract the IP address without CIDR suffix for matching
|
||||
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
||||
const [ipAddress] = lookupTarget.split("/");
|
||||
if (!ipAddress) throw new Error(`Invalid IPv6 format: ${lookupTarget}`);
|
||||
for (const bootstrapItem of bootstrap.services) {
|
||||
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
|
||||
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
|
||||
|
||||
@@ -11,10 +11,7 @@ export function domainMatch(tld: string, domain: string): boolean {
|
||||
|
||||
// return the first HTTPS url, or the first URL
|
||||
export function getBestURL(urls: [string, ...string[]]): string {
|
||||
urls.forEach((url) => {
|
||||
if (url.startsWith("https://")) return url;
|
||||
});
|
||||
return urls[0];
|
||||
return urls.find((url) => url.startsWith("https://")) ?? urls[0];
|
||||
}
|
||||
|
||||
type ValidatorArgs = {
|
||||
|
||||
Reference in New Issue
Block a user