mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-28 03:32:20 +00:00
Pass one at getting it into the db
This commit is contained in:
@@ -1947,6 +1947,8 @@
|
|||||||
"httpMethod": "Scheme",
|
"httpMethod": "Scheme",
|
||||||
"selectHttpMethod": "Select scheme",
|
"selectHttpMethod": "Select scheme",
|
||||||
"domainPickerSubdomainLabel": "Subdomain",
|
"domainPickerSubdomainLabel": "Subdomain",
|
||||||
|
"domainPickerWildcard": "Wildcard",
|
||||||
|
"domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.",
|
||||||
"domainPickerBaseDomainLabel": "Base Domain",
|
"domainPickerBaseDomainLabel": "Base Domain",
|
||||||
"domainPickerSearchDomains": "Search domains...",
|
"domainPickerSearchDomains": "Search domains...",
|
||||||
"domainPickerNoDomainsFound": "No domains found",
|
"domainPickerNoDomainsFound": "No domains found",
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ export const resources = pgTable("resources", {
|
|||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: varchar("health") // "healthy", "unhealthy"
|
health: varchar("health"), // "healthy", "unhealthy"
|
||||||
|
wildcard: boolean("wildcard").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health") // "healthy", "unhealthy"
|
health: text("health"), // "healthy", "unhealthy"
|
||||||
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export enum TierFeature {
|
|||||||
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules"
|
AlertingRules = "alertingRules",
|
||||||
|
WildcardSubdomain = "wildcardSubdomain"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -64,5 +65,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"]
|
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
|
domainNamespaces,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
Resource,
|
Resource,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
@@ -236,6 +237,7 @@ export async function updateProxyResources(
|
|||||||
fullDomain: http ? resourceData["full-domain"] : null,
|
fullDomain: http ? resourceData["full-domain"] : null,
|
||||||
subdomain: domain ? domain.subdomain : null,
|
subdomain: domain ? domain.subdomain : null,
|
||||||
domainId: domain ? domain.domainId : null,
|
domainId: domain ? domain.domainId : null,
|
||||||
|
wildcard: domain ? domain.wildcard : false,
|
||||||
enabled: resourceEnabled,
|
enabled: resourceEnabled,
|
||||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||||
skipToIdpId:
|
skipToIdpId:
|
||||||
@@ -683,6 +685,7 @@ export async function updateProxyResources(
|
|||||||
fullDomain: http ? resourceData["full-domain"] : null,
|
fullDomain: http ? resourceData["full-domain"] : null,
|
||||||
subdomain: domain ? domain.subdomain : null,
|
subdomain: domain ? domain.subdomain : null,
|
||||||
domainId: domain ? domain.domainId : null,
|
domainId: domain ? domain.domainId : null,
|
||||||
|
wildcard: domain ? domain.wildcard : false,
|
||||||
enabled: resourceEnabled,
|
enabled: resourceEnabled,
|
||||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||||
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
|
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
|
||||||
@@ -1152,7 +1155,9 @@ async function getDomainId(
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<{ subdomain: string | null; domainId: string } | null> {
|
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
|
||||||
|
const isWildcardFullDomain = fullDomain.startsWith("*.");
|
||||||
|
|
||||||
const possibleDomains = await trx
|
const possibleDomains = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(domains)
|
.from(domains)
|
||||||
@@ -1165,6 +1170,11 @@ async function getDomainId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validDomains = possibleDomains.filter((domain) => {
|
const validDomains = possibleDomains.filter((domain) => {
|
||||||
|
// Wildcard full-domains are not allowed on CNAME domains
|
||||||
|
if (isWildcardFullDomain && domain.domains.type === "cname") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
||||||
return (
|
return (
|
||||||
fullDomain === domain.domains.baseDomain ||
|
fullDomain === domain.domains.baseDomain ||
|
||||||
@@ -1182,6 +1192,21 @@ async function getDomainId(
|
|||||||
const domainSelection = validDomains[0].domains;
|
const domainSelection = validDomains[0].domains;
|
||||||
const baseDomain = domainSelection.baseDomain;
|
const baseDomain = domainSelection.baseDomain;
|
||||||
|
|
||||||
|
// Wildcard full-domains are not allowed on namespace (provided/free) domains
|
||||||
|
if (isWildcardFullDomain) {
|
||||||
|
const [namespaceDomain] = await trx
|
||||||
|
.select()
|
||||||
|
.from(domainNamespaces)
|
||||||
|
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (namespaceDomain) {
|
||||||
|
throw new Error(
|
||||||
|
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// remove the base domain of the domain
|
// remove the base domain of the domain
|
||||||
let subdomain = null;
|
let subdomain = null;
|
||||||
if (fullDomain != baseDomain) {
|
if (fullDomain != baseDomain) {
|
||||||
@@ -1191,6 +1216,7 @@ async function getDomainId(
|
|||||||
// Return the first valid domain
|
// Return the first valid domain
|
||||||
return {
|
return {
|
||||||
subdomain: subdomain,
|
subdomain: subdomain,
|
||||||
domainId: domainSelection.domainId
|
domainId: domainSelection.domainId,
|
||||||
|
wildcard: isWildcardFullDomain
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { portRangeStringSchema } from "@server/lib/ip";
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -319,6 +320,34 @@ export const ResourceSchema = z
|
|||||||
message:
|
message:
|
||||||
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
|
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
const fullDomain = resource["full-domain"];
|
||||||
|
if (!fullDomain || !fullDomain.includes("*")) return true;
|
||||||
|
|
||||||
|
// A wildcard full-domain must be of the form *.labels.basedomain
|
||||||
|
// Extract the leftmost label(s) before the first non-wildcard segment.
|
||||||
|
// e.g. "*.level1.example.com" → subdomain candidate is "*.level1"
|
||||||
|
// We do this by finding the base domain: everything after the first
|
||||||
|
// real (non-wildcard) dot-separated segment pair.
|
||||||
|
//
|
||||||
|
// Simple rule: split on ".", first token must be "*", rest must be
|
||||||
|
// valid hostname labels, and there must be at least 2 remaining labels
|
||||||
|
// (so the full domain has a real base domain).
|
||||||
|
const parts = fullDomain.split(".");
|
||||||
|
if (parts[0] !== "*") return false; // * must be the very first label
|
||||||
|
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||||
|
if (parts.length < 3) return false; // need at least *.label.tld
|
||||||
|
|
||||||
|
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||||
|
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["full-domain"],
|
||||||
|
message:
|
||||||
|
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function isTargetsOnlyResource(resource: any): boolean {
|
export function isTargetsOnlyResource(resource: any): boolean {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { domains, orgDomains } from "@server/db";
|
import { domains, orgDomains, domainNamespaces } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
export type DomainValidationResult =
|
export type DomainValidationResult =
|
||||||
@@ -9,6 +9,7 @@ export type DomainValidationResult =
|
|||||||
success: true;
|
success: true;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
subdomain: string | null;
|
subdomain: string | null;
|
||||||
|
wildcard: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
success: false;
|
success: false;
|
||||||
@@ -66,6 +67,47 @@ export async function validateAndConstructDomain(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect wildcard subdomain request
|
||||||
|
const isWildcard =
|
||||||
|
subdomain !== undefined &&
|
||||||
|
subdomain !== null &&
|
||||||
|
subdomain.includes("*");
|
||||||
|
|
||||||
|
// Wildcard subdomains are not allowed on CNAME domains
|
||||||
|
if (isWildcard && domainRes.domains.type === "cname") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard subdomains are not allowed on namespace (provided/free) domains
|
||||||
|
if (isWildcard) {
|
||||||
|
const [namespaceDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(domainNamespaces)
|
||||||
|
.where(eq(domainNamespaces.domainId, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (namespaceDomain) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate wildcard subdomain format
|
||||||
|
if (isWildcard) {
|
||||||
|
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
|
||||||
|
if (!parsedWildcard.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: fromError(parsedWildcard.error).toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Construct full domain based on domain type
|
// Construct full domain based on domain type
|
||||||
let fullDomain = "";
|
let fullDomain = "";
|
||||||
let finalSubdomain = subdomain;
|
let finalSubdomain = subdomain;
|
||||||
@@ -81,13 +123,15 @@ export async function validateAndConstructDomain(
|
|||||||
finalSubdomain = null; // CNAME domains don't use subdomains
|
finalSubdomain = null; // CNAME domains don't use subdomains
|
||||||
} else if (domainRes.domains.type === "wildcard") {
|
} else if (domainRes.domains.type === "wildcard") {
|
||||||
if (subdomain !== undefined && subdomain !== null) {
|
if (subdomain !== undefined && subdomain !== null) {
|
||||||
// Validate subdomain format for wildcard domains
|
if (!isWildcard) {
|
||||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
// Validate regular subdomain format for wildcard domains
|
||||||
if (!parsedSubdomain.success) {
|
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
||||||
return {
|
if (!parsedSubdomain.success) {
|
||||||
success: false,
|
return {
|
||||||
error: fromError(parsedSubdomain.error).toString()
|
success: false,
|
||||||
};
|
error: fromError(parsedSubdomain.error).toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -100,13 +144,14 @@ export async function validateAndConstructDomain(
|
|||||||
finalSubdomain = null;
|
finalSubdomain = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to lowercase
|
// Convert to lowercase (preserve * as-is)
|
||||||
fullDomain = fullDomain.toLowerCase();
|
fullDomain = fullDomain.toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
subdomain: finalSubdomain ?? null
|
subdomain: isWildcard ? "*" : (finalSubdomain ?? null),
|
||||||
|
wildcard: isWildcard
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a wildcard subdomain passed as the leftmost component of a full domain.
|
||||||
|
*
|
||||||
|
* The value represents everything to the left of the base domain, so when combined
|
||||||
|
* with e.g. "example.com" it must produce a valid SSL-style wildcard hostname.
|
||||||
|
*
|
||||||
|
* Valid:
|
||||||
|
* "*" → *.example.com
|
||||||
|
* "*.level1" → *.level1.example.com
|
||||||
|
*
|
||||||
|
* Invalid:
|
||||||
|
* "*example" → *example.com (no dot after *)
|
||||||
|
* "level2.*.level1" → wildcard not in leftmost position
|
||||||
|
* "*.level1.*" → multiple wildcards
|
||||||
|
*/
|
||||||
|
export const wildcardSubdomainSchema = z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// Must start with "*."; the remainder (if any) must be valid hostname labels.
|
||||||
|
// A bare "*" is also valid (becomes *.baseDomain directly).
|
||||||
|
if (val === "*") return true;
|
||||||
|
if (!val.startsWith("*.")) return false;
|
||||||
|
const rest = val.slice(2); // everything after "*."
|
||||||
|
// rest must not be empty, must not contain another "*",
|
||||||
|
// and every label must be a valid hostname label.
|
||||||
|
if (!rest || rest.includes("*")) return false;
|
||||||
|
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||||
|
return rest.split(".").every((label) => labelRegex.test(label));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const subdomainSchema = z
|
export const subdomainSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import createHttpError from "http-errors";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -25,6 +25,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
|
|||||||
import { getUniqueResourceName } from "@server/db/names";
|
import { getUniqueResourceName } from "@server/db/names";
|
||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const createResourceParamsSchema = z.strictObject({
|
const createResourceParamsSchema = z.strictObject({
|
||||||
@@ -44,7 +45,10 @@ const createHttpResourceSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.subdomain) {
|
if (data.subdomain) {
|
||||||
return subdomainSchema.safeParse(data.subdomain).success;
|
return (
|
||||||
|
subdomainSchema.safeParse(data.subdomain).success ||
|
||||||
|
wildcardSubdomainSchema.safeParse(data.subdomain).success
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -198,6 +202,22 @@ async function createHttpResource(
|
|||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
|
// Wildcard subdomains are a paid feature
|
||||||
|
if (subdomain && subdomain.includes("*")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
||||||
// grandfather in existing users
|
// grandfather in existing users
|
||||||
const lastAllowedDate = new Date("2026-04-13");
|
const lastAllowedDate = new Date("2026-04-13");
|
||||||
@@ -232,7 +252,7 @@ async function createHttpResource(
|
|||||||
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
|
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult;
|
||||||
|
|
||||||
logger.debug(`Full domain: ${fullDomain}`);
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
@@ -299,7 +319,8 @@ async function createHttpResource(
|
|||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
ssl: true,
|
ssl: true,
|
||||||
stickySession: stickySession,
|
stickySession: stickySession,
|
||||||
postAuthPath: postAuthPath
|
postAuthPath: postAuthPath,
|
||||||
|
wildcard
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { tlsNameSchema } from "@server/lib/schemas";
|
import {
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
tlsNameSchema,
|
||||||
|
subdomainSchema,
|
||||||
|
wildcardSubdomainSchema
|
||||||
|
} from "@server/lib/schemas";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
@@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.subdomain) {
|
if (data.subdomain) {
|
||||||
return subdomainSchema.safeParse(data.subdomain).success;
|
return (
|
||||||
|
subdomainSchema.safeParse(data.subdomain).success ||
|
||||||
|
wildcardSubdomainSchema.safeParse(data.subdomain).success
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -318,6 +324,22 @@ async function updateHttpResource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wildcard subdomains are a paid feature
|
||||||
|
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
resource.orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateData.domainId) {
|
if (updateData.domainId) {
|
||||||
const domainId = updateData.domainId;
|
const domainId = updateData.domainId;
|
||||||
|
|
||||||
@@ -362,7 +384,11 @@ async function updateHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
const {
|
||||||
|
fullDomain,
|
||||||
|
subdomain: finalSubdomain,
|
||||||
|
wildcard
|
||||||
|
} = domainResult;
|
||||||
|
|
||||||
logger.debug(`Full domain: ${fullDomain}`);
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
@@ -419,7 +445,7 @@ async function updateHttpResource(
|
|||||||
if (fullDomain && fullDomain !== resource.fullDomain) {
|
if (fullDomain && fullDomain !== resource.fullDomain) {
|
||||||
await db
|
await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ fullDomain })
|
.set({ fullDomain, wildcard })
|
||||||
.where(eq(resources.resourceId, resource.resourceId));
|
.where(eq(resources.resourceId, resource.resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { cn } from "@/lib/cn";
|
|||||||
import {
|
import {
|
||||||
finalizeSubdomainSanitize,
|
finalizeSubdomainSanitize,
|
||||||
isValidSubdomainStructure,
|
isValidSubdomainStructure,
|
||||||
|
isWildcardSubdomain,
|
||||||
sanitizeInputRaw,
|
sanitizeInputRaw,
|
||||||
validateByDomainType
|
validateByDomainType
|
||||||
} from "@/lib/subdomain-utils";
|
} from "@/lib/subdomain-utils";
|
||||||
@@ -77,6 +78,7 @@ interface DomainPickerProps {
|
|||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
baseDomain: string;
|
baseDomain: string;
|
||||||
|
wildcard?: boolean;
|
||||||
} | null
|
} | null
|
||||||
) => void;
|
) => void;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
@@ -85,6 +87,7 @@ interface DomainPickerProps {
|
|||||||
defaultSubdomain?: string | null;
|
defaultSubdomain?: string | null;
|
||||||
defaultDomainId?: string | null;
|
defaultDomainId?: string | null;
|
||||||
warnOnProvidedDomain?: boolean;
|
warnOnProvidedDomain?: boolean;
|
||||||
|
allowWildcard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainPicker({
|
export default function DomainPicker({
|
||||||
@@ -95,23 +98,33 @@ export default function DomainPicker({
|
|||||||
defaultSubdomain,
|
defaultSubdomain,
|
||||||
defaultFullDomain,
|
defaultFullDomain,
|
||||||
defaultDomainId,
|
defaultDomainId,
|
||||||
warnOnProvidedDomain = false
|
warnOnProvidedDomain = false,
|
||||||
|
allowWildcard = false
|
||||||
}: DomainPickerProps) {
|
}: DomainPickerProps) {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { hasSaasSubscription } = usePaidStatus();
|
const { hasSaasSubscription, isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const requiresPaywall =
|
const requiresPaywall =
|
||||||
build === "saas" &&
|
build === "saas" &&
|
||||||
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
|
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
|
||||||
new Date(user.dateCreated) > new Date("2026-04-13");
|
new Date(user.dateCreated) > new Date("2026-04-13");
|
||||||
|
|
||||||
|
const wildcardAllowed =
|
||||||
|
allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]);
|
||||||
|
|
||||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||||
orgQueries.domains({ orgId })
|
orgQueries.domains({ orgId })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wildcard mode toggle — only relevant when allowWildcard is true, the
|
||||||
|
// user has a paid plan, and the selected base domain supports it.
|
||||||
|
const [wildcardMode, setWildcardMode] = useState(
|
||||||
|
wildcardAllowed && !!defaultSubdomain && isWildcardSubdomain(defaultSubdomain)
|
||||||
|
);
|
||||||
|
|
||||||
if (!env.flags.usePangolinDns) {
|
if (!env.flags.usePangolinDns) {
|
||||||
hideFreeDomain = true;
|
hideFreeDomain = true;
|
||||||
}
|
}
|
||||||
@@ -180,13 +193,15 @@ export default function DomainPicker({
|
|||||||
firstOrExistingDomain.type !== "cname"
|
firstOrExistingDomain.type !== "cname"
|
||||||
? defaultSubdomain?.trim() || undefined
|
? defaultSubdomain?.trim() || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isWc = allowWildcard && !!sub && isWildcardSubdomain(sub);
|
||||||
|
|
||||||
onDomainChange?.({
|
onDomainChange?.({
|
||||||
domainId: firstOrExistingDomain.domainId,
|
domainId: firstOrExistingDomain.domainId,
|
||||||
type: "organization",
|
type: "organization",
|
||||||
subdomain: sub,
|
subdomain: sub,
|
||||||
fullDomain: sub ? `${sub}.${base}` : base,
|
fullDomain: sub ? `${sub}.${base}` : base,
|
||||||
baseDomain: base
|
baseDomain: base,
|
||||||
|
wildcard: isWc
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,7 +300,7 @@ export default function DomainPicker({
|
|||||||
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
|
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
|
||||||
|
|
||||||
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||||
const sanitized = finalizeSubdomainSanitize(sub);
|
const sanitized = finalizeSubdomainSanitize(sub, wildcardAllowed && wildcardMode);
|
||||||
|
|
||||||
if (!sanitized) {
|
if (!sanitized) {
|
||||||
toast({
|
toast({
|
||||||
@@ -301,7 +316,8 @@ export default function DomainPicker({
|
|||||||
base.type === "provided-search"
|
base.type === "provided-search"
|
||||||
? "provided-search"
|
? "provided-search"
|
||||||
: "organization",
|
: "organization",
|
||||||
domainType: base.domainType
|
domainType: base.domainType,
|
||||||
|
allowWildcard: wildcardAllowed && wildcardMode
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -330,7 +346,7 @@ export default function DomainPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubdomainChange = (value: string) => {
|
const handleSubdomainChange = (value: string) => {
|
||||||
const raw = sanitizeInputRaw(value);
|
const raw = sanitizeInputRaw(value, wildcardAllowed && wildcardMode);
|
||||||
setSubdomainInput(raw);
|
setSubdomainInput(raw);
|
||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
|
|
||||||
@@ -338,13 +354,15 @@ export default function DomainPicker({
|
|||||||
const fullDomain = raw
|
const fullDomain = raw
|
||||||
? `${raw}.${selectedBaseDomain.domain}`
|
? `${raw}.${selectedBaseDomain.domain}`
|
||||||
: selectedBaseDomain.domain;
|
: selectedBaseDomain.domain;
|
||||||
|
const isWc = wildcardAllowed && wildcardMode && isWildcardSubdomain(raw);
|
||||||
|
|
||||||
onDomainChange?.({
|
onDomainChange?.({
|
||||||
domainId: selectedBaseDomain.domainId!,
|
domainId: selectedBaseDomain.domainId!,
|
||||||
type: "organization",
|
type: "organization",
|
||||||
subdomain: raw || undefined,
|
subdomain: raw || undefined,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
baseDomain: selectedBaseDomain.domain
|
baseDomain: selectedBaseDomain.domain,
|
||||||
|
wildcard: isWc
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -366,6 +384,18 @@ export default function DomainPicker({
|
|||||||
const handleBaseDomainSelect = (option: DomainOption) => {
|
const handleBaseDomainSelect = (option: DomainOption) => {
|
||||||
let sub = subdomainInput;
|
let sub = subdomainInput;
|
||||||
|
|
||||||
|
// Wildcard mode is not applicable for cname or provided-search domains,
|
||||||
|
// or when the user doesn't have a paid plan.
|
||||||
|
const newWildcardMode =
|
||||||
|
wildcardAllowed &&
|
||||||
|
wildcardMode &&
|
||||||
|
option.type === "organization" &&
|
||||||
|
option.domainType !== "cname";
|
||||||
|
|
||||||
|
if (newWildcardMode !== wildcardMode) {
|
||||||
|
setWildcardMode(newWildcardMode);
|
||||||
|
}
|
||||||
|
|
||||||
if (sub && sub.trim() !== "") {
|
if (sub && sub.trim() !== "") {
|
||||||
sub = finalizeSubdomain(sub, option) || "";
|
sub = finalizeSubdomain(sub, option) || "";
|
||||||
setSubdomainInput(sub);
|
setSubdomainInput(sub);
|
||||||
@@ -389,6 +419,7 @@ export default function DomainPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
||||||
|
const isWc = newWildcardMode && !!sub && isWildcardSubdomain(sub);
|
||||||
|
|
||||||
if (option.type === "provided-search") {
|
if (option.type === "provided-search") {
|
||||||
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
|
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
|
||||||
@@ -402,7 +433,8 @@ export default function DomainPicker({
|
|||||||
? sub || undefined
|
? sub || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
baseDomain: option.domain
|
baseDomain: option.domain,
|
||||||
|
wildcard: isWc
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -431,7 +463,8 @@ export default function DomainPicker({
|
|||||||
selectedBaseDomain.type === "provided-search"
|
selectedBaseDomain.type === "provided-search"
|
||||||
? "provided-search"
|
? "provided-search"
|
||||||
: "organization",
|
: "organization",
|
||||||
domainType: selectedBaseDomain.domainType
|
domainType: selectedBaseDomain.domainType,
|
||||||
|
allowWildcard: wildcardAllowed && wildcardMode
|
||||||
})
|
})
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
@@ -439,6 +472,31 @@ export default function DomainPicker({
|
|||||||
selectedBaseDomain &&
|
selectedBaseDomain &&
|
||||||
selectedBaseDomain.type === "organization" &&
|
selectedBaseDomain.type === "organization" &&
|
||||||
selectedBaseDomain.domainType !== "cname";
|
selectedBaseDomain.domainType !== "cname";
|
||||||
|
|
||||||
|
// Wildcard toggle is shown when the caller opts in and the selected domain
|
||||||
|
// supports it (ns or wildcard type). Shown even when unpaid so we can
|
||||||
|
// render a disabled state explaining it's a paid feature.
|
||||||
|
const showWildcardToggle =
|
||||||
|
allowWildcard &&
|
||||||
|
showSubdomainInput &&
|
||||||
|
(selectedBaseDomain?.domainType === "ns" ||
|
||||||
|
selectedBaseDomain?.domainType === "wildcard");
|
||||||
|
|
||||||
|
const handleWildcardModeChange = (enabled: boolean) => {
|
||||||
|
setWildcardMode(enabled);
|
||||||
|
// Reset subdomain input when toggling modes to avoid invalid state
|
||||||
|
setSubdomainInput("");
|
||||||
|
if (selectedBaseDomain?.type === "organization") {
|
||||||
|
onDomainChange?.({
|
||||||
|
domainId: selectedBaseDomain.domainId!,
|
||||||
|
type: "organization",
|
||||||
|
subdomain: undefined,
|
||||||
|
fullDomain: selectedBaseDomain.domain,
|
||||||
|
baseDomain: selectedBaseDomain.domain,
|
||||||
|
wildcard: enabled ? true : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
const showProvidedDomainSearch =
|
const showProvidedDomainSearch =
|
||||||
selectedBaseDomain?.type === "provided-search";
|
selectedBaseDomain?.type === "provided-search";
|
||||||
|
|
||||||
@@ -463,9 +521,35 @@ export default function DomainPicker({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="subdomain-input">
|
<div className="flex items-center justify-between">
|
||||||
{t("domainPickerSubdomainLabel")}
|
<Label htmlFor="subdomain-input">
|
||||||
</Label>
|
{t("domainPickerSubdomainLabel")}
|
||||||
|
</Label>
|
||||||
|
{showWildcardToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
wildcardAllowed &&
|
||||||
|
handleWildcardModeChange(!wildcardMode)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
!wildcardAllowed
|
||||||
|
? t("domainPickerWildcardPaidOnly")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full border transition-colors",
|
||||||
|
!wildcardAllowed
|
||||||
|
? "opacity-50 cursor-not-allowed border-input text-muted-foreground"
|
||||||
|
: wildcardMode
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-transparent text-muted-foreground border-input hover:border-primary hover:text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("domainPickerWildcard")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="subdomain-input"
|
id="subdomain-input"
|
||||||
value={
|
value={
|
||||||
@@ -477,7 +561,9 @@ export default function DomainPicker({
|
|||||||
showProvidedDomainSearch
|
showProvidedDomainSearch
|
||||||
? ""
|
? ""
|
||||||
: showSubdomainInput
|
: showSubdomainInput
|
||||||
? ""
|
? wildcardMode
|
||||||
|
? "*.level1"
|
||||||
|
: ""
|
||||||
: t("domainPickerNotAvailableForCname")
|
: t("domainPickerNotAvailableForCname")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -498,7 +584,7 @@ export default function DomainPicker({
|
|||||||
/>
|
/>
|
||||||
{showSubdomainInput &&
|
{showSubdomainInput &&
|
||||||
subdomainInput &&
|
subdomainInput &&
|
||||||
!isValidSubdomainStructure(subdomainInput) && (
|
!isValidSubdomainStructure(subdomainInput, wildcardAllowed && wildcardMode) && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{t("domainPickerInvalidSubdomainStructure")}
|
{t("domainPickerInvalidSubdomainStructure")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild
|
|||||||
export const SINGLE_LABEL_STRICT_RE =
|
export const SINGLE_LABEL_STRICT_RE =
|
||||||
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
|
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
|
||||||
|
|
||||||
export function sanitizeInputRaw(input: string): string {
|
/**
|
||||||
|
* A wildcard subdomain is either bare "*" or "*.label1.label2…" where every
|
||||||
|
* label after the dot is a valid hostname label. This mirrors the shape that
|
||||||
|
* the server's `wildcardSubdomainSchema` accepts.
|
||||||
|
*/
|
||||||
|
export const WILDCARD_SUBDOMAIN_RE =
|
||||||
|
/^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
|
export function isWildcardSubdomain(input: string): boolean {
|
||||||
|
return WILDCARD_SUBDOMAIN_RE.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeInputRaw(input: string, allowWildcard = false): string {
|
||||||
if (!input) return "";
|
if (!input) return "";
|
||||||
|
// When wildcard mode is active, preserve a leading "* " / "*." prefix and
|
||||||
|
// only sanitize the remainder so the user can type "*.level1" naturally.
|
||||||
|
if (allowWildcard && input.startsWith("*")) {
|
||||||
|
const rest = input.slice(1);
|
||||||
|
const sanitizedRest = rest
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/[^\p{L}\p{N}.-]/gu, "");
|
||||||
|
return "*" + sanitizedRest;
|
||||||
|
}
|
||||||
return input
|
return input
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize("NFC") // normalize Unicode
|
.normalize("NFC") // normalize Unicode
|
||||||
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
|
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finalizeSubdomainSanitize(input: string): string {
|
export function finalizeSubdomainSanitize(
|
||||||
|
input: string,
|
||||||
|
allowWildcard = false
|
||||||
|
): string {
|
||||||
if (!input) return "";
|
if (!input) return "";
|
||||||
|
|
||||||
|
// If the input is a valid wildcard and the caller permits it, keep it as-is
|
||||||
|
// (just lowercase the non-wildcard labels).
|
||||||
|
if (allowWildcard && input.startsWith("*")) {
|
||||||
|
const rest = input.slice(1); // everything after the leading "*"
|
||||||
|
const sanitizedRest = rest
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/[^\p{L}\p{N}.-]/gu, "")
|
||||||
|
.replace(/\.{2,}/g, ".")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.replace(/(\.-)|(-\.)/g, ".");
|
||||||
|
const candidate = "*" + sanitizedRest;
|
||||||
|
// Return only if it still forms a valid wildcard after sanitizing
|
||||||
|
return isWildcardSubdomain(candidate) ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
return input
|
return input
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -30,6 +72,7 @@ export function validateByDomainType(
|
|||||||
domainType: {
|
domainType: {
|
||||||
type: "provided-search" | "organization";
|
type: "provided-search" | "organization";
|
||||||
domainType?: "ns" | "cname" | "wildcard";
|
domainType?: "ns" | "cname" | "wildcard";
|
||||||
|
allowWildcard?: boolean;
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!domainType) return false;
|
if (!domainType) return false;
|
||||||
@@ -46,6 +89,12 @@ export function validateByDomainType(
|
|||||||
domainType.domainType === "wildcard"
|
domainType.domainType === "wildcard"
|
||||||
) {
|
) {
|
||||||
if (subdomain === "") return true;
|
if (subdomain === "") return true;
|
||||||
|
|
||||||
|
// Wildcard subdomain validation (only when caller opts in)
|
||||||
|
if (domainType.allowWildcard && subdomain.startsWith("*")) {
|
||||||
|
return isWildcardSubdomain(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
if (!MULTI_LABEL_RE.test(subdomain)) return false;
|
if (!MULTI_LABEL_RE.test(subdomain)) return false;
|
||||||
const labels = subdomain.split(".");
|
const labels = subdomain.split(".");
|
||||||
return labels.every(
|
return labels.every(
|
||||||
@@ -57,10 +106,19 @@ export function validateByDomainType(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isValidSubdomainStructure = (input: string): boolean => {
|
export const isValidSubdomainStructure = (
|
||||||
|
input: string,
|
||||||
|
allowWildcard = false
|
||||||
|
): boolean => {
|
||||||
|
if (!input) return false;
|
||||||
|
|
||||||
|
// A valid wildcard subdomain is structurally valid when the caller allows it
|
||||||
|
if (allowWildcard && input.startsWith("*")) {
|
||||||
|
return isWildcardSubdomain(input);
|
||||||
|
}
|
||||||
|
|
||||||
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
|
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
|
||||||
|
|
||||||
if (!input) return false;
|
|
||||||
if (input.includes("..")) return false;
|
if (input.includes("..")) return false;
|
||||||
|
|
||||||
return input.split(".").every((label) => regex.test(label));
|
return input.split(".").every((label) => regex.test(label));
|
||||||
|
|||||||
Reference in New Issue
Block a user