ci: implement comprehensive CI/CD and workflow automation

- Add GitHub Actions workflows for CI, release, and deployment
- Configure Renovate for automated dependency updates
- Set up Husky pre-commit hooks with lint-staged
- Add commitlint for enforcing Conventional Commits
- Configure semantic-release for automated versioning
- Add Prettier configuration for consistent formatting
- Reformat codebase with new formatting rules
This commit is contained in:
2025-10-22 02:45:58 -05:00
parent 2c1f882cd9
commit c17f733da1
48 changed files with 5453 additions and 2422 deletions

26
.commitlintrc.json Normal file
View File

@@ -0,0 +1,26 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert"
]
],
"subject-case": [2, "never", ["upper-case"]],
"subject-empty": [2, "never"],
"subject-full-stop": [2, "never", "."],
"header-max-length": [2, "always", 100]
}
}

85
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboard",
":semanticCommits",
":automergeDigest",
":automergeMinor"
],
"schedule": ["after 10pm every weekday", "before 5am every weekday", "every weekend"],
"timezone": "America/Chicago",
"prConcurrentLimit": 3,
"prCreation": "not-pending",
"rebaseWhen": "behind-base-branch",
"semanticCommitScope": "deps",
"vulnerabilityAlerts": {
"labels": ["security"],
"automerge": true,
"schedule": ["at any time"]
},
"packageRules": [
{
"description": "Automerge dev dependencies",
"matchDepTypes": ["devDependencies"],
"automerge": true,
"automergeType": "pr",
"minimumReleaseAge": "3 days"
},
{
"description": "Automerge TypeScript type packages",
"matchPackagePatterns": ["^@types/"],
"automerge": true,
"automergeType": "pr"
},
{
"description": "Group ESLint packages together",
"matchPackagePatterns": ["^eslint", "^@typescript-eslint/"],
"groupName": "eslint packages",
"automerge": true
},
{
"description": "Group testing packages together",
"matchPackagePatterns": ["^vitest", "^@vitest/", "^@testing-library/"],
"groupName": "testing packages",
"automerge": true
},
{
"description": "Group Next.js related packages",
"matchPackageNames": ["next", "eslint-config-next"],
"groupName": "Next.js packages",
"minimumReleaseAge": "7 days"
},
{
"description": "Group React packages",
"matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
"groupName": "React packages",
"minimumReleaseAge": "7 days"
},
{
"description": "Pin Node.js major versions",
"matchPackageNames": ["node"],
"enabled": false
},
{
"description": "Group Tailwind CSS packages",
"matchPackagePatterns": [
"^tailwindcss",
"^@tailwindcss/",
"prettier-plugin-tailwindcss"
],
"groupName": "Tailwind CSS packages"
},
{
"description": "Group font packages",
"matchPackagePatterns": ["^@fontsource"],
"groupName": "font packages",
"automerge": true
}
],
"postUpdateOptions": ["pnpmDedupe"],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 5am on monday"]
}
}

165
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,165 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
NODE_VERSION: "20"
PNPM_VERSION: "9.0.0"
jobs:
# Code quality checks
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linting
run: pnpm lint
- name: Type checking
run: pnpm type-check
- name: Check formatting
run: pnpm prettier --check .
# Testing
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm test:run
- name: Run integration tests
run: pnpm test:integration
- name: Upload coverage
uses: codecov/codecov-action@v4
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
# Build verification
build:
name: Build Application
needs: [quality, test]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build application
run: pnpm build
env:
NODE_ENV: production
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: next-build
path: |
.next/
out/
retention-days: 7
if-no-files-found: warn
# Security scanning
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run security audit
run: pnpm audit --audit-level=moderate
continue-on-error: true
- name: Check for known vulnerabilities
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "."
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
exit-code: 0
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: "trivy-results.sarif"

71
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Release
on:
push:
branches:
- master
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Check for skip ci
id: check_skip
run: |
if git log -1 --pretty=%B | grep -q '\[skip ci\]'; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Setup pnpm
if: steps.check_skip.outputs.skip == 'false'
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
if: steps.check_skip.outputs.skip == 'false'
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
if: steps.check_skip.outputs.skip == 'false'
run: pnpm install --frozen-lockfile
- name: Build application
if: steps.check_skip.outputs.skip == 'false'
run: pnpm build
env:
NODE_ENV: production
- name: Run semantic release
if: steps.check_skip.outputs.skip == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm semantic-release
- name: Upload release artifacts
if: steps.check_skip.outputs.skip == 'false'
uses: actions/upload-artifact@v4
with:
name: release-build
path: |
.next/
out/
retention-days: 30

