diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 37c5c363..ce8f29a7 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -55,7 +55,7 @@ import { Globe } from "lucide-react"; import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { DomainRow } from "../../../domains/DomainsTable"; -import { toUnicode } from "punycode"; +import { toASCII, toUnicode } from "punycode"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -82,7 +82,7 @@ export default function GeneralForm() { const [loadingPage, setLoadingPage] = useState(true); const [resourceFullDomain, setResourceFullDomain] = useState( - `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); const [selectedDomain, setSelectedDomain] = useState<{ domainId: string; @@ -186,7 +186,7 @@ export default function GeneralForm() { { enabled: data.enabled, name: data.name, - subdomain: data.subdomain, + subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, domainId: data.domainId, proxyPort: data.proxyPort, // ...(!resource.http && { @@ -478,7 +478,6 @@ export default function GeneralForm() { setEditDomainOpen(false); toast({ - title: "Domain sanitized", description: `Final domain: ${sanitizedFullDomain}`, }); } diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 6436ac15..782b3135 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -89,7 +89,7 @@ import { isTargetValid } from "@server/lib/validators"; import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; import { parseHostTarget } from "@app/lib/parseHostTarget"; -import { toUnicode } from 'punycode'; +import { toASCII, toUnicode } from 'punycode'; import { DomainRow } from "../../domains/DomainsTable"; const baseResourceFormSchema = z.object({ @@ -329,7 +329,7 @@ export default function Page() { if (isHttp) { const httpData = httpForm.getValues(); Object.assign(payload, { - subdomain: httpData.subdomain, + subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined, domainId: httpData.domainId, protocol: "tcp" }); diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 572ff913..f00292ee 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -336,8 +336,13 @@ export default function DomainPicker2({ const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; - sub = finalizeSubdomain(sub, option); - setSubdomainInput(sub); + if (sub && sub.trim() !== "") { + sub = finalizeSubdomain(sub, option) || ""; + setSubdomainInput(sub); + } else { + sub = ""; + setSubdomainInput(""); + } if (option.type === "provided-search") { setUserInput(""); @@ -409,11 +414,6 @@ export default function DomainPicker2({ sortedAvailableOptions.length > providedDomainsShown; - const isValidDomainCharacter = (char: string) => { - // Allow Unicode letters, numbers, hyphens, and periods - return /[\p{L}\p{N}.-]/u.test(char); - }; - return (
@@ -444,16 +444,10 @@ export default function DomainPicker2({ "border-red-500 focus:border-red-500" )} onChange={(e) => { - const rawInput = e.target.value; - const validInput = rawInput - .split("") - .filter((char) => isValidDomainCharacter(char)) - .join(""); - if (showProvidedDomainSearch) { - handleProvidedDomainInputChange(validInput); + handleProvidedDomainInputChange(e.target.value); } else { - handleSubdomainChange(validInput); + handleSubdomainChange(e.target.value); } }} /> diff --git a/src/lib/subdomain-utils.ts b/src/lib/subdomain-utils.ts index 7c94db16..5a82e930 100644 --- a/src/lib/subdomain-utils.ts +++ b/src/lib/subdomain-utils.ts @@ -1,29 +1,32 @@ export type DomainType = "organization" | "provided" | "provided-search"; -export const SINGLE_LABEL_RE = /^[a-z0-9-]+$/i; // provided-search (no dots) -export const MULTI_LABEL_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i; // ns/wildcard -export const SINGLE_LABEL_STRICT_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; // start/end alnum +export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots) +export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard +export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum export function sanitizeInputRaw(input: string): string { if (!input) return ""; - return input.toLowerCase().replace(/[^a-z0-9.-]/g, ""); + return input + .toLowerCase() + .normalize("NFC") // normalize Unicode + .replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen } export function finalizeSubdomainSanitize(input: string): string { if (!input) return ""; return input .toLowerCase() - .replace(/[^a-z0-9.-]/g, "") // allow only valid chars - .replace(/\.{2,}/g, ".") // collapse multiple dots - .replace(/^-+|-+$/g, "") // strip leading/trailing hyphens - .replace(/^\.+|\.+$/g, ""); // strip leading/trailing dots + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode + .replace(/\.{2,}/g, ".") // collapse multiple dots + .replace(/^-+|-+$/g, "") // strip leading/trailing hyphens + .replace(/^\.+|\.+$/g, "") // strip leading/trailing dots + .replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos } - - export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean { if (!domainType) return false; @@ -47,7 +50,7 @@ export function validateByDomainType(subdomain: string, domainType: { type: "pro export const isValidSubdomainStructure = (input: string): boolean => { - const regex = /^(?!-)([a-zA-Z0-9-]{1,63})(? { +