feat(domains): support apex domains and non-.com TLDs
Loosen the custom-domain regex to accept apex domains (1+ labels) in addition to subdomains; the TLD already allowed any letters. Suppress the CNAME record for apex hostnames since a CNAME is invalid at the zone apex, and surface only the A record there. Drop the frontend apex rejection and the misleading ".com" input suffix, and render the domain-input prefix/suffix addons only when set.
This commit is contained in:
@@ -67,6 +67,7 @@
|
|||||||
"katex": "^0.16.10",
|
"katex": "^0.16.10",
|
||||||
"marked": "^7.0.3",
|
"marked": "^7.0.3",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
"tldts": "^6.1.86",
|
||||||
"zod": "^4.1.8",
|
"zod": "^4.1.8",
|
||||||
"zod-openapi": "^5.4.1"
|
"zod-openapi": "^5.4.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
|
|
||||||
import { env } from '@cio/core/config/env';
|
import { env } from '@cio/core/config/env';
|
||||||
|
import { parse } from 'tldts';
|
||||||
|
|
||||||
export type DomainSetupStatus =
|
export type DomainSetupStatus =
|
||||||
| 'reconnect_required'
|
| 'reconnect_required'
|
||||||
@@ -40,7 +41,7 @@ interface ApproximatedVhost {
|
|||||||
ssl_active_until?: string | null;
|
ssl_active_until?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
|
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
||||||
|
|
||||||
const APPROXIMATED_BASE_URL = 'https://cloud.approximated.app/api';
|
const APPROXIMATED_BASE_URL = 'https://cloud.approximated.app/api';
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ async function approximatedRequest<T>(path: string, init: RequestInit = {}): Pro
|
|||||||
function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] {
|
function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] {
|
||||||
const targetIp = env.APPROXIMATED_DNS_TARGET_IP;
|
const targetIp = env.APPROXIMATED_DNS_TARGET_IP;
|
||||||
const targetCname = env.APPROXIMATED_DNS_TARGET_CNAME;
|
const targetCname = env.APPROXIMATED_DNS_TARGET_CNAME;
|
||||||
|
const isApex = !parse(hostname).subdomain;
|
||||||
|
|
||||||
const records: DomainDnsRecord[] = [];
|
const records: DomainDnsRecord[] = [];
|
||||||
const isResolvingToTarget = Boolean(
|
const isResolvingToTarget = Boolean(
|
||||||
@@ -120,7 +122,9 @@ function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): Do
|
|||||||
if (targetIp) {
|
if (targetIp) {
|
||||||
records.push({ type: 'A', name: hostname, value: targetIp, status });
|
records.push({ type: 'A', name: hostname, value: targetIp, status });
|
||||||
}
|
}
|
||||||
if (targetCname) {
|
|
||||||
|
// A CNAME is invalid at the zone apex, so only offer it for subdomains.
|
||||||
|
if (targetCname && !isApex) {
|
||||||
records.push({ type: 'CNAME', name: hostname, value: targetCname, status });
|
records.push({ type: 'CNAME', name: hostname, value: targetCname, status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +194,7 @@ export function normalizeCustomDomain(domain: string) {
|
|||||||
export function assertSupportedCustomDomain(domain: string) {
|
export function assertSupportedCustomDomain(domain: string) {
|
||||||
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
|
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'Only custom subdomains are supported. Use a subdomain like courses.yourwebsite.com.',
|
'Enter a valid domain like yourwebsite.com or courses.yourwebsite.com.',
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
400,
|
400,
|
||||||
'domain'
|
'domain'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
if (!domainApex) return recordName;
|
if (!domainApex) return recordName;
|
||||||
|
|
||||||
const suffix = '.' + domainApex;
|
const suffix = '.' + domainApex;
|
||||||
if (recordName === domainApex) return recordName;
|
if (recordName === domainApex) return '@';
|
||||||
if (recordName.endsWith(suffix)) return recordName.slice(0, -suffix.length);
|
if (recordName.endsWith(suffix)) return recordName.slice(0, -suffix.length);
|
||||||
|
|
||||||
return recordName;
|
return recordName;
|
||||||
@@ -190,13 +190,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedDomain = sanitizeDomain(customDomain);
|
const sanitizedDomain = sanitizeDomain(customDomain);
|
||||||
const { parse } = await import('tldts/dist/es6/index.js');
|
|
||||||
const details = parse(sanitizedDomain);
|
|
||||||
|
|
||||||
if (!details.subdomain) {
|
|
||||||
errors.customDomain = $t('components.settings.domains.custom_domain_error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) {
|
if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) {
|
||||||
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
|
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
|
||||||
@@ -549,7 +542,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
|
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
|
||||||
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
|
<DomainInput bind:value={customDomain} placeholder="yourwebsite.com" suffix="" disabled={$isFreePlan} />
|
||||||
{#if errors.customDomain}
|
{#if errors.customDomain}
|
||||||
<Field.Error>{errors.customDomain}</Field.Error>
|
<Field.Error>{errors.customDomain}</Field.Error>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,11 +24,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<InputGroup.Root class={cn('ui:w-full', className)} data-slot="domain-input">
|
<InputGroup.Root class={cn('ui:w-full', className)} data-slot="domain-input">
|
||||||
<InputGroup.Addon>
|
{#if prefix}
|
||||||
<InputGroup.Text>{prefix}</InputGroup.Text>
|
<InputGroup.Addon>
|
||||||
</InputGroup.Addon>
|
<InputGroup.Text>{prefix}</InputGroup.Text>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
{/if}
|
||||||
<InputGroup.Input {...restProps} {placeholder} bind:value {disabled} class="!ps-0.5" />
|
<InputGroup.Input {...restProps} {placeholder} bind:value {disabled} class="!ps-0.5" />
|
||||||
<InputGroup.Addon align="inline-end">
|
{#if suffix}
|
||||||
<InputGroup.Text>{suffix}</InputGroup.Text>
|
<InputGroup.Addon align="inline-end">
|
||||||
</InputGroup.Addon>
|
<InputGroup.Text>{suffix}</InputGroup.Text>
|
||||||
|
</InputGroup.Addon>
|
||||||
|
{/if}
|
||||||
</InputGroup.Root>
|
</InputGroup.Root>
|
||||||
|
|||||||
Generated
+3
@@ -144,6 +144,9 @@ importers:
|
|||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.5
|
specifier: ^5.1.5
|
||||||
version: 5.1.6
|
version: 5.1.6
|
||||||
|
tldts:
|
||||||
|
specifier: ^6.1.86
|
||||||
|
version: 6.1.86
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.2.1
|
specifier: 4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user