Implement Cloudflare custom domain management flow
- Added support for connecting, refreshing, and removing custom domains through Cloudflare. - Updated API routes and validation schemas to handle new domain actions. - Refactored domain management logic in the dashboard to accommodate Cloudflare integration. - Enhanced user experience with clear status messages and DNS instructions for domain setup. - Updated translations and UI components to reflect new domain management features.
This commit is contained in:
@@ -8,6 +8,10 @@ const envSchema = z.object({
|
||||
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
|
||||
CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(),
|
||||
CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(),
|
||||
CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(),
|
||||
CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN: z.string().optional(),
|
||||
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
|
||||
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
|
||||
OBJECT_STORAGE_REGION: z.string().optional(),
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '@api/services/org/domain';
|
||||
|
||||
import { ZDomainActionRequest } from '@cio/utils/validation/organization';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import {
|
||||
assertSupportedCustomDomain,
|
||||
connectDomain,
|
||||
normalizeCustomDomain,
|
||||
refreshDomain,
|
||||
removeDomain
|
||||
} from '@api/services/org/domain';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { updateOrg } from '@api/services/organization';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZDomainRequest = z.object({
|
||||
params: z.object({
|
||||
key: z.enum(['verify_domain', 'add_domain', 'remove_domain']),
|
||||
domain: z.string().min(1)
|
||||
})
|
||||
});
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const domainRouter = new Hono()
|
||||
/**
|
||||
* POST /domain
|
||||
* Handles domain operations: verify, add, or remove domain
|
||||
* Requires authentication
|
||||
* Handles domain operations: connect, refresh, or remove
|
||||
* Requires authentication and organization admin access
|
||||
*/
|
||||
.post('/', authMiddleware, async (c) => {
|
||||
.post('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZDomainActionRequest), async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const validatedData = ZDomainRequest.parse(body);
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { action, domain } = c.req.valid('json');
|
||||
|
||||
const { key, domain } = validatedData.params;
|
||||
const normalizedDomain = normalizeCustomDomain(domain);
|
||||
assertSupportedCustomDomain(normalizedDomain);
|
||||
|
||||
if (domain.includes('classroomio.com')) {
|
||||
return c.json({ success: false }, 400);
|
||||
}
|
||||
switch (action) {
|
||||
case 'connect': {
|
||||
const result = await connectDomain(normalizedDomain);
|
||||
await updateOrg(orgId, {
|
||||
customDomain: normalizedDomain,
|
||||
isCustomDomainVerified: result.verified
|
||||
});
|
||||
|
||||
switch (key) {
|
||||
case 'verify_domain': {
|
||||
const configResponse = await getConfigResponse(domain);
|
||||
return c.json({ success: true, verified: !configResponse.misconfigured }, 200);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
}
|
||||
case 'add_domain': {
|
||||
const addDomainResponse = await addDomainToVercel(domain);
|
||||
return c.json({ success: true, data: addDomainResponse }, 200);
|
||||
case 'refresh': {
|
||||
const result = await refreshDomain(normalizedDomain);
|
||||
await updateOrg(orgId, {
|
||||
customDomain: normalizedDomain,
|
||||
isCustomDomainVerified: result.verified
|
||||
});
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
}
|
||||
case 'remove_domain': {
|
||||
const removeDomainResponse = await removeDomainFromVercelProject(domain);
|
||||
return c.json({ success: true, data: removeDomainResponse }, 200);
|
||||
case 'remove': {
|
||||
const result = await removeDomain(normalizedDomain);
|
||||
await updateOrg(orgId, {
|
||||
customDomain: null,
|
||||
isCustomDomainVerified: false
|
||||
});
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
}
|
||||
default:
|
||||
return c.json({ success: false, message: 'Invalid request' }, 400);
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to process domain request');
|
||||
|
||||
@@ -1,48 +1,325 @@
|
||||
// Code from https://github.com/vercel/platforms/blob/1ed55b13bcae97b037c69ec40b4c32df21c2412c/lib/domains.ts
|
||||
|
||||
import { env } from '@api/config/env';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
|
||||
export const addDomainToVercel = async (domain: string) => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v10/projects/${env.PROJECT_ID_VERCEL}/domains${
|
||||
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
|
||||
}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: domain
|
||||
})
|
||||
export type DomainSetupStatus =
|
||||
| 'reconnect_required'
|
||||
| 'pending_dns'
|
||||
| 'pending_verification'
|
||||
| 'verified'
|
||||
| 'removed'
|
||||
| 'error';
|
||||
|
||||
export interface DomainDnsRecord {
|
||||
type: 'CNAME' | 'TXT';
|
||||
name: string;
|
||||
value: string;
|
||||
status: 'pending' | 'active';
|
||||
}
|
||||
|
||||
export interface DomainSetupResult {
|
||||
hostname: string;
|
||||
status: DomainSetupStatus;
|
||||
verified: boolean;
|
||||
reconnectRequired: boolean;
|
||||
message: string;
|
||||
provider: 'cloudflare';
|
||||
dnsRecords: DomainDnsRecord[];
|
||||
}
|
||||
|
||||
interface CloudflareApiError {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CloudflareEnvelope<T> {
|
||||
success: boolean;
|
||||
errors: CloudflareApiError[];
|
||||
messages: Array<{ code: number; message: string }>;
|
||||
result: T;
|
||||
}
|
||||
|
||||
interface CloudflareValidationRecord {
|
||||
status?: string;
|
||||
txt_name?: string;
|
||||
txt_value?: string;
|
||||
}
|
||||
|
||||
interface CloudflareCustomHostname {
|
||||
id: string;
|
||||
hostname: string;
|
||||
status?: string;
|
||||
ownership_verification?: {
|
||||
type?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
};
|
||||
ssl?: {
|
||||
status?: string;
|
||||
validation_records?: CloudflareValidationRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
|
||||
|
||||
function ensureCloudflareConfig() {
|
||||
const missing = [
|
||||
['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN],
|
||||
['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID],
|
||||
['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET]
|
||||
].filter(([, value]) => !value);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new AppError(
|
||||
`Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`,
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCloudflareBasePath() {
|
||||
ensureCloudflareConfig();
|
||||
return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`;
|
||||
}
|
||||
|
||||
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${getCloudflareBasePath()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers ?? {})
|
||||
}
|
||||
).then((res) => res.json());
|
||||
};
|
||||
});
|
||||
|
||||
export const removeDomainFromVercelProject = async (domain: string) => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${
|
||||
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
|
||||
}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`
|
||||
},
|
||||
method: 'DELETE'
|
||||
const payload = (await response.json()) as CloudflareEnvelope<T>;
|
||||
|
||||
if (!response.ok || !payload.success) {
|
||||
const message =
|
||||
payload.errors?.map((error) => error.message).join(', ') ||
|
||||
payload.messages?.map((item) => item.message).join(', ') ||
|
||||
'Cloudflare domain request failed';
|
||||
|
||||
throw new AppError(
|
||||
message,
|
||||
response.status === 409 ? ErrorCodes.CONFLICT : ErrorCodes.INTERNAL_ERROR,
|
||||
(response.status || 500) as 400 | 401 | 403 | 404 | 409 | 422 | 500
|
||||
);
|
||||
}
|
||||
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function dedupeDnsRecords(records: DomainDnsRecord[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return records.filter((record) => {
|
||||
const key = `${record.type}:${record.name}:${record.value}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
).then((res) => res.json());
|
||||
};
|
||||
|
||||
export const getConfigResponse = async (domain: string) => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v6/domains/${domain}/config${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`,
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] {
|
||||
const records: DomainDnsRecord[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
type: 'CNAME',
|
||||
name: hostname,
|
||||
value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!,
|
||||
status: customHostname?.status === 'active' ? 'active' : 'pending'
|
||||
}
|
||||
];
|
||||
|
||||
if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) {
|
||||
records.push({
|
||||
type: 'TXT',
|
||||
name: customHostname.ownership_verification.name,
|
||||
value: customHostname.ownership_verification.value,
|
||||
status: customHostname.status === 'active' ? 'active' : 'pending'
|
||||
});
|
||||
}
|
||||
|
||||
customHostname?.ssl?.validation_records?.forEach((record) => {
|
||||
if (!record.txt_name || !record.txt_value) {
|
||||
return;
|
||||
}
|
||||
|
||||
records.push({
|
||||
type: 'TXT',
|
||||
name: record.txt_name,
|
||||
value: record.txt_value,
|
||||
status: record.status === 'active' ? 'active' : 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
return dedupeDnsRecords(records);
|
||||
}
|
||||
|
||||
function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus {
|
||||
if (!customHostname) {
|
||||
return 'reconnect_required';
|
||||
}
|
||||
|
||||
if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') {
|
||||
return 'verified';
|
||||
}
|
||||
|
||||
const hasOwnershipRecord = Boolean(
|
||||
customHostname.ownership_verification?.name && customHostname.ownership_verification?.value
|
||||
);
|
||||
|
||||
if (hasOwnershipRecord) {
|
||||
return 'pending_dns';
|
||||
}
|
||||
|
||||
const hasSslValidationRecords = Boolean(
|
||||
customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value)
|
||||
);
|
||||
|
||||
if (hasSslValidationRecords || customHostname.ssl?.status) {
|
||||
return 'pending_verification';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function getStatusMessage(status: DomainSetupStatus) {
|
||||
switch (status) {
|
||||
case 'reconnect_required':
|
||||
return 'Reconnect this custom domain to generate new Cloudflare setup records.';
|
||||
case 'pending_dns':
|
||||
return 'Add the DNS records below, then refresh verification.';
|
||||
case 'pending_verification':
|
||||
return 'DNS records were found. SSL issuance or validation is still in progress.';
|
||||
case 'verified':
|
||||
return 'Custom domain verified.';
|
||||
case 'removed':
|
||||
return 'Custom domain removed.';
|
||||
case 'error':
|
||||
default:
|
||||
return 'Custom domain exists, but verification is not complete yet.';
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult {
|
||||
const status = mapDomainStatus(customHostname);
|
||||
|
||||
return {
|
||||
hostname,
|
||||
status,
|
||||
verified: status === 'verified',
|
||||
reconnectRequired: status === 'reconnect_required',
|
||||
message: getStatusMessage(status),
|
||||
provider: 'cloudflare',
|
||||
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomDomain(domain: string) {
|
||||
return domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.split('/')[0]
|
||||
.replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function assertSupportedCustomDomain(domain: string) {
|
||||
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
|
||||
throw new AppError(
|
||||
'Only custom subdomains are supported. Use a subdomain like courses.yourwebsite.com.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'domain'
|
||||
);
|
||||
}
|
||||
|
||||
if (domain.includes('classroomio.com')) {
|
||||
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomHostnameByDomain(hostname: string) {
|
||||
const results = await cloudflareRequest<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
async function createCustomHostname(hostname: string) {
|
||||
const body: Record<string, unknown> = {
|
||||
hostname,
|
||||
ssl: {
|
||||
method: 'txt',
|
||||
type: 'dv'
|
||||
}
|
||||
};
|
||||
|
||||
if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) {
|
||||
body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN;
|
||||
}
|
||||
|
||||
return cloudflareRequest<CloudflareCustomHostname>('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) {
|
||||
return cloudflareRequest<CloudflareCustomHostname>(`/${customHostname.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ssl: {
|
||||
method: 'txt',
|
||||
type: 'dv'
|
||||
}
|
||||
}
|
||||
).then((res) => res.json());
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectDomain(hostname: string): Promise<DomainSetupResult> {
|
||||
const existing = await getCustomHostnameByDomain(hostname);
|
||||
const customHostname = existing ?? (await createCustomHostname(hostname));
|
||||
|
||||
return toDomainSetupResult(hostname, customHostname);
|
||||
}
|
||||
|
||||
export async function refreshDomain(hostname: string): Promise<DomainSetupResult> {
|
||||
const existing = await getCustomHostnameByDomain(hostname);
|
||||
|
||||
if (!existing) {
|
||||
return toDomainSetupResult(hostname, null);
|
||||
}
|
||||
|
||||
if (existing.status === 'active' || existing.ssl?.status === 'active') {
|
||||
return toDomainSetupResult(hostname, existing);
|
||||
}
|
||||
|
||||
const refreshed = await revalidateCustomHostname(existing);
|
||||
return toDomainSetupResult(hostname, refreshed);
|
||||
}
|
||||
|
||||
export async function removeDomain(hostname: string): Promise<DomainSetupResult> {
|
||||
const existing = await getCustomHostnameByDomain(hostname);
|
||||
|
||||
if (existing) {
|
||||
await cloudflareRequest<null>(`/${existing.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hostname,
|
||||
status: 'removed',
|
||||
verified: false,
|
||||
reconnectRequired: false,
|
||||
message: getStatusMessage('removed'),
|
||||
provider: 'cloudflare',
|
||||
dnsRecords: []
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user