5
.husky/commit-msg Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Validate commit message format
pnpm commitlint --edit "$1"

5
.husky/pre-commit Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run linting on staged files
pnpm lint-staged

6
.lintstagedrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"*.{js,jsx,ts,tsx,mjs}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml,yaml}": ["prettier --write"],
"*.{css,scss}": ["prettier --write"],
"package.json": ["prettier --write"]
}

25
.prettierignore Normal file
View File

@@ -0,0 +1,25 @@
# Dependencies
node_modules
pnpm-lock.yaml
# Build outputs
.next
out
dist
build
# Cache
.cache
.turbo
# Coverage
coverage
# Misc
.DS_Store
*.log
*.tsbuildinfo
# Environment
.env
.env*.local

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 4,
"useTabs": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

109
.releaserc.json Normal file
View File

@@ -0,0 +1,109 @@
{
"branches": [
"master",
{
"name": "beta",
"prerelease": true
},
{
"name": "alpha",
"prerelease": true
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{
"type": "docs",
"scope": "README",
"release": "patch"
},
{
"type": "refactor",
"release": "patch"
},
{
"type": "style",
"release": "patch"
},
{
"type": "perf",
"release": "patch"
}
],
"parserOpts": {
"noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES"]
}
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "refactor",
"section": "Code Refactoring"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"section": "Styles"
},
{
"type": "test",
"section": "Tests"
},
{
"type": "build",
"section": "Build System"
},
{
"type": "ci",
"section": "Continuous Integration"
}
]
}
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}

View File

@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
allConfig: js.configs.all,
});
export default [
@@ -22,7 +22,7 @@ export default [
"out/**",
"*.config.mjs",
"*.config.js",
"next-env.d.ts" // Next.js generated file
"next-env.d.ts", // Next.js generated file
],
},

View File

@@ -6,13 +6,18 @@
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "next start",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run --exclude '**/*.integration.test.ts'",
"test:integration": "vitest run --include '**/*.integration.test.ts'",
"test:all": "vitest run",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"prepare": "husky install",
"semantic-release": "semantic-release"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
@@ -35,6 +40,10 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -44,12 +53,16 @@
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^8.0.0",
"eslint": "^9.38.0",
"eslint-config-next": "15.5.6",
"happy-dom": "^20.0.8",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"postcss": "^8.4.14",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"semantic-release": "^24.0.0",
"tailwindcss": "^4.1.15",
"type-fest": "^5.1.0",
"typescript": "^5.9.3",

2652
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
"@tailwindcss/postcss": {},
},
};

View File

@@ -1,4 +0,0 @@
/** @type {import("prettier").Config} */
module.exports = {
plugins: [require.resolve("prettier-plugin-tailwindcss")],
};

View File

