From 9557f755a5b0fca33e7ffe41d452a9e62bec2e37 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 22 Aug 2025 13:07:03 +0530 Subject: [PATCH 01/21] Add Smart Host Parsing --- .../resources/[resourceId]/proxy/page.tsx | 162 +++++++++------- .../settings/resources/create/page.tsx | 182 ++++++++++-------- src/lib/parseHostTarget.ts | 15 ++ 3 files changed, 207 insertions(+), 152 deletions(-) create mode 100644 src/lib/parseHostTarget.ts diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index c6584219..ae831277 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -94,6 +94,7 @@ import { CommandList } from "@app/components/ui/command"; import { Badge } from "@app/components/ui/badge"; +import { parseHostTarget } from "@app/lib/parseHostTarget"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: { }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -647,13 +648,25 @@ export default function ReverseProxyTargets(props: { - updateTarget(row.original.targetId, { - ...row.original, - ip: e.target.value - }) - } + onBlur={(e) => { + const parsed = parseHostTarget(e.target.value); + if (parsed) { + updateTarget(row.original.targetId, { + ...row.original, + method: parsed.protocol, + ip: parsed.host, + port: parsed.port + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + ip: e.target.value + }); + } + }} + /> + ) }, { @@ -785,21 +798,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -865,18 +878,18 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; })()} @@ -942,11 +955,22 @@ export default function ReverseProxyTargets(props: { name="ip" render={({ field }) => ( - - {t("targetAddr")} - + {t("targetAddr")} - + { + const parsed = parseHostTarget(e.target.value); + if (parsed) { + addTargetForm.setValue("method", parsed.protocol); + addTargetForm.setValue("ip", parsed.host); + addTargetForm.setValue("port", parsed.port); + } else { + field.onBlur(); + } + }} + /> @@ -1048,12 +1072,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 438b8917..9caa3655 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -88,6 +88,7 @@ import { ArrayElement } from "@server/types/ArrayElement"; 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"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -164,12 +165,12 @@ export default function Page() { ...(!env.flags.allowRawResources ? [] : [ - { - id: "raw" as ResourceType, - title: t("resourceRaw"), - description: t("resourceRawDescription") - } - ]) + { + id: "raw" as ResourceType, + title: t("resourceRaw"), + description: t("resourceRawDescription") + } + ]) ]; const baseForm = useForm({ @@ -301,11 +302,11 @@ export default function Page() { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site?.type || null - } + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -520,7 +521,7 @@ export default function Page() { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -589,31 +590,31 @@ export default function Page() { }, ...(baseForm.watch("http") ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -622,12 +623,23 @@ export default function Page() { - updateTarget(row.original.targetId, { - ...row.original, - ip: e.target.value - }) - } + onBlur={(e) => { + const parsed = parseHostTarget(e.target.value); + + if (parsed) { + updateTarget(row.original.targetId, { + ...row.original, + method: parsed.protocol, + ip: parsed.host, + port: parsed.port ? Number(parsed.port) : undefined, + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + ip: e.target.value, + }); + } + }} /> ) }, @@ -909,10 +921,10 @@ export default function Page() { .target .value ? parseInt( - e - .target - .value - ) + e + .target + .value + ) : undefined ) } @@ -1015,21 +1027,21 @@ export default function Page() { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -1097,18 +1109,18 @@ export default function Page() { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; })()} @@ -1176,21 +1188,25 @@ export default function Page() { )} ( - - {t( - "targetAddr" - )} - + {t("targetAddr")} { + const parsed = parseHostTarget(e.target.value); + if (parsed) { + addTargetForm.setValue("method", parsed.protocol); + addTargetForm.setValue("ip", parsed.host); + addTargetForm.setValue("port", parsed.port); + } else { + field.onBlur(); + } + }} /> @@ -1270,12 +1286,12 @@ export default function Page() { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} diff --git a/src/lib/parseHostTarget.ts b/src/lib/parseHostTarget.ts new file mode 100644 index 00000000..c79c7aa3 --- /dev/null +++ b/src/lib/parseHostTarget.ts @@ -0,0 +1,15 @@ +export function parseHostTarget(input: string) { + try { + const normalized = input.match(/^https?:\/\//) ? input : `http://${input}`; + const url = new URL(normalized); + + const protocol = url.protocol.replace(":", ""); // http | https + const host = url.hostname; + const port = url.port ? parseInt(url.port, 10) : protocol === "https" ? 443 : 80; + + return { protocol, host, port }; + } catch { + return null; + } +} + From fb1481c69cc07b2b5420bf177e526906199fc9b2 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 22 Aug 2025 19:18:47 +0530 Subject: [PATCH 02/21] fix lint issue --- server/lib/remoteProxy.ts | 2 +- server/routers/gerbil/getConfig.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/remoteProxy.ts b/server/lib/remoteProxy.ts index 2dad9ba8..c9016071 100644 --- a/server/lib/remoteProxy.ts +++ b/server/lib/remoteProxy.ts @@ -70,4 +70,4 @@ export const proxyToRemote = async ( ) ); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index f7663f53..77f7d2e0 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -109,7 +109,7 @@ export async function getConfig( ...req.body, endpoint: exitNode[0].endpoint, listenPort: exitNode[0].listenPort - } + }; return proxyToRemote(req, res, next, "hybrid/gerbil/get-config"); } From bc335d15c05a7bce8ece4b581aacd98053f59682 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 29 Aug 2025 01:12:12 +0530 Subject: [PATCH 03/21] 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 04/21] 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 05/21] 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 d8e547c9a021b2f07dcdc3a07f5a9637e2a7e74f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 28 Aug 2025 22:11:24 -0700 Subject: [PATCH 06/21] Configure if allow raw resources --- server/lib/readConfigFile.ts | 1 + server/routers/traefik/getTraefikConfig.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 916c7e7e..918fa4c4 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -179,6 +179,7 @@ export const configSchema = z .default("/var/dynamic/router_config.yml"), static_domains: z.array(z.string()).optional().default([]), site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]), + allow_raw_resources: z.boolean().optional().default(true), file_mode: z.boolean().optional().default(false) }) .optional() diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index f9a67432..1a55f2bd 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import { db, exitNodes } from "@server/db"; -import { and, eq, inArray, or, isNull, ne } from "drizzle-orm"; +import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; @@ -149,7 +149,10 @@ export async function getTraefikConfig( eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId) ), - inArray(sites.type, siteTypes) + inArray(sites.type, siteTypes), + config.getRawConfig().traefik.allow_raw_resources + ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true + : eq(resources.http, true), ) ); From b156b5ff2d2efe3ba2bdab44662b70558f47c359 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 28 Aug 2025 22:42:27 -0700 Subject: [PATCH 07/21] Make /32 to not mess with newt --- server/routers/site/createSite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 3a4dd885..9d3ab692 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -272,7 +272,7 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/0" + subnet: "0.0.0.0/32" }) .returning(); } From 54764dfacd8fd5e691608fe85baa06b6098d5e2b Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sat, 30 Aug 2025 01:14:03 +0530 Subject: [PATCH 08/21] 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() { From 9455adf61f61f98223cc0c3367322d182a50d52f Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 Aug 2025 21:08:31 -0700 Subject: [PATCH 13/21] Add list invitations to integration api Fixes #1364 --- messages/en-US.json | 1 + server/routers/integration.ts | 7 +++++++ src/components/PermissionsSelectBox.tsx | 1 + 3 files changed, 9 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index d5f36bae..f9bb4f6b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1059,6 +1059,7 @@ "actionGetSiteResource": "Get Site Resource", "actionListSiteResources": "List Site Resources", "actionUpdateSiteResource": "Update Site Resource", + "actionListInvitations": "List Invitations", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "searchProgress": "Search...", diff --git a/server/routers/integration.ts b/server/routers/integration.ts index cb38e441..79453732 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -221,6 +221,13 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/invitations", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listInvitations), + user.listInvitations +); + authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index a0d34b4b..d8f9b59f 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) { [t('actionUpdateOrg')]: "updateOrg", [t('actionGetOrgUser')]: "getOrgUser", [t('actionInviteUser')]: "inviteUser", + [t('actionListInvitations')]: "listInvitations", [t('actionRemoveUser')]: "removeUser", [t('actionListUsers')]: "listUsers", [t('actionListOrgDomains')]: "listOrgDomains" From ccf8e5e6f4285b7837fd4b850912b8dca7964fb6 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 Aug 2025 22:12:35 -0700 Subject: [PATCH 14/21] Dont pull org from api key Fixes #1361 --- server/middlewares/verifyRoleAccess.ts | 2 +- server/routers/resource/setResourceRoles.ts | 15 ++++++++++----- server/routers/user/addUserRole.ts | 17 +++++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index cfcbd475..98681644 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -22,7 +22,7 @@ export async function verifyRoleAccess( ); } - const { roleIds } = req.body; + const roleIds = req.body?.roleIds; const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); if (allRoleIds.length === 0) { diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 01991763..7ea76d21 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { apiKeys, roleResources, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -74,13 +74,18 @@ export async function setResourceRoles( const { resourceId } = parsedParams.data; - const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - if (!orgId) { + if (!resource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Organization not found" + "Resource not found" ) ); } @@ -92,7 +97,7 @@ export async function setResourceRoles( .where( and( eq(roles.name, "Admin"), - eq(roles.orgId, orgId) + eq(roles.orgId, resource.orgId) ) ) .limit(1); diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index bd6d9901..27f5e612 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -58,18 +58,23 @@ export async function addUserRole( ); } - const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + // get the role + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); - if (!orgId) { + if (!role) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") ); } const existingUser = await db .select() .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) .limit(1); if (existingUser.length === 0) { @@ -93,7 +98,7 @@ export async function addUserRole( const roleExists = await db .select() .from(roles) - .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) + .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) .limit(1); if (roleExists.length === 0) { @@ -108,7 +113,7 @@ export async function addUserRole( const newUserRole = await db .update(userOrgs) .set({ roleId }) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) .returning(); return response(res, { From 4e106e9e5a5a19687c46b0b461366a746ae19cb0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 Aug 2025 22:22:42 -0700 Subject: [PATCH 15/21] Make more explicit in config telemetry Fixes #1374 --- install/config/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install/config/config.yml b/install/config/config.yml index 5a86a930..b86f7890 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -13,6 +13,8 @@ managed: app: dashboard_url: "https://{{.DashboardDomain}}" log_level: "info" + telemetry: + anonymous_usage: true domains: domain1: From f37eda473915b380fee822a9d3e91e05068c1b61 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 Aug 2025 22:28:37 -0700 Subject: [PATCH 16/21] Fix #1376 --- server/routers/external.ts | 1 + server/routers/org/deleteOrg.ts | 14 +------------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index 91c185d2..0ca31117 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -82,6 +82,7 @@ authenticated.delete( "/org/:orgId", verifyOrgAccess, verifyUserIsOrgOwner, + verifyUserHasAction(ActionsEnum.deleteOrg), org.deleteOrg ); diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 76e2ad79..63e9abb0 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -49,19 +49,7 @@ export async function deleteOrg( } const { orgId } = parsedParams.data; - // Check if the user has permission to list sites - const hasPermission = await checkUserActionPermission( - ActionsEnum.deleteOrg, - req - ); - if (!hasPermission) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have permission to perform this action" - ) - ); - } + const [org] = await db .select() .from(orgs) From 78f464f6ca43ce4eaa61213e735af6f420e938a2 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 8 Aug 2025 23:08:08 +0530 Subject: [PATCH 17/21] Show/allow unicode domain name --- messages/bg-BG.json | 4 +- messages/cs-CZ.json | 4 +- messages/de-DE.json | 4 +- messages/en-US.json | 4 +- messages/es-ES.json | 4 +- messages/fr-FR.json | 4 +- messages/it-IT.json | 4 +- messages/ko-KR.json | 4 +- messages/nl-NL.json | 4 +- messages/pl-PL.json | 4 +- messages/pt-PT.json | 4 +- messages/ru-RU.json | 4 +- messages/tr-TR.json | 4 +- messages/zh-CN.json | 4 +- .../settings/domains/CreateDomainForm.tsx | 255 ++++++++++++------ src/app/[orgId]/settings/domains/page.tsx | 9 +- .../[resourceId]/CustomDomainInput.tsx | 3 +- .../[resourceId]/ResourceInfoBox.tsx | 14 +- .../resources/[resourceId]/general/page.tsx | 16 +- .../settings/resources/create/page.tsx | 8 +- src/app/[orgId]/settings/resources/page.tsx | 5 +- .../share-links/CreateShareLinkForm.tsx | 3 +- src/components/DomainPicker.tsx | 137 +++++----- 23 files changed, 333 insertions(+), 173 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 3717855e..e8c85487 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", - "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "internationaldomaindetected": "International Domain Detected", + "willbestoredas": "Will be stored as:" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index fc03108a..fe9530d3 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", - "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", + "internationaldomaindetected": "Detekována mezinárodní doména", + "willbestoredas": "Bude uloženo jako:" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 062b61af..c79a5f64 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Weiterleitung zur Anmeldung...", "autoLoginError": "Fehler bei der automatischen Anmeldung", "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", - "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL." + "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", + "internationaldomaindetected": "Internationale Domäne erkannt", + "willbestoredas": "Wird gespeichert als:" } diff --git a/messages/en-US.json b/messages/en-US.json index f9bb4f6b..dbfa817e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1494,5 +1494,7 @@ "documentation": "documentation" }, "convertButton": "Convert This Node to Managed Self-Hosted" - } + }, + "internationaldomaindetected": "International Domain Detected", + "willbestoredas": "Will be stored as:" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 2b49d9bb..0bb67191 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirigiendo al inicio de sesión...", "autoLoginError": "Error de inicio de sesión automático", "autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.", - "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación." + "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.", + "internationaldomaindetected": "Dominio internacional detectado", + "willbestoredas": "Se almacenará como: " } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ee08d77b..28bcce6a 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirection vers la connexion...", "autoLoginError": "Erreur de connexion automatique", "autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.", - "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification." + "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.", + "internationaldomaindetected": "Domaine international détecté", + "willbestoredas": "Sera stocké comme:" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 83708597..b41079c3 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Reindirizzamento al login...", "autoLoginError": "Errore di Accesso Automatico", "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", - "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione." + "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.", + "internationaldomaindetected": "Rilevato dominio internazionale", + "willbestoredas": "Verrà archiviato come:" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index b13dd19d..4b27bf55 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "로그인으로 리디렉션 중...", "autoLoginError": "자동 로그인 오류", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", - "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패." + "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.", + "internationaldomaindetected": "국제 도메인 감지됨", + "willbestoredas": "다음과 같이 저장됩니다." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 76c28cb5..91e0be86 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecting naar inloggen...", "autoLoginError": "Auto Login Fout", "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", - "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt." + "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.", + "internationaldomaindetected": "Internationaal Domein Gedetecteerd", + "willbestoredas": "Wordt opgeslagen als:" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index be33709c..9491a94d 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Przekierowanie do logowania...", "autoLoginError": "Błąd automatycznego logowania", "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", - "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania." + "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.", + "internationaldomaindetected": "Wykryto domenę międzynarodową", + "willbestoredas": "Będzie przechowywane jako:" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 4efd0237..050280fa 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Redirecionando para login...", "autoLoginError": "Erro de Login Automático", "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", - "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação." + "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.", + "internationaldomaindetected": "Domínio internacional detetado", + "willbestoredas": "Será armazenado como:" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index ed44702d..eef9aad6 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Перенаправление к входу...", "autoLoginError": "Ошибка автоматического входа", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", - "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации." + "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.", + "internationaldomaindetected": "Обнаружен международный домен", + "willbestoredas": "Будет сохранен как:" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 89de6876..09115e9a 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...", "autoLoginError": "Otomatik Giriş Hatası", "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", - "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı." + "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.", + "internationaldomaindetected": "Uluslararası Etki Alanı Algılandı", + "willbestoredas": "Şu şekilde saklanacaktır:" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 06cd8549..dee4cff1 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1457,5 +1457,7 @@ "autoLoginRedirecting": "重定向到登录...", "autoLoginError": "自动登录错误", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", - "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。" + "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。", + "internationaldomaindetected": "检测到国际域名", + "willbestoredas": "将存储为:" } diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx index 31bf82f1..60b5fa02 100644 --- a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx +++ b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx @@ -7,12 +7,13 @@ import { FormField, FormItem, FormLabel, - FormMessage + FormMessage, + FormDescription } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { @@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain"; import { StrategySelect } from "@app/components/StrategySelect"; import { AxiosResponse } from "axios"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon, AlertTriangle } from "lucide-react"; +import { InfoIcon, AlertTriangle, Globe } from "lucide-react"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, @@ -43,9 +44,58 @@ import { } from "@app/components/InfoSection"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { build } from "@server/build"; +import { toASCII, toUnicode } from 'punycode'; + + +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + +function fromPunycode(domain: string): string { + try { + const parts = toUnicode(domain) + return parts; + } catch (error) { + return domain; + } +} + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split('.'); + for (const part of parts) { + if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} const formSchema = z.object({ - baseDomain: z.string().min(1, "Domain is required"), + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), type: z.enum(["ns", "cname", "wildcard"]) }); @@ -109,8 +159,14 @@ export default function CreateDomainForm({ } } - const domainType = form.watch("type"); const baseDomain = form.watch("baseDomain"); + const domainInputValue = form.watch("baseDomain") || ""; + + const punycodePreview = useMemo(() => { + if (!domainInputValue) return ""; + const punycode = toPunycode(domainInputValue); + return punycode !== domainInputValue.toLowerCase() ? punycode : ""; + }, [domainInputValue]); let domainOptions: any = []; if (build == "enterprise" || build == "saas") { @@ -182,10 +238,23 @@ export default function CreateDomainForm({ {t("domain")} + {punycodePreview && ( + + + + {t("internationaldomaindetected")} + +

+

{t("willbestoredas")} {punycodePreview}

+
+ + + + )}
)} @@ -206,66 +275,73 @@ export default function CreateDomainForm({
{createdDomain.nsRecords && - createdDomain.nsRecords.length > 0 && ( -
-

- {t("createDomainNsRecords")} -

- - - - {t("createDomainRecord")} - - -
-
- - {t( - "createDomainType" - )} - - - NS - -
-
- - {t( - "createDomainName" - )} - - - {baseDomain} - -
- - {t( - "createDomainValue" - )} - - {createdDomain.nsRecords.map( - ( - nsRecord, - index - ) => ( -
- + createdDomain.nsRecords.length > 0 && ( +
+

+ {t("createDomainNsRecords")} +

+ + + + {t("createDomainRecord")} + + +
+
+ + {t( + "createDomainType" + )} + + + NS + +
+
+ + {t( + "createDomainName" + )} + +
+ + {fromPunycode(baseDomain)} + + {fromPunycode(baseDomain) !== baseDomain && ( + + ({baseDomain}) + + )}
- ) - )} -
- - - -
- )} +
+ + {t( + "createDomainValue" + )} + + {createdDomain.nsRecords.map( + ( + nsRecord, + index + ) => ( +
+ +
+ ) + )} +
+ + + +
+ )} {createdDomain.cnameRecords && createdDomain.cnameRecords.length > 0 && ( @@ -307,11 +383,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - cnameRecord.baseDomain - } - +
+ + {fromPunycode(cnameRecord.baseDomain)} + + {fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && ( + + ({cnameRecord.baseDomain}) + + )} +
@@ -374,11 +455,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - aRecord.baseDomain - } - +
+ + {fromPunycode(aRecord.baseDomain)} + + {fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && ( + + ({aRecord.baseDomain}) + + )} +
@@ -390,7 +476,7 @@ export default function CreateDomainForm({ { aRecord.value } - +
@@ -440,11 +526,16 @@ export default function CreateDomainForm({ "createDomainName" )} - - { - txtRecord.baseDomain - } - +
+ + {fromPunycode(txtRecord.baseDomain)} + + {fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && ( + + ({txtRecord.baseDomain}) + + )} +
@@ -513,4 +604,4 @@ export default function CreateDomainForm({ ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index d20e431f..c85fe10d 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org"; import { redirect } from "next/navigation"; import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; +import { toUnicode } from 'punycode'; type Props = { params: Promise<{ orgId: string }>; @@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) { const res = await internal.get< AxiosResponse >(`/org/${params.orgId}/domains`, await authCookieHeader()); - domains = res.data.data.domains as DomainRow[]; + + const rawDomains = res.data.data.domains as DomainRow[]; + + domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); } catch (e) { console.error(e); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx index 0764d740..171f5683 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toUnicode } from "punycode"; interface DomainOption { baseDomain: string; @@ -91,7 +92,7 @@ export default function CustomDomainInput({ key={option.domainId} value={option.domainId} > - .{option.baseDomain} + .{toUnicode(option.baseDomain)} ))} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index af7d96fc..8da95ec0 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -12,15 +12,19 @@ import { } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { toUnicode } from 'punycode'; type ResourceInfoBoxType = {}; -export default function ResourceInfoBox({}: ResourceInfoBoxType) { +export default function ResourceInfoBox({ }: ResourceInfoBoxType) { const { resource, authInfo } = useResourceContext(); const t = useTranslations(); - const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + + const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`; + + return ( @@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist ? ( + authInfo.pincode || + authInfo.sso || + authInfo.whitelist ? (
{t("protected")} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index b95ecef2..37c5c363 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -54,6 +54,8 @@ import DomainPicker from "@app/components/DomainPicker"; 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"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -155,7 +157,11 @@ export default function GeneralForm() { }); if (res?.status === 200) { - const domains = res.data.data.domains; + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); setBaseDomains(domains); setFormKey((key) => key + 1); } @@ -319,10 +325,10 @@ export default function GeneralForm() { .target .value ? parseInt( - e - .target - .value - ) + e + .target + .value + ) : undefined ) } diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 9caa3655..6436ac15 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -89,6 +89,8 @@ 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 { DomainRow } from "../../domains/DomainsTable"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -469,7 +471,11 @@ export default function Page() { }); if (res?.status === 200) { - const domains = res.data.data.domains; + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); setBaseDomains(domains); // if (domains.length) { // httpForm.setValue("domainId", domains[0].domainId); diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index d5af500b..f8ef5397 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; +import { toUnicode } from "punycode"; type ResourcesPageProps = { params: Promise<{ orgId: string }>; @@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) { id: resource.resourceId, name: resource.name, orgId: params.orgId, - domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + + + domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, protocol: resource.protocol, proxyPort: resource.proxyPort, http: resource.http, diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 44891980..18c989ab 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -67,6 +67,7 @@ import { } from "@app/components/ui/collapsible"; import AccessTokenSection from "./AccessTokenUsage"; import { useTranslations } from "next-intl"; +import { toUnicode } from 'punycode'; type FormProps = { open: boolean; @@ -159,7 +160,7 @@ export default function CreateShareLinkForm({ .map((r) => ({ resourceId: r.resourceId, name: r.name, - resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` + resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` })) ); } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index ab54ccce..572ff913 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -43,6 +43,7 @@ import { validateByDomainType, isValidSubdomainStructure } from "@/lib/subdomain-utils"; +import { toUnicode } from "punycode"; type OrganizationDomain = { domainId: string; @@ -126,6 +127,7 @@ export default function DomainPicker2({ ) .map((domain) => ({ ...domain, + baseDomain: toUnicode(domain.baseDomain), type: domain.type as "ns" | "cname" | "wildcard" })); setOrganizationDomains(domains); @@ -406,6 +408,12 @@ export default function DomainPicker2({ const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; + + const isValidDomainCharacter = (char: string) => { + // Allow Unicode letters, numbers, hyphens, and periods + return /[\p{L}\p{N}.-]/u.test(char); + }; + return (
@@ -424,8 +432,8 @@ export default function DomainPicker2({ showProvidedDomainSearch ? "" : showSubdomainInput - ? "" - : t("domainPickerNotAvailableForCname") + ? "" + : t("domainPickerNotAvailableForCname") } disabled={ !showSubdomainInput && !showProvidedDomainSearch @@ -436,10 +444,16 @@ 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(e.target.value); + handleProvidedDomainInputChange(validInput); } else { - handleSubdomainChange(e.target.value); + handleSubdomainChange(validInput); } }} /> @@ -448,7 +462,6 @@ export default function DomainPicker2({ This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.

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

{t("domainPickerEnterSubdomainOrLeaveBlank")} @@ -474,7 +487,7 @@ export default function DomainPicker2({ {selectedBaseDomain ? (

{selectedBaseDomain.type === - "organization" ? null : ( + "organization" ? null : ( )} @@ -568,67 +581,67 @@ export default function DomainPicker2({ {(build === "saas" || build === "enterprise") && ( - - )} + + )} )} {(build === "saas" || build === "enterprise") && ( - - - - handleBaseDomainSelect({ - id: "provided-search", - domain: - build === - "enterprise" + + + + handleBaseDomainSelect({ + id: "provided-search", + domain: + build === + "enterprise" + ? "Provided Domain" + : "Free Provided Domain", + type: "provided-search" + }) + } + className="mx-2 rounded-md" + > +
+ +
+
+ + {build === "enterprise" ? "Provided Domain" - : "Free Provided Domain", - type: "provided-search" - }) - } - className="mx-2 rounded-md" - > -
- -
-
- - {build === "enterprise" - ? "Provided Domain" - : "Free Provided Domain"} - - - {t( - "domainPickerSearchForAvailableDomains" + : "Free Provided Domain"} + + + {t( + "domainPickerSearchForAvailableDomains" + )} + +
+ -
- -
-
-
- )} + /> +
+
+
+ )} @@ -684,7 +697,7 @@ export default function DomainPicker2({ htmlFor={option.domainNamespaceId} data-state={ selectedProvidedDomain?.domainNamespaceId === - option.domainNamespaceId + option.domainNamespaceId ? "checked" : "unchecked" } @@ -764,4 +777,4 @@ function debounce any>( func(...args); }, wait); }; -} +} \ No newline at end of file From 8a62f12e8b960637ec25fc125e1e4d08bf8c7c49 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sun, 24 Aug 2025 22:20:52 +0530 Subject: [PATCH 18/21] fix lint --- src/app/[orgId]/settings/domains/CreateDomainForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx index 60b5fa02..e609a8ac 100644 --- a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx +++ b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx @@ -59,7 +59,7 @@ function toPunycode(domain: string): string { function fromPunycode(domain: string): string { try { - const parts = toUnicode(domain) + const parts = toUnicode(domain); return parts; } catch (error) { return domain; From be161960585ebd389ae80d1bbaf29a1d04605a79 Mon Sep 17 00:00:00 2001 From: Hetav21 Date: Sun, 31 Aug 2025 21:19:34 +0530 Subject: [PATCH 19/21] feat: make version numbers link to GitHub releases and add Discord link --- src/components/LayoutMobileMenu.tsx | 12 ++++++++++-- src/components/LayoutSidebar.tsx | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index cdec0d98..0cf0ae78 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import SupporterStatus from "@app/components/SupporterStatus"; import { Button } from "@app/components/ui/button"; -import { Menu, Server } from "lucide-react"; +import { ExternalLink, Menu, Server } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -117,7 +117,15 @@ export function LayoutMobileMenu({ {env?.app?.version && (
- v{env.app.version} + + v{env.app.version} + +
)}
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index cfc21144..b563d9ac 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import SupporterStatus from "@app/components/SupporterStatus"; import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react"; +import { FaDiscord, FaGithub } from "react-icons/fa"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -151,7 +152,7 @@ export function LayoutSidebar({ {!isUnlocked() ? t("communityEdition") : t("commercialEdition")} - +
@@ -165,9 +166,28 @@ export function LayoutSidebar({
+
+ + Discord + + +
{env?.app?.version && (
- v{env.app.version} + + v{env.app.version} + +
)}
From 7d5961cf5036ec35a21db7181878949062c28d58 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sun, 31 Aug 2025 22:45:42 +0530 Subject: [PATCH 20/21] Support unicode with subdomain sanitized --- .../resources/[resourceId]/general/page.tsx | 7 +++-- .../settings/resources/create/page.tsx | 4 +-- src/components/DomainPicker.tsx | 24 +++++++---------- src/lib/subdomain-utils.ts | 26 +++++++++++-------- 4 files changed, 29 insertions(+), 32 deletions(-) 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})(? { + From 601645fa724711b9311e710ebeb978ca683df5b0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 Aug 2025 20:40:34 -0700 Subject: [PATCH 21/21] Fix translations Fix #1355 --- src/app/invite/page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 0df7b810..2e0c11e2 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -48,6 +48,7 @@ export default async function InvitePage(props: { ) .catch((e) => { error = formatAxiosError(e); + console.error(error); }); if (res && res.status === 200) { @@ -55,13 +56,13 @@ export default async function InvitePage(props: { } function cardType() { - if (error.includes(t('inviteErrorWrongUser'))) { + if (error.includes("Invite is not for this user")) { return "wrong_user"; } else if ( - error.includes(t('inviteErrorUserNotExists')) + error.includes("User does not exist. Please create an account first.") ) { return "user_does_not_exist"; - } else if (error.includes(t('inviteErrorLoginRequired'))) { + } else if (error.includes("You must be logged in to accept an invite")) { return "not_logged_in"; } else { return "rejected";