diff --git a/apps/builder/app/builder/features/topbar/add-domain.tsx b/apps/builder/app/builder/features/topbar/add-domain.tsx index 5b928280454e..e2de69130de0 100644 --- a/apps/builder/app/builder/features/topbar/add-domain.tsx +++ b/apps/builder/app/builder/features/topbar/add-domain.tsx @@ -54,7 +54,7 @@ export const AddDomain = ({ }); // enforce www subdomain when no support for cname flattening // and root cname can conflict with MX or NS - if (!registrar.cnameFlattening) { + if (registrar.known && !registrar.cnameFlattening) { domain = `www.${domain}`; } } diff --git a/apps/builder/app/shared/help.tsx b/apps/builder/app/shared/help.tsx index 665359ffcc83..1fb41fee10f2 100644 --- a/apps/builder/app/shared/help.tsx +++ b/apps/builder/app/shared/help.tsx @@ -6,12 +6,6 @@ import { } from "@webstudio-is/icons"; export const help = [ - { - label: "Support hub", - url: "https://help.webstudio.is/", - icon: , - target: "embed", - }, { label: "Video tutorials", url: "https://wstd.us/101", diff --git a/packages/domain/src/rdap.ts b/packages/domain/src/rdap.ts new file mode 100644 index 000000000000..926079dbea8b --- /dev/null +++ b/packages/domain/src/rdap.ts @@ -0,0 +1,104 @@ +interface DNSList { + description: string; + publication: string; + services: [string[], string[]][]; + version: string; +} + +// Cache map to store TLD (top level domain) → RDAP server URL mappings +const dnsCache = new Map(); + +/** + * Fetch the IANA DNS RDAP bootstrap JSON. + */ +const fetchDnsList = async (): Promise => { + try { + const response = await fetch("https://data.iana.org/rdap/dns.json", { + headers: { + accept: "application/json,application/rdap+json", + }, + }); + if (response.ok) { + return response.json(); + } + } catch { + // empty block + } +}; + +/** + * Find the RDAP server URL for a given top-level domain (TLD). + */ +const findRdapServer = async (topLevelDomain: string) => { + if (dnsCache.size === 0) { + const dns = await fetchDnsList(); + if (dns) { + for (const [domains, [server]] of dns.services) { + for (const domain of domains) { + dnsCache.set(domain, server); + } + } + } + } + return dnsCache.get(topLevelDomain); +}; + +/** + * Extract the top-level domain from a full domain string. + * Unicode is converted to ascii + */ +const getTopLevelDomain = (domain: string) => { + try { + return new URL(`https://${domain}`).hostname.split(".").at(-1); + } catch { + // invalid domain + } +}; + +const fetchRdap = async ( + rdapServer: string, + domain: string +): Promise => { + try { + const response = await fetch(`${rdapServer}domain/${domain}`, { + headers: { + accept: "application/json,application/rdap+json", + }, + }); + if (response.ok) { + return response.text(); + } + } catch { + // empty block + } +}; + +/** + * Determine whether a domain is using Cloudflare nameservers. + * 1. Parse TLD from domain. + * 2. Lookup RDAP server for that TLD. + * 3. Fetch RDAP data for the domain. + * 4. Search the raw response for ".ns.cloudflare.com". + */ +export const isDomainUsingCloudflareNameservers = async (domain: string) => { + const topLevelDomain = getTopLevelDomain(domain); + if (!topLevelDomain) { + throw new Error("Could not parse the top level domain."); + } + + const rdapServer = await findRdapServer(topLevelDomain); + if (!rdapServer) { + console.error( + "RDAP Server for the given top level domain could not be found." + ); + return undefined; + } + + const data = await fetchRdap(rdapServer, domain); + if (data) { + // detect by nameservers rather than registrar url + // sometimes stored as *.NS.CLOUDFLARE.COM + return data.toLowerCase().includes(".ns.cloudflare.com"); + } + return false; +}; diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 8f6bee07b122..2b140ea1d979 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -5,11 +5,7 @@ import { createProductionBuild } from "@webstudio-is/project-build/index.server" import { router, procedure } from "@webstudio-is/trpc-interface/index.server"; import { Templates } from "@webstudio-is/sdk"; import { db } from "../db"; - -const rdap = [ - "https://rdap.cloudflare.com/rdap/v1/domain/", - "https://rdap.namecheap.com/domain/", -]; +import { isDomainUsingCloudflareNameservers } from "../rdap"; export const domainRouter = router({ getEntriToken: procedure.query(async ({ ctx }) => { @@ -32,23 +28,12 @@ export const domainRouter = router({ findDomainRegistrar: procedure .input(z.object({ domain: z.string() })) .query(async ({ input }) => { - try { - for (const rdapEndpoint of rdap) { - const response = await fetch(`${rdapEndpoint}${input.domain}`); - if (response.ok) { - const data = await response.text(); - return { - // detect by nameservers rather than registrar url - cnameFlattening: data.includes(".ns.cloudflare.com"), - }; - } - } - } catch { - // empty block - } + const isCloudflare = await isDomainUsingCloudflareNameservers( + input.domain + ); return { - name: "other", - cnameFlattening: false, + known: isCloudflare !== undefined, + cnameFlattening: isCloudflare === true, }; }),