@@ -54,7 +54,10 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
console.error(
`Failed to copy to clipboard (${err.toString()}).`
);
else console.error("Failed to copy to clipboard.");
else
console.error(
"Failed to copy to clipboard."
);
}
);
}}
@@ -88,7 +91,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
) : null}
<div className="max-w-full overflow-x-auto p-2 px-4">
{showRaw ? (
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto whitespace-pre-wrap rounded">
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto rounded whitespace-pre-wrap">
{JSON.stringify(data, null, 4)}
</pre>
) : (

View File

@@ -13,10 +13,7 @@ type DynamicDateProps = {
* @param value The date to be displayed, the Date value, or
* @param absoluteFormat Optional - the date-fns format string to use for the absolute date rendering.
*/
const DynamicDate: FunctionComponent<DynamicDateProps> = ({
value,
absoluteFormat,
}) => {
const DynamicDate: FunctionComponent<DynamicDateProps> = ({ value, absoluteFormat }) => {
const { value: showAbsolute, toggle: toggleFormat } = useBoolean(true);
const date = new Date(value);

View File

@@ -19,7 +19,7 @@ const ErrorCard: FunctionComponent<ErrorCardProps> = ({
<div
className={cn(
className,
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pb-1 pt-3"
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pt-3 pb-1"
)}
>
<div className="flex">

View File

@@ -18,7 +18,7 @@ const Property: FunctionComponent<PropertyProps> = ({
return (
<>
<dt className={cn("font-medium", titleClass)}>{title}:</dt>
<dd className={cn("mb-2 ml-6 mt-2", valueClass)}>{children}</dd>
<dd className={cn("mt-2 mb-2 ml-6", valueClass)}>{children}</dd>
</>
);
};

View File

@@ -43,10 +43,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 }) => Promise<void>;
detectedType: Maybe<TargetType>;
};
@@ -98,9 +95,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
/**
* Represents the selected value in the LookupInput component.
*/
const [selected, setSelected] = useState<SimplifiedTargetType | "auto">(
"auto"
);
const [selected, setSelected] = useState<SimplifiedTargetType | "auto">("auto");
/**
* Retrieves the target type based on the provided value.
@@ -132,10 +127,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
aria-hidden="true"
/>
) : (
<MagnifyingGlassIcon
className="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
)}
</button>
</>
@@ -144,9 +136,9 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
const searchInput = (
<input
className={cn(
"lg:py-4.5 block w-full rounded-l-md border border-transparent",
"bg-zinc-700 py-2 pl-10 pr-1.5 text-sm placeholder-zinc-400 placeholder:translate-y-2 focus:text-zinc-200",
" focus:outline-hidden sm:text-sm md:py-3 md:text-base lg:text-lg"
"block w-full rounded-l-md border border-transparent lg:py-4.5",
"bg-zinc-700 py-2 pr-1.5 pl-10 text-sm placeholder-zinc-400 placeholder:translate-y-2 focus:text-zinc-200",
"focus:outline-hidden sm:text-sm md:py-3 md:text-base lg:text-lg"
)}
disabled={isLoading}
placeholder={placeholders[selected]}
@@ -183,9 +175,9 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
<div className="relative">
<ListboxButton
className={cn(
"relative h-full w-full cursor-default whitespace-nowrap rounded-r-lg bg-zinc-700 py-2 pl-1 pr-10 text-right",
"relative h-full w-full cursor-default rounded-r-lg bg-zinc-700 py-2 pr-10 pl-1 text-right whitespace-nowrap",
"text-xs focus:outline-hidden focus-visible:border-indigo-500 sm:text-sm md:text-base lg:text-lg",
"focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 "
"focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300"
)}
>
{/* Fetch special text for 'auto' mode, otherwise just use the options. */}
@@ -206,7 +198,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
) : (
<>
<LockClosedIcon
className="mb-1 mr-2.5 inline h-4 w-4 animate-pulse text-zinc-500"
className="mr-2.5 mb-1 inline h-4 w-4 animate-pulse text-zinc-500"
aria-hidden
/>
{objectNames[selected]}
@@ -214,10 +206,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
)}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-zinc-200"
aria-hidden="true"
/>
<ChevronUpDownIcon className="h-5 w-5 text-zinc-200" aria-hidden="true" />
</span>
</ListboxButton>
<Transition
@@ -237,7 +226,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
key={key}
className={({ focus }) =>
cn(
"relative cursor-default select-none py-2 pl-10 pr-4",
"relative cursor-default py-2 pr-4 pl-10 select-none",
focus ? "bg-zinc-800 text-zinc-300" : null
)
}
@@ -247,7 +236,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
<>
<span
className={cn(
"block whitespace-nowrap text-right text-xs md:text-sm lg:text-base",
"block text-right text-xs whitespace-nowrap md:text-sm lg:text-base",
selected ? "font-medium" : null
)}
>
@@ -281,11 +270,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
return (
<form
className="pb-3"
onSubmit={
onSubmit != undefined
? onPromise(handleSubmit(onSubmit))
: preventDefault
}
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
>
<div className="col">
<label htmlFor="search" className="sr-only">
@@ -298,10 +283,10 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
</div>
</div>
<div className="col">
<div className="flex flex-wrap pb-1 pt-3 text-sm">
<div className="flex flex-wrap pt-3 pb-1 text-sm">
<div className="whitespace-nowrap">
<input
className="ml-2 mr-1 whitespace-nowrap text-zinc-800 accent-blue-700"
className="mr-1 ml-2 whitespace-nowrap text-zinc-800 accent-blue-700"
type="checkbox"
{...register("requestJSContact")}
/>
@@ -311,7 +296,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
</div>
<div className="whitespace-nowrap">
<input
className="ml-2 mr-1 bg-zinc-500 text-inherit accent-blue-700"
className="mr-1 ml-2 bg-zinc-500 text-inherit accent-blue-700"
type="checkbox"
{...register("followReferral")}
/>

View File

@@ -11,10 +11,7 @@ export type AutnumCardProps = {
url?: string;
};
const AutnumCard: FunctionComponent<AutnumCardProps> = ({
data,
url,
}: AutnumCardProps) => {
const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCardProps) => {
const asnRange =
data.startAutnum === data.endAutnum
? `AS${data.startAutnum}`

View File

@@ -12,10 +12,7 @@ export type DomainProps = {
url?: string;
};
const DomainCard: FunctionComponent<DomainProps> = ({
data,
url,
}: DomainProps) => {
const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps) => {
return (
<AbstractCard
data={data}

View File

@@ -2,31 +2,17 @@ import type { FunctionComponent } from "react";
import DomainCard from "@/components/lookup/DomainCard";
import IPCard from "@/components/lookup/IPCard";
import AutnumCard from "@/components/lookup/AutnumCard";
import type {
Domain,
AutonomousNumber,
Entity,
Nameserver,
IpNetwork,
} from "@/types";
import type { Domain, AutonomousNumber, Entity, Nameserver, IpNetwork } from "@/types";
import AbstractCard from "@/components/common/AbstractCard";
export type ParsedGeneric =
| Domain
| Nameserver
| Entity
| AutonomousNumber
| IpNetwork;
export type ParsedGeneric = Domain | Nameserver | Entity | AutonomousNumber | IpNetwork;
export type ObjectProps = {
data: ParsedGeneric;
url?: string;
};
const Generic: FunctionComponent<ObjectProps> = ({
data,
url,
}: ObjectProps) => {
const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) => {
switch (data.objectClassName) {
case "domain":
return <DomainCard url={url} data={data} />;

View File

@@ -1,22 +1,16 @@
// see https://www.iana.org/assignments/rdap-json-values
import type {
RdapStatusType,
RootRegistryType,
SimplifiedTargetType,
} from "@/types";
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/types";
export const rdapStatusInfo: Record<RdapStatusType, string> = {
validated:
"Signifies that the data of the object instance has been found to be accurate. This type of status is usually found on entity object instances to note the validity of identifying contact information.",
"renew prohibited":
"Renewal or reregistration of the object instance is forbidden.",
"renew prohibited": "Renewal or reregistration of the object instance is forbidden.",
"update prohibited": "Updates to the object instance are forbidden.",
"transfer prohibited":
"Transfers of the registration from one registrar to another are forbidden. This type of status normally applies to DNR domain names.",
"delete prohibited":
"Deletion of the registration of the object instance is forbidden. This type of status normally applies to DNR domain names.",
proxy:
"The registration of the object instance has been performed by a third party. This is most commonly applied to entities.",
proxy: "The registration of the object instance has been performed by a third party. This is most commonly applied to entities.",
private:
"The information of the object instance is not designated for public consumption. This is most commonly applied to entities.",
removed:
@@ -25,11 +19,9 @@ export const rdapStatusInfo: Record<RdapStatusType, string> = {
"Some of the information of the object instance has been altered for the purposes of not readily revealing the actual information of the object instance. This is most commonly applied to entities.",
associated:
"The object instance is associated with other object instances in the registry. This is most commonly used to signify that a nameserver is associated with a domain or that an entity is associated with a network resource or domain.",
active:
"The object instance is in use. For domain names, it signifies that the domain name is published in DNS. For network and autnum registrations it signifies that they are allocated or assigned for use in operational networks. This maps to the Extensible Provisioning Protocol (EPP) [RFC5730] 'OK' status.",
active: "The object instance is in use. For domain names, it signifies that the domain name is published in DNS. For network and autnum registrations it signifies that they are allocated or assigned for use in operational networks. This maps to the Extensible Provisioning Protocol (EPP) [RFC5730] 'OK' status.",
inactive: "The object instance is not in use. See 'active'.",
locked:
"Changes to the object instance cannot be made, including the association of other object instances.",
locked: "Changes to the object instance cannot be made, including the association of other object instances.",
"pending create":
"A request has been received for the creation of the object instance but this action is not yet complete.",
"pending renew":

View File

@@ -94,9 +94,9 @@ describe("ipv6InCIDR", () => {
it("should match IPv6 in /32 network", () => {
expect(ipv6InCIDR("2001:db8::", "2001:db8::/32")).toBe(true);
expect(ipv6InCIDR("2001:db8:1234::", "2001:db8::/32")).toBe(true);
expect(
ipv6InCIDR("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "2001:db8::/32")
).toBe(true);
expect(ipv6InCIDR("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "2001:db8::/32")).toBe(
true
);
});
it("should not match IPv6 outside /32 network", () => {
@@ -105,24 +105,15 @@ describe("ipv6InCIDR", () => {
});
it("should match IPv6 in /64 network", () => {
expect(ipv6InCIDR("2001:db8:1234:5678::", "2001:db8:1234:5678::/64")).toBe(true);
expect(ipv6InCIDR("2001:db8:1234:5678:abcd::", "2001:db8:1234:5678::/64")).toBe(true);
expect(
ipv6InCIDR("2001:db8:1234:5678::", "2001:db8:1234:5678::/64")
).toBe(true);
expect(
ipv6InCIDR("2001:db8:1234:5678:abcd::", "2001:db8:1234:5678::/64")
).toBe(true);
expect(
ipv6InCIDR(
"2001:db8:1234:5678:ffff:ffff:ffff:ffff",
"2001:db8:1234:5678::/64"
)
ipv6InCIDR("2001:db8:1234:5678:ffff:ffff:ffff:ffff", "2001:db8:1234:5678::/64")
).toBe(true);
});
it("should not match IPv6 outside /64 network", () => {
expect(
ipv6InCIDR("2001:db8:1234:5679::", "2001:db8:1234:5678::/64")
).toBe(false);
expect(ipv6InCIDR("2001:db8:1234:5679::", "2001:db8:1234:5678::/64")).toBe(false);
});
it("should match IPv6 in /128 network (single host)", () => {
@@ -154,12 +145,12 @@ describe("ipv6InCIDR", () => {
});
it("should handle expanded vs compressed notation", () => {
expect(
ipv6InCIDR("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::/32")
).toBe(true);
expect(
ipv6InCIDR("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0000/32")
).toBe(true);
expect(ipv6InCIDR("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::/32")).toBe(
true
);
expect(ipv6InCIDR("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0000/32")).toBe(
true
);
});
});

View File

@@ -32,9 +32,7 @@ export function onPromise<T>(promise: (event: SyntheticEvent) => Promise<T>) {
export function truncated(input: string, maxLength: number, ellipsis = "...") {
if (maxLength <= 0) return "";
if (input.length <= maxLength) return input;
return (
input.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis
);
return input.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;
}
export function preventDefault(event: SyntheticEvent | Event) {
@@ -48,8 +46,7 @@ function ipv4ToInt(ip: string): number {
const parts = ip.split(".").map(Number);
if (parts.length !== 4) return 0;
const [a, b, c, d] = parts;
if (a === undefined || b === undefined || c === undefined || d === undefined)
return 0;
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}

View File

@@ -53,9 +53,8 @@ const useLookup = (warningHandler?: WarningHandler) => {
useCallback(async () => {
if (currentType != null) return Maybe.just(currentType);
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(
Maybe.nothing(),
(type) => Maybe.just(type)
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) =>
Maybe.just(type)
);
setUriType(uri);
}, [target, currentType, getTypeEasy]);
@@ -67,15 +66,12 @@ const useLookup = (warningHandler?: WarningHandler) => {
// Fetch the bootstrapping file from the registry
const response = await fetch(registryURLs[type]);
if (response.status != 200)
throw new Error(`Error: ${response.statusText}`);
if (response.status != 200) throw new Error(`Error: ${response.statusText}`);
// Parse it, so we don't make any false assumptions during development & while maintaining the tool.
const parsedRegister = RegisterSchema.safeParse(await response.json());
if (!parsedRegister.success)
throw new Error(
`Could not parse IANA bootstrap response (type: ${type}).`
);
throw new Error(`Could not parse IANA bootstrap response (type: ${type}).`);
// Set it in state so we can use it.
registryDataRef.current = {
@@ -92,21 +88,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
return registry;
}
async function getTypeEasy(
target: string
): Promise<Result<TargetType, Error>> {
async function getTypeEasy(target: string): Promise<Result<TargetType, Error>> {
return getType(target, getRegistry);
}
function getRegistryURL(
type: RootRegistryType,
lookupTarget: string
): string {
function getRegistryURL(type: RootRegistryType, lookupTarget: string): string {
const bootstrap = registryDataRef.current[type];
if (bootstrap == null)
throw new Error(
`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`
);
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
let url: string | null = null;
@@ -198,8 +187,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
await loadBootstrap(registryUri.data);
} catch (e) {
if (warningHandler != undefined) {
const message =
e instanceof Error ? `(${truncated(e.message, 15)})` : ".";
const message = e instanceof Error ? `(${truncated(e.message, 15)})` : ".";
warningHandler({
message: `Failed to preload registry${message}`,
});
@@ -210,10 +198,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
preload().catch(console.error);
}, [target, uriType, warningHandler]);
async function getAndParse<T>(
url: string,
schema: ZodSchema<T>
): Promise<Result<T, Error>> {
async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
const response = await fetch(url);
if (response.status == 200) {
@@ -277,9 +262,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
);
default:
return Result.err(
new Error(
`The registry did not return an OK status code: ${response.status}.`
)
new Error(`The registry did not return an OK status code: ${response.status}.`)
);
}
}
@@ -288,9 +271,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
target: string
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
if (target == null || target.length == 0)
return Result.err(
new Error("A target must be given in order to execute a lookup.")
);
return Result.err(new Error("A target must be given in order to execute a lookup."));
const targetType = await getTypeEasy(target);
@@ -341,10 +322,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
case "autnum": {
await loadBootstrap("autnum");
const url = getRegistryURL(targetType.value, target);
const result = await getAndParse<AutonomousNumber>(
url,
AutonomousNumberSchema
);
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
if (result.isErr) return Result.err(result.error);
return Result.ok({ data: result.value, url });
}
@@ -371,13 +349,10 @@ const useLookup = (warningHandler?: WarningHandler) => {
// Try each schema until one works
for (const schema of schemas) {
const result = schema.safeParse(data);
if (result.success)
return Result.ok({ data: result.data, url: target });
if (result.success) return Result.ok({ data: result.data, url: target });
}
return Result.err(
new Error("No schema was able to parse the response.")
);
return Result.err(new Error("No schema was able to parse the response."));
}
case "json": {
const data = JSON.parse(target);
@@ -389,15 +364,11 @@ const useLookup = (warningHandler?: WarningHandler) => {
case "registrar": {
}
default:
return Result.err(
new Error("The type detected has not been implemented.")
);
return Result.err(new Error("The type detected has not been implemented."));
}
}
async function submit({
target,
}: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
async function submit({ target }: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
try {
// target is already set in state, but it's also provided by the form callback, so we'll use it.
const response = await submitInternal(target);
@@ -415,8 +386,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
})
: Maybe.nothing();
} catch (e) {
if (!(e instanceof Error))
setError("An unknown, unprocessable error has occurred.");
if (!(e instanceof Error)) setError("An unknown, unprocessable error has occurred.");
else setError(e.message);
console.error(e);
return Maybe.nothing();

View File

@@ -11,12 +11,8 @@ import type { TargetType } from "@/types";
const Index: NextPage = () => {
const { error, setTarget, setTargetType, submit, getType } = useLookup();
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(
Maybe.nothing()
);
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(
Maybe.nothing()
);
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(Maybe.nothing());
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
const [isLoading, setLoading] = useState<boolean>(false);
return (
@@ -42,21 +38,15 @@ const Index: NextPage = () => {
/>
</Head>
<nav className="bg-zinc-850 px-5 py-4 shadow-xs">
<span
className="text-xl font-medium text-white"
style={{ fontSize: "larger" }}
>
<span className="text-xl font-medium text-white" style={{ fontSize: "larger" }}>
<a href="https://github.com/Xevion/rdap">rdap</a>
<a
href={"https://xevion.dev"}
className="text-zinc-400 hover:animate-pulse"
>
<a href={"https://xevion.dev"} className="text-zinc-400 hover:animate-pulse">
.xevion.dev
</a>
</span>
</nav>
<div className="mx-auto max-w-screen-sm px-5 lg:max-w-screen-md xl:max-w-screen-lg">
<div className="dark container mx-auto w-full py-6 md:py-12 ">
<div className="dark container mx-auto w-full py-6 md:py-12">
<LookupInput
isLoading={isLoading}
detectedType={detectedType}

View File

@@ -11,8 +11,8 @@ const mockRegistry: Register = {
[
["test@example.com"], // email
["RIPE", "APNIC"], // tags
["https://rdap.example.com/"] // urls
]
["https://rdap.example.com/"], // urls
],
],
};
@@ -240,10 +240,7 @@ describe("getType - URL detection", () => {
describe("getType - JSON detection", () => {
it("should detect JSON objects", async () => {
const result = await getType(
'{"objectClassName":"domain"}',
mockGetRegistry
);
const result = await getType('{"objectClassName":"domain"}', mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("json");

View File

@@ -774,10 +774,7 @@ type ValidatorResult = boolean | string;
* Type validators in priority order (most specific to most generic).
* Order matters: url/json/tld are checked before domain to avoid false matches.
*/
const TypeValidators = new Map<
TargetType,
(args: ValidatorArgs) => Promise<ValidatorResult>
>([
const TypeValidators = new Map<TargetType, (args: ValidatorArgs) => Promise<ValidatorResult>>([
["url", ({ value }) => Promise.resolve(/^https?:/.test(value))],
["json", ({ value }) => Promise.resolve(/^{/.test(value))],
["tld", ({ value }) => Promise.resolve(/^\.\w+$/.test(value))],
@@ -785,9 +782,7 @@ const TypeValidators = new Map<
"ip4",
({ value }) => {
// Basic format check
const match = value.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/
);
const match = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/);
if (!match) return Promise.resolve(false);
// Validate each octet is 0-255
@@ -796,7 +791,7 @@ const TypeValidators = new Map<
const octet = parseInt(octets[i] ?? "", 10);
if (isNaN(octet) || octet < 0 || octet > 255) {
return Promise.resolve(
`Invalid IPv4 address: octet ${i + 1} (${octets[i] ?? 'undefined'}) must be 0-255`
`Invalid IPv4 address: octet ${i + 1} (${octets[i] ?? "undefined"}) must be 0-255`
);
}
}
@@ -805,9 +800,7 @@ const TypeValidators = new Map<
if (match[5]) {
const prefix = parseInt(match[5].substring(1), 10);
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
return Promise.resolve(
"Invalid IPv4 address: CIDR prefix must be 0-32"
);
return Promise.resolve("Invalid IPv4 address: CIDR prefix must be 0-32");
}
}
@@ -825,9 +818,7 @@ const TypeValidators = new Map<
// Check for invalid characters
if (!/^[0-9a-fA-F:]+$/.test(ipPart)) {
return Promise.resolve(
"Invalid IPv6 address: contains invalid characters"
);
return Promise.resolve("Invalid IPv6 address: contains invalid characters");
}
// Validate double :: only appears once
@@ -840,9 +831,7 @@ const TypeValidators = new Map<
if (match[2]) {
const prefix = parseInt(match[2].substring(1), 10);
if (isNaN(prefix) || prefix < 0 || prefix > 128) {
return Promise.resolve(
"Invalid IPv6 address: CIDR prefix must be 0-128"
);
return Promise.resolve("Invalid IPv6 address: CIDR prefix must be 0-128");
}
}
@@ -855,8 +844,7 @@ const TypeValidators = new Map<
async ({ value, getRegistry }) => {
// Ensure the entity handle is in the correct format
const result = value.match(/^\w+-(\w+)$/);
if (result === null || result.length <= 1 || result[1] == undefined)
return false;
if (result === null || result.length <= 1 || result[1] == undefined) return false;
// Check if the entity object tag is real
try {
@@ -876,9 +864,7 @@ const TypeValidators = new Map<
return false;
} catch (e) {
console.error(
new Error("Failed to fetch entity registry", { cause: e })
);
console.error(new Error("Failed to fetch entity registry", { cause: e }));
return false;
}
},

View File

@@ -12,13 +12,7 @@ export const TargetTypeEnum = z.enum([
"json",
]);
export const RootRegistryEnum = z.enum([
"autnum",
"domain",
"ip4",
"ip6",
"entity",
]);
export const RootRegistryEnum = z.enum(["autnum", "domain", "ip4", "ip6", "entity"]);
export const StatusEnum = z.enum([
"validated",

View File

@@ -1,9 +1,13 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter var", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--color-zinc-850: #1D1D20;
--font-sans:
"Inter var", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono:
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-zinc-850: #1d1d20;
}
dd {