fix errors and onboarding problems (#242)
This commit is contained in:
parent
cc5d6bc632
commit
1495660631
@ -4,6 +4,7 @@ import type {
|
|||||||
PageRow,
|
PageRow,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from "@/client/features/domain/types";
|
} from "@/client/features/domain/types";
|
||||||
|
import { isValidDomainHost } from "@/types/schemas/domain";
|
||||||
|
|
||||||
export function toSortMode(value: string | null): DomainSortMode | undefined {
|
export function toSortMode(value: string | null): DomainSortMode | undefined {
|
||||||
if (
|
if (
|
||||||
@ -67,6 +68,7 @@ export function normalizeDomainTarget(input: string): string | null {
|
|||||||
const hostname = parsed.hostname.toLowerCase();
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
if (!hostname || !hostname.includes(".")) return null;
|
if (!hostname || !hostname.includes(".")) return null;
|
||||||
if (!/^[a-z\d.-]+$/.test(hostname)) return null;
|
if (!/^[a-z\d.-]+$/.test(hostname)) return null;
|
||||||
|
if (!isValidDomainHost(hostname)) return null;
|
||||||
|
|
||||||
const path = parsed.pathname === "/" ? "" : parsed.pathname;
|
const path = parsed.pathname === "/" ? "" : parsed.pathname;
|
||||||
return `${hostname}${path}`;
|
return `${hostname}${path}`;
|
||||||
|
|||||||
@ -23,6 +23,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
|||||||
AI_SEARCH_BILLING_ISSUE:
|
AI_SEARCH_BILLING_ISSUE:
|
||||||
"The connected DataForSEO account has a billing or balance issue.",
|
"The connected DataForSEO account has a billing or balance issue.",
|
||||||
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
RATE_LIMITED: "Too many requests. Please wait and try again.",
|
||||||
|
UPSTREAM_UNAVAILABLE:
|
||||||
|
"The data provider is temporarily unavailable. Please retry in a moment.",
|
||||||
CONFLICT: "This request conflicts with existing data.",
|
CONFLICT: "This request conflicts with existing data.",
|
||||||
INTERNAL_ERROR:
|
INTERNAL_ERROR:
|
||||||
"An unexpected error occurred. Please check server logs and try again.",
|
"An unexpected error occurred. Please check server logs and try again.",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
@ -11,7 +10,7 @@ import {
|
|||||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getSignInSearch } from "@/lib/auth-redirect";
|
import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const signInSchema = z.object({
|
const signInSchema = z.object({
|
||||||
@ -26,14 +25,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
|
|||||||
|
|
||||||
function SignInPage() {
|
function SignInPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
|
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
|
||||||
search.redirect,
|
search.redirect,
|
||||||
);
|
);
|
||||||
const authCallbackURL = redirectTo;
|
const authCallbackURL = redirectTo;
|
||||||
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
|
||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||||
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
||||||
const [socialError, setSocialError] = useState<string | null>(null);
|
const [socialError, setSocialError] = useState<string | null>(null);
|
||||||
@ -52,7 +48,6 @@ function SignInPage() {
|
|||||||
captureClientEvent("auth:sign_in_submit", {
|
captureClientEvent("auth:sign_in_submit", {
|
||||||
redirect_to: redirectTo,
|
redirect_to: redirectTo,
|
||||||
});
|
});
|
||||||
setVerificationEmail(null);
|
|
||||||
|
|
||||||
const result = await authClient.signIn.email({
|
const result = await authClient.signIn.email({
|
||||||
email,
|
email,
|
||||||
@ -72,12 +67,12 @@ function SignInPage() {
|
|||||||
captureClientEvent("auth:sign_in_block_unverified", {
|
captureClientEvent("auth:sign_in_block_unverified", {
|
||||||
redirect_to: redirectTo,
|
redirect_to: redirectTo,
|
||||||
});
|
});
|
||||||
setVerificationEmail(email);
|
// Email not verified yet: send them to the verification page (which
|
||||||
formApi.setErrorMap({
|
// shows "check your inbox" + resend) instead of leaving them on a
|
||||||
onSubmit: {
|
// sign-in form that will keep rejecting them.
|
||||||
form: "Please confirm your email before signing in.",
|
void navigate({
|
||||||
fields: {},
|
to: "/verify-email",
|
||||||
},
|
search: getVerifyEmailSearch(email, redirectTo),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -99,42 +94,6 @@ function SignInPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleResendVerification() {
|
|
||||||
if (!verificationEmail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSendingVerification(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verificationCallbackURL = new URL(
|
|
||||||
"/verify-email",
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
if (authCallbackURL !== "/") {
|
|
||||||
verificationCallbackURL.searchParams.set("redirect", authCallbackURL);
|
|
||||||
}
|
|
||||||
const result = await authClient.sendVerificationEmail({
|
|
||||||
email: verificationEmail,
|
|
||||||
callbackURL: verificationCallbackURL.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message || "We couldn't send another email.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
captureClientEvent("auth:verification_resend");
|
|
||||||
toast.success("A new email is on the way.");
|
|
||||||
} catch {
|
|
||||||
toast.error(
|
|
||||||
"We couldn't send another email right now. Please try again.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSendingVerification(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleContinueWithGoogle() {
|
async function handleContinueWithGoogle() {
|
||||||
setSocialError(null);
|
setSocialError(null);
|
||||||
setIsStartingGoogle(true);
|
setIsStartingGoogle(true);
|
||||||
@ -266,29 +225,6 @@ function SignInPage() {
|
|||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
{verificationEmail ? (
|
|
||||||
<div className="alert alert-warning items-start">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm">
|
|
||||||
Please check {verificationEmail} for a link to confirm your
|
|
||||||
email.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm btn-outline"
|
|
||||||
onClick={() => {
|
|
||||||
void handleResendVerification();
|
|
||||||
}}
|
|
||||||
disabled={isSendingVerification}
|
|
||||||
>
|
|
||||||
{isSendingVerification
|
|
||||||
? "Sending email..."
|
|
||||||
: "Send another email"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
submitError: state.errorMap.onSubmit,
|
submitError: state.errorMap.onSubmit,
|
||||||
|
|||||||
@ -73,10 +73,12 @@ function getVerifyEmailPageCopy({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWaiting && email) {
|
if (isWaiting) {
|
||||||
return {
|
return {
|
||||||
title: "Verify your email",
|
title: "Verify your email",
|
||||||
helperText: `Click the link we sent to ${email} to verify your email.`,
|
helperText: email
|
||||||
|
? `Click the link we sent to ${email} to verify your email.`
|
||||||
|
: "Check your inbox for the link to verify your email.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,8 +97,8 @@ function getVerifyEmailPageCopy({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Email confirmed",
|
title: "Sign in to continue",
|
||||||
helperText: "Your email is confirmed. You can sign in now.",
|
helperText: "Sign in to continue to your account.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,14 +112,18 @@ function VerifyEmailPage() {
|
|||||||
const verificationIssueType = search.error
|
const verificationIssueType = search.error
|
||||||
? verificationIssueSchema.parse(search.error)
|
? verificationIssueSchema.parse(search.error)
|
||||||
: null;
|
: null;
|
||||||
const email = search.email;
|
const email = search.email ?? session?.user?.email;
|
||||||
|
const isVerified = !!session?.user?.emailVerified;
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
// A hosted user who still needs to verify (session resolved, not verified)
|
||||||
|
// must see the resend / "check your inbox" state — never a sign-in CTA, which
|
||||||
|
// the verification gate would immediately block (the email-verify trap).
|
||||||
const isWaiting =
|
const isWaiting =
|
||||||
|
isHostedMode &&
|
||||||
!errorMessage &&
|
!errorMessage &&
|
||||||
!bypassEmailVerification &&
|
!bypassEmailVerification &&
|
||||||
!session?.user?.emailVerified &&
|
!isPending &&
|
||||||
!!email;
|
!isVerified;
|
||||||
const [isResending, setIsResending] = useState(false);
|
|
||||||
const isVerified = !!session?.user?.emailVerified;
|
|
||||||
const pageCopy = getVerifyEmailPageCopy({
|
const pageCopy = getVerifyEmailPageCopy({
|
||||||
isHostedMode,
|
isHostedMode,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@ -222,16 +228,18 @@ function VerifyEmailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : isWaiting ? (
|
) : isWaiting ? (
|
||||||
<div className="space-y-4">
|
email ? (
|
||||||
<button
|
<div className="space-y-4">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-soft w-full"
|
type="button"
|
||||||
onClick={() => void handleResend()}
|
className="btn btn-soft w-full"
|
||||||
disabled={isResending}
|
onClick={() => void handleResend()}
|
||||||
>
|
disabled={isResending}
|
||||||
{isResending ? "Sending email..." : "Resend email"}
|
>
|
||||||
</button>
|
{isResending ? "Sending email..." : "Resend email"}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
) : isPending || isVerified ? (
|
) : isPending || isVerified ? (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<span className="loading loading-spinner loading-md" />
|
<span className="loading loading-spinner loading-md" />
|
||||||
|
|||||||
@ -109,7 +109,7 @@ async function getSuggestedKeywords(
|
|||||||
keywordDifficulty: number | null;
|
keywordDifficulty: number | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const domain = input.domain.toLowerCase().trim();
|
const domain = normalizeDomainInput(input.domain, true);
|
||||||
|
|
||||||
const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
|
const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
|
||||||
organizationId: billingCustomer.organizationId,
|
organizationId: billingCustomer.organizationId,
|
||||||
|
|||||||
@ -8,11 +8,16 @@ import {
|
|||||||
} from "dataforseo-client";
|
} from "dataforseo-client";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
import type { ErrorCode } from "@/shared/error-codes";
|
||||||
|
|
||||||
const API_BASE = "https://api.dataforseo.com";
|
const API_BASE = "https://api.dataforseo.com";
|
||||||
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
|
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
|
||||||
// Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds).
|
// Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds).
|
||||||
const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000;
|
const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
// Retry idempotent reads on transient 5xx. Total attempts = retries + 1; the
|
||||||
|
// shared request-timeout signal still caps overall wall time.
|
||||||
|
const DATAFORSEO_MAX_RETRIES = 2;
|
||||||
|
const DATAFORSEO_RETRY_BACKOFF_MS = 250;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates a DataForSEO HTTP/task failure into a product-specific AppError
|
* Translates a DataForSEO HTTP/task failure into a product-specific AppError
|
||||||
@ -63,32 +68,47 @@ function createAuthenticatedFetch(classify?: DataforseoErrorClassifier) {
|
|||||||
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
headers.set("Authorization", `Basic ${apiKey}`);
|
headers.set("Authorization", `Basic ${apiKey}`);
|
||||||
|
// Resolve the signal once so retries share the overall request timeout
|
||||||
|
// rather than restarting a fresh 60s budget on each attempt.
|
||||||
|
const signal =
|
||||||
|
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
for (let attempt = 0; ; attempt++) {
|
||||||
...init,
|
const response = await fetch(url, { ...init, headers, signal });
|
||||||
headers,
|
if (response.ok) return response;
|
||||||
signal:
|
|
||||||
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (response.ok) return response;
|
|
||||||
|
|
||||||
const rawText = await response.text();
|
// Transient upstream 5xx on an idempotent read -> back off and retry.
|
||||||
const path = formatDataforseoRequestPath(url);
|
if (response.status >= 500 && attempt < DATAFORSEO_MAX_RETRIES) {
|
||||||
const classified = classify?.(response.status, rawText, path);
|
await new Promise((resolve) =>
|
||||||
if (classified) throw classified;
|
setTimeout(resolve, DATAFORSEO_RETRY_BACKOFF_MS * (attempt + 1)),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const error = new AppError(
|
const rawText = await response.text();
|
||||||
response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR",
|
const path = formatDataforseoRequestPath(url);
|
||||||
`DataForSEO HTTP ${response.status} on ${path}`,
|
const classified = classify?.(response.status, rawText, path);
|
||||||
{
|
if (classified) throw classified;
|
||||||
provider: "dataforseo",
|
|
||||||
providerStatus: String(response.status),
|
const code: ErrorCode =
|
||||||
providerPath: path,
|
response.status >= 500
|
||||||
responseBody: formatDataforseoErrorPayload(rawText),
|
? "UPSTREAM_UNAVAILABLE"
|
||||||
},
|
: response.status === 429
|
||||||
);
|
? "RATE_LIMITED"
|
||||||
error.name = "DataForSEOHttpError";
|
: "INTERNAL_ERROR";
|
||||||
throw error;
|
const error = new AppError(
|
||||||
|
code,
|
||||||
|
`DataForSEO HTTP ${response.status} on ${path}`,
|
||||||
|
{
|
||||||
|
provider: "dataforseo",
|
||||||
|
providerStatus: String(response.status),
|
||||||
|
providerPath: path,
|
||||||
|
responseBody: formatDataforseoErrorPayload(rawText),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
error.name = "DataForSEOHttpError";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
src/server/lib/domainUtils.test.ts
Normal file
42
src/server/lib/domainUtils.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeDomainInput } from "@/server/lib/domainUtils";
|
||||||
|
import { isValidDomainHost } from "@/types/schemas/domain";
|
||||||
|
|
||||||
|
describe("isValidDomainHost", () => {
|
||||||
|
it("accepts real registrable domains", () => {
|
||||||
|
expect(isValidDomainHost("example.com")).toBe(true);
|
||||||
|
expect(isValidDomainHost("sub.example.co.uk")).toBe(true);
|
||||||
|
expect(isValidDomainHost("openseo.so")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects fake TLDs, IPs, and bare hosts", () => {
|
||||||
|
expect(isValidDomainHost("example.por")).toBe(false);
|
||||||
|
expect(isValidDomainHost("localhost")).toBe(false);
|
||||||
|
expect(isValidDomainHost("127.0.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeDomainInput", () => {
|
||||||
|
it("normalizes a valid domain, stripping protocol/www/path", () => {
|
||||||
|
expect(
|
||||||
|
normalizeDomainInput("https://www.Example.com/path?q=1", false),
|
||||||
|
).toBe("example.com");
|
||||||
|
expect(normalizeDomainInput("blog.example.com", true)).toBe(
|
||||||
|
"blog.example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fake TLD before it can reach DataForSEO", () => {
|
||||||
|
expect(() => normalizeDomainInput("victorgomez.por", false)).toThrowError(
|
||||||
|
/valid domain/i,
|
||||||
|
);
|
||||||
|
// Validation must also run on the includeSubdomains=true path.
|
||||||
|
expect(() => normalizeDomainInput("victorgomez.por", true)).toThrowError(
|
||||||
|
/valid domain/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty input", () => {
|
||||||
|
expect(() => normalizeDomainInput(" ", false)).toThrowError(/required/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { getDomain } from "tldts";
|
import { getDomain } from "tldts";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { isValidDomainHost } from "@/types/schemas/domain";
|
||||||
|
|
||||||
export function toRelativePath(url: string | null | undefined): string | null {
|
export function toRelativePath(url: string | null | undefined): string | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@ -36,6 +37,15 @@ export function normalizeDomainInput(
|
|||||||
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
|
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject fake TLDs / non-registrable hosts (e.g. "example.por") before they
|
||||||
|
// reach DataForSEO and come back as an opaque "Invalid Field: 'target'".
|
||||||
|
if (!isValidDomainHost(host)) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"Enter a valid domain like example.com",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (includeSubdomains) {
|
if (includeSubdomains) {
|
||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const ERROR_CODES = [
|
|||||||
"AI_SEARCH_NOT_ENABLED",
|
"AI_SEARCH_NOT_ENABLED",
|
||||||
"AI_SEARCH_BILLING_ISSUE",
|
"AI_SEARCH_BILLING_ISSUE",
|
||||||
"RATE_LIMITED",
|
"RATE_LIMITED",
|
||||||
|
"UPSTREAM_UNAVAILABLE",
|
||||||
"CONFLICT",
|
"CONFLICT",
|
||||||
"INTERNAL_ERROR",
|
"INTERNAL_ERROR",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { parse as parseTld } from "tldts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -12,6 +13,19 @@ export function normalizeDomain(input: string): string {
|
|||||||
return hostname.replace(/^www\./, "");
|
return hostname.replace(/^www\./, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `host` resolves to a real registrable domain (public-suffix list),
|
||||||
|
* rejecting IPs and fake TLDs like `example.por` before they reach DataForSEO.
|
||||||
|
*/
|
||||||
|
export function isValidDomainHost(host: string): boolean {
|
||||||
|
const parsed = parseTld(host, { allowPrivateDomains: true });
|
||||||
|
return (
|
||||||
|
!parsed.isIp &&
|
||||||
|
!!parsed.publicSuffix &&
|
||||||
|
(parsed.isIcann === true || parsed.isPrivate === true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */
|
/** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */
|
||||||
export const domainField = z
|
export const domainField = z
|
||||||
.string()
|
.string()
|
||||||
@ -20,13 +34,19 @@ export const domainField = z
|
|||||||
.transform((val, ctx) => {
|
.transform((val, ctx) => {
|
||||||
try {
|
try {
|
||||||
const hostname = normalizeDomain(val);
|
const hostname = normalizeDomain(val);
|
||||||
if (!hostname.includes(".")) {
|
if (!hostname.includes(".") || !isValidDomainHost(hostname)) {
|
||||||
ctx.addIssue({ code: "custom", message: "Invalid domain format" });
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Enter a valid domain like example.com",
|
||||||
|
});
|
||||||
return z.NEVER;
|
return z.NEVER;
|
||||||
}
|
}
|
||||||
return hostname;
|
return hostname;
|
||||||
} catch {
|
} catch {
|
||||||
ctx.addIssue({ code: "custom", message: "Invalid domain format" });
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Enter a valid domain like example.com",
|
||||||
|
});
|
||||||
return z.NEVER;
|
return z.NEVER;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user