From bc335d15c05a7bce8ece4b581aacd98053f59682 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 29 Aug 2025 01:12:12 +0530 Subject: [PATCH 1/5] preserve subdomain with sanitizeSubdomain and validateSubdomain --- src/components/DomainPicker.tsx | 124 +++++++++++++++++++------------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 1fc856c9..495f94f5 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -278,12 +278,47 @@ export default function DomainPicker2({ return false; }; + const sanitizeSubdomain = (input: string): string => { + return input + .toLowerCase() + .replace(/[^a-z0-9.-]/g, "") + .replace(/\.{2,}/g, ".") // collapse multiple dots → single dot + .replace(/^-+|-+$/g, "") // trim leading/trailing hyphens + .replace(/^\.+|\.+$/g, ""); // trim leading/trailing dots + }; + + // Handle base domain selection const handleBaseDomainSelect = (option: DomainOption) => { + let sub = subdomainInput; + + if (sub) { + const sanitized = sanitizeSubdomain(sub); + if (sub !== sanitized) { + toast({ + title: "Invalid subdomain", + description: `"${sub}" was corrected to "${sanitized}"`, + }); + sub = sanitized; + setSubdomainInput(sanitized); + } + + if (!validateSubdomain(sub, option)) { + toast({ + variant: "destructive", + title: "Invalid subdomain", + description: `"${sub}" cannot be used with ${option.domain}`, + }); + sub = ""; + setSubdomainInput(""); + } + } + setSelectedBaseDomain(option); setOpen(false); if (option.domainType === "cname") { + sub = ""; setSubdomainInput(""); } @@ -291,61 +326,43 @@ export default function DomainPicker2({ setUserInput(""); setAvailableOptions([]); setSelectedProvidedDomain(null); - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain + } + + const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; + + onDomainChange?.({ + domainId: option.domainId || "", + type: option.type === "provided-search" ? "provided" : "organization", + subdomain: sub || undefined, + fullDomain, + baseDomain: option.domain + }); + }; + + + const handleSubdomainChange = (value: string) => { + const sanitized = sanitizeSubdomain(value); + setSubdomainInput(sanitized); + + if (value !== sanitized) { + toast({ + title: "Invalid characters removed", + description: `"${value}" was corrected to "${sanitized}"`, }); } - if (option.type === "organization") { - if (option.domainType === "cname") { - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain - }); - } else { - onDomainChange?.({ - domainId: option.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: option.domain, - baseDomain: option.domain - }); - } - } - }; - - const handleSubdomainChange = (value: string) => { - const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); - setSubdomainInput(validInput); - - setSelectedProvidedDomain(null); - - if (selectedBaseDomain && selectedBaseDomain.type === "organization") { - const isValid = validateSubdomain(validInput, selectedBaseDomain); - if (isValid) { - const fullDomain = validInput - ? `${validInput}.${selectedBaseDomain.domain}` + if (selectedBaseDomain?.type === "organization") { + const isValid = validateSubdomain(sanitized, selectedBaseDomain); + if (isValid || sanitized === "") { + const fullDomain = sanitized + ? `${sanitized}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; + onDomainChange?.({ domainId: selectedBaseDomain.domainId!, type: "organization", - subdomain: validInput || undefined, - fullDomain: fullDomain, - baseDomain: selectedBaseDomain.domain - }); - } else if (validInput === "") { - onDomainChange?.({ - domainId: selectedBaseDomain.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: selectedBaseDomain.domain, + subdomain: sanitized || undefined, + fullDomain, baseDomain: selectedBaseDomain.domain }); } @@ -353,8 +370,15 @@ export default function DomainPicker2({ }; const handleProvidedDomainInputChange = (value: string) => { - const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); - setUserInput(validInput); + const sanitized = sanitizeSubdomain(value); + setUserInput(sanitized); + + if (value !== sanitized) { + toast({ + title: "Invalid characters removed", + description: `"${value}" was corrected to "${sanitized}"`, + }); + } // Clear selected domain when user types if (selectedProvidedDomain) { From 18bb6caf8fa7df048090d82a07686214ce36c589 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 29 Aug 2025 02:44:39 +0530 Subject: [PATCH 2/5] allows typing flow while providing helpful validation --- src/components/DomainPicker.tsx | 83 +++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 495f94f5..35d58fae 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -262,16 +262,21 @@ export default function DomainPicker2({ if (!baseDomain) return false; if (baseDomain.type === "provided-search") { - return /^[a-zA-Z0-9-]+$/.test(subdomain); + return subdomain === "" || ( + /^[a-zA-Z0-9.-]+$/.test(subdomain) && + isValidSubdomainStructure(subdomain) + ); } if (baseDomain.type === "organization") { if (baseDomain.domainType === "cname") { return subdomain === ""; - } else if (baseDomain.domainType === "ns") { - return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); - } else if (baseDomain.domainType === "wildcard") { - return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); + } else if (baseDomain.domainType === "ns" || baseDomain.domainType === "wildcard") { + // NS and wildcard domains support multi-level subdomains with dots and hyphens + return subdomain === "" || ( + /^[a-zA-Z0-9.-]+$/.test(subdomain) && + isValidSubdomainStructure(subdomain) + ); } } @@ -279,14 +284,30 @@ export default function DomainPicker2({ }; const sanitizeSubdomain = (input: string): string => { + if (!input) return ""; return input .toLowerCase() - .replace(/[^a-z0-9.-]/g, "") - .replace(/\.{2,}/g, ".") // collapse multiple dots → single dot - .replace(/^-+|-+$/g, "") // trim leading/trailing hyphens - .replace(/^\.+|\.+$/g, ""); // trim leading/trailing dots + .replace(/[^a-z0-9.-]/g, ""); }; + const isValidSubdomainStructure = (subdomain: string): boolean => { + if (!subdomain) return true; + + // check for consecutive dots or hyphens + if (/\.{2,}|-{2,}/.test(subdomain)) return false; + + // check if starts or ends with hyphen or dot + if (/^[-.]|[-.]$/.test(subdomain)) return false; + + // check each label >> (part between dots) + const parts = subdomain.split("."); + for (const part of parts) { + if (!part) return false; // Empty label + if (/^-|-$/.test(part)) return false; // Label starts/ends with hyphen + } + + return true; + }; // Handle base domain selection const handleBaseDomainSelect = (option: DomainOption) => { @@ -303,11 +324,11 @@ export default function DomainPicker2({ setSubdomainInput(sanitized); } - if (!validateSubdomain(sub, option)) { + if (sanitized && !validateSubdomain(sanitized, option)) { toast({ variant: "destructive", title: "Invalid subdomain", - description: `"${sub}" cannot be used with ${option.domain}`, + description: `"${sanitized}" is not valid for ${option.domain}. Subdomain labels cannot start or end with hyphens.`, }); sub = ""; setSubdomainInput(""); @@ -344,28 +365,27 @@ export default function DomainPicker2({ const sanitized = sanitizeSubdomain(value); setSubdomainInput(sanitized); + // Only show toast for truly invalid characters, not structure issues if (value !== sanitized) { toast({ title: "Invalid characters removed", - description: `"${value}" was corrected to "${sanitized}"`, + description: `Only letters, numbers, hyphens, and dots are allowed`, }); } if (selectedBaseDomain?.type === "organization") { - const isValid = validateSubdomain(sanitized, selectedBaseDomain); - if (isValid || sanitized === "") { - const fullDomain = sanitized - ? `${sanitized}.${selectedBaseDomain.domain}` - : selectedBaseDomain.domain; + // Always update the domain, validation will show visual feedback + const fullDomain = sanitized + ? `${sanitized}.${selectedBaseDomain.domain}` + : selectedBaseDomain.domain; - onDomainChange?.({ - domainId: selectedBaseDomain.domainId!, - type: "organization", - subdomain: sanitized || undefined, - fullDomain, - baseDomain: selectedBaseDomain.domain - }); - } + onDomainChange?.({ + domainId: selectedBaseDomain.domainId!, + type: "organization", + subdomain: sanitized || undefined, + fullDomain, + baseDomain: selectedBaseDomain.domain + }); } }; @@ -376,7 +396,7 @@ export default function DomainPicker2({ if (value !== sanitized) { toast({ title: "Invalid characters removed", - description: `"${value}" was corrected to "${sanitized}"`, + description: `Only letters, numbers, hyphens, and dots are allowed`, }); } @@ -410,7 +430,7 @@ export default function DomainPicker2({ }); }; - const isSubdomainValid = selectedBaseDomain + const isSubdomainValid = selectedBaseDomain && subdomainInput ? validateSubdomain(subdomainInput, selectedBaseDomain) : true; const showSubdomainInput = @@ -458,8 +478,8 @@ export default function DomainPicker2({ } className={cn( !isSubdomainValid && - subdomainInput && - "border-red-500" + subdomainInput && + "border-red-500 focus:border-red-500" )} onChange={(e) => { if (showProvidedDomainSearch) { @@ -469,6 +489,11 @@ export default function DomainPicker2({ } }} /> + {showSubdomainInput && subdomainInput && !isSubdomainValid && ( +

+ Invalid format. Subdomain cannot start/end with hyphens or dots, and cannot have consecutive dots or hyphens. +

+ )} {showSubdomainInput && !subdomainInput && (

{t("domainPickerEnterSubdomainOrLeaveBlank")} From e8a6efd079877e96ee954624b63743a97405de0c Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 29 Aug 2025 03:58:49 +0530 Subject: [PATCH 3/5] subdomain validation consistent --- server/lib/schemas.ts | 12 +++++++++--- src/components/DomainPicker.tsx | 23 ++++++++--------------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index cf1b40c8..f1aa6c9d 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -1,18 +1,24 @@ import { z } from "zod"; + export const subdomainSchema = z .string() .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "Invalid subdomain format" ) .min(1, "Subdomain must be at least 1 character long") + .max(63, "Subdomain must not exceed 63 characters") .transform((val) => val.toLowerCase()); export const tlsNameSchema = z .string() .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, + /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/, "Invalid subdomain format" - ) + ).max(253, "Domain must not exceed 253 characters") + .refine((val) => { + const labels = val.split('.'); + return labels.every((label) => label.length <= 63); + }, "Each part of the domain must not exceed 63 characters") .transform((val) => val.toLowerCase()); \ No newline at end of file diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 35d58fae..c9c3b921 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -263,7 +263,7 @@ export default function DomainPicker2({ if (baseDomain.type === "provided-search") { return subdomain === "" || ( - /^[a-zA-Z0-9.-]+$/.test(subdomain) && + /^[a-zA-Z0-9-]+$/.test(subdomain) && isValidSubdomainStructure(subdomain) ); } @@ -274,7 +274,7 @@ export default function DomainPicker2({ } else if (baseDomain.domainType === "ns" || baseDomain.domainType === "wildcard") { // NS and wildcard domains support multi-level subdomains with dots and hyphens return subdomain === "" || ( - /^[a-zA-Z0-9.-]+$/.test(subdomain) && + /^[a-zA-Z0-9-]+$/.test(subdomain) && isValidSubdomainStructure(subdomain) ); } @@ -287,24 +287,17 @@ export default function DomainPicker2({ if (!input) return ""; return input .toLowerCase() - .replace(/[^a-z0-9.-]/g, ""); + .replace(/[^a-z0-9-]/g, ""); }; const isValidSubdomainStructure = (subdomain: string): boolean => { if (!subdomain) return true; - // check for consecutive dots or hyphens - if (/\.{2,}|-{2,}/.test(subdomain)) return false; + // Check for consecutive hyphens + if (/--/.test(subdomain)) return false; - // check if starts or ends with hyphen or dot - if (/^[-.]|[-.]$/.test(subdomain)) return false; - - // check each label >> (part between dots) - const parts = subdomain.split("."); - for (const part of parts) { - if (!part) return false; // Empty label - if (/^-|-$/.test(part)) return false; // Label starts/ends with hyphen - } + // Check if starts or ends with hyphen + if (/^-|-$/.test(subdomain)) return false; return true; }; @@ -369,7 +362,7 @@ export default function DomainPicker2({ if (value !== sanitized) { toast({ title: "Invalid characters removed", - description: `Only letters, numbers, hyphens, and dots are allowed`, + description: `Only letters, numbers, and hyphens are allowed`, }); } From 54764dfacd8fd5e691608fe85baa06b6098d5e2b Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sat, 30 Aug 2025 01:14:03 +0530 Subject: [PATCH 4/5] unify subdomain validation schema to handle edge cases --- server/lib/schemas.ts | 15 +- .../resources/[resourceId]/general/page.tsx | 32 ++- src/components/DomainPicker.tsx | 216 ++++++++---------- src/lib/subdomain-utils.ts | 59 +++++ 4 files changed, 174 insertions(+), 148 deletions(-) create mode 100644 src/lib/subdomain-utils.ts diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index f1aa6c9d..0888ff31 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -1,24 +1,19 @@ import { z } from "zod"; - export const subdomainSchema = z .string() .regex( - /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, + /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, "Invalid subdomain format" ) .min(1, "Subdomain must be at least 1 character long") - .max(63, "Subdomain must not exceed 63 characters") .transform((val) => val.toLowerCase()); export const tlsNameSchema = z .string() .regex( - /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/, + /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/, "Invalid subdomain format" - ).max(253, "Domain must not exceed 253 characters") - .refine((val) => { - const labels = val.split('.'); - return labels.every((label) => label.length <= 63); - }, "Each part of the domain must not exceed 63 characters") - .transform((val) => val.toLowerCase()); \ No newline at end of file + ) + .transform((val) => val.toLowerCase()); + diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 8c5ee667..ef70abad 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -53,6 +53,7 @@ import { import DomainPicker from "@app/components/DomainPicker"; import { Globe } from "lucide-react"; import { build } from "@server/build"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -454,18 +455,27 @@ export default function GeneralForm() {