feat(custom-domains): allow to register a custom domain (without UI) (#9879)
# In this PR - Allow to register a custom domain - Refacto subdomain generation # In other PRs - Add UI to deal with a custom domain - Add logic to work with custom domain
This commit is contained in:
@ -37,6 +37,9 @@ export class AvailableWorkspaceOutput {
|
||||
@Field(() => String)
|
||||
subdomain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo?: string;
|
||||
|
||||
|
||||
@ -7,5 +7,7 @@ export class DomainManagerException extends CustomException {
|
||||
}
|
||||
|
||||
export enum DomainManagerExceptionCode {
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
|
||||
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
|
||||
}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import { createUnionType, Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
class CustomHostnameOwnershipVerificationTxt {
|
||||
@Field(() => String)
|
||||
type: 'txt';
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
|
||||
@Field(() => String)
|
||||
value: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class CustomHostnameOwnershipVerificationHttp {
|
||||
@Field()
|
||||
type: 'http';
|
||||
|
||||
@Field(() => String)
|
||||
body: string;
|
||||
|
||||
@Field(() => String)
|
||||
url: string;
|
||||
}
|
||||
|
||||
const CustomHostnameOwnershipVerification = createUnionType({
|
||||
name: 'OwnershipVerification',
|
||||
types: () =>
|
||||
[
|
||||
CustomHostnameOwnershipVerificationTxt,
|
||||
CustomHostnameOwnershipVerificationHttp,
|
||||
] as const,
|
||||
resolveType(value) {
|
||||
if ('type' in value && value.type === 'txt') {
|
||||
return CustomHostnameOwnershipVerificationTxt;
|
||||
}
|
||||
if ('type' in value && value.type === 'http') {
|
||||
return CustomHostnameOwnershipVerificationHttp;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class CustomHostnameDetails {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
hostname: string;
|
||||
|
||||
@Field(() => [CustomHostnameOwnershipVerification])
|
||||
ownershipVerifications: Array<typeof CustomHostnameOwnershipVerification>;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
status?:
|
||||
| 'active'
|
||||
| 'pending'
|
||||
| 'active_redeploying'
|
||||
| 'moved'
|
||||
| 'pending_deletion'
|
||||
| 'deleted'
|
||||
| 'pending_blocked'
|
||||
| 'pending_migration'
|
||||
| 'pending_provisioned'
|
||||
| 'test_pending'
|
||||
| 'test_active'
|
||||
| 'test_active_apex'
|
||||
| 'test_blocked'
|
||||
| 'test_failed'
|
||||
| 'provisioned'
|
||||
| 'blocked';
|
||||
}
|
||||
@ -2,24 +2,36 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
|
||||
|
||||
@Injectable()
|
||||
export class DomainManagerService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
) {
|
||||
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getFrontUrl() {
|
||||
let baseUrl: URL;
|
||||
@ -105,6 +117,7 @@ export class DomainManagerService {
|
||||
return url;
|
||||
}
|
||||
|
||||
// @Deprecated
|
||||
getWorkspaceSubdomainFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
@ -119,6 +132,24 @@ export class DomainManagerService {
|
||||
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
|
||||
};
|
||||
|
||||
getSubdomainAndHostnameFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
const frontDomain = this.getFrontUrl().hostname;
|
||||
|
||||
const isFrontdomain = originHostname.endsWith(`.${frontDomain}`);
|
||||
|
||||
const subdomain = originHostname.replace(`.${frontDomain}`, '');
|
||||
|
||||
return {
|
||||
subdomain:
|
||||
isFrontdomain && !this.isDefaultSubdomain(subdomain)
|
||||
? subdomain
|
||||
: undefined,
|
||||
hostname: isFrontdomain ? undefined : originHostname,
|
||||
};
|
||||
};
|
||||
|
||||
async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) {
|
||||
return subdomain
|
||||
? await this.workspaceRepository.findOne({
|
||||
@ -180,121 +211,37 @@ export class DomainManagerService {
|
||||
}
|
||||
|
||||
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
|
||||
try {
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
return this.getDefaultWorkspace();
|
||||
}
|
||||
|
||||
const subdomain = this.getWorkspaceSubdomainFromUrl(origin);
|
||||
|
||||
if (!isDefined(subdomain)) return;
|
||||
|
||||
return (
|
||||
(await this.workspaceRepository.findOne({
|
||||
where: { subdomain },
|
||||
relations: ['workspaceSSOIdentityProviders'],
|
||||
})) ?? undefined
|
||||
);
|
||||
} catch (e) {
|
||||
throw new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND,
|
||||
);
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
return this.getDefaultWorkspace();
|
||||
}
|
||||
|
||||
const { subdomain, hostname } = this.getSubdomainAndHostnameFromUrl(origin);
|
||||
|
||||
if (!hostname && !subdomain) return;
|
||||
|
||||
const where = isDefined(hostname) ? { hostname } : { subdomain };
|
||||
|
||||
return (
|
||||
(await this.workspaceRepository.findOne({
|
||||
where,
|
||||
relations: ['workspaceSSOIdentityProviders'],
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
private generateRandomSubdomain(): string {
|
||||
const prefixes = [
|
||||
'cool',
|
||||
'smart',
|
||||
'fast',
|
||||
'bright',
|
||||
'shiny',
|
||||
'happy',
|
||||
'funny',
|
||||
'clever',
|
||||
'brave',
|
||||
'kind',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'calm',
|
||||
'silent',
|
||||
'lucky',
|
||||
'fierce',
|
||||
'swift',
|
||||
'mighty',
|
||||
'noble',
|
||||
'bold',
|
||||
'wise',
|
||||
'eager',
|
||||
'joyful',
|
||||
'glad',
|
||||
'zany',
|
||||
'witty',
|
||||
'bouncy',
|
||||
'graceful',
|
||||
'colorful',
|
||||
];
|
||||
const suffixes = [
|
||||
'raccoon',
|
||||
'panda',
|
||||
'whale',
|
||||
'tiger',
|
||||
'dolphin',
|
||||
'eagle',
|
||||
'penguin',
|
||||
'owl',
|
||||
'fox',
|
||||
'wolf',
|
||||
'lion',
|
||||
'bear',
|
||||
'hawk',
|
||||
'shark',
|
||||
'sparrow',
|
||||
'moose',
|
||||
'lynx',
|
||||
'falcon',
|
||||
'rabbit',
|
||||
'hedgehog',
|
||||
'monkey',
|
||||
'horse',
|
||||
'koala',
|
||||
'kangaroo',
|
||||
'elephant',
|
||||
'giraffe',
|
||||
'panther',
|
||||
'crocodile',
|
||||
'seal',
|
||||
'octopus',
|
||||
];
|
||||
private extractSubdomain(params?: { email?: string; displayName?: string }) {
|
||||
if (params?.email) {
|
||||
return getSubdomainFromEmail(params.email);
|
||||
}
|
||||
|
||||
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
|
||||
return `${randomPrefix}-${randomSuffix}`;
|
||||
}
|
||||
|
||||
private getSubdomainNameByEmail(email?: string) {
|
||||
if (!isDefined(email) || !isWorkEmail(email)) return;
|
||||
|
||||
return getDomainNameByEmail(email);
|
||||
}
|
||||
|
||||
private getSubdomainNameByDisplayName(displayName?: string) {
|
||||
if (!isDefined(displayName)) return;
|
||||
const displayNameWords = displayName.match(/(\w| |\d)+/g);
|
||||
|
||||
if (displayNameWords) {
|
||||
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
|
||||
if (params?.displayName) {
|
||||
return getSubdomainNameFromDisplayName(params.displayName);
|
||||
}
|
||||
}
|
||||
|
||||
async generateSubdomain(params?: { email?: string; displayName?: string }) {
|
||||
const subdomain =
|
||||
this.getSubdomainNameByEmail(params?.email) ??
|
||||
this.getSubdomainNameByDisplayName(params?.displayName) ??
|
||||
this.generateRandomSubdomain();
|
||||
this.extractSubdomain(params) ?? generateRandomSubdomain();
|
||||
|
||||
const existingWorkspaceCount = await this.workspaceRepository.countBy({
|
||||
subdomain,
|
||||
@ -302,4 +249,133 @@ export class DomainManagerService {
|
||||
|
||||
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
|
||||
}
|
||||
|
||||
async registerCustomHostname(hostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (await this.getCustomHostnameDetails(hostname)) {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname,
|
||||
ssl: {
|
||||
method: 'txt',
|
||||
type: 'dv',
|
||||
settings: {
|
||||
http2: 'on',
|
||||
min_tls_version: '1.2',
|
||||
tls_1_3: 'on',
|
||||
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
|
||||
early_hints: 'on',
|
||||
},
|
||||
bundle_method: 'ubiquitous',
|
||||
wildcard: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomHostnameDetails(
|
||||
hostname: string,
|
||||
): Promise<CustomHostnameDetails | undefined> {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const response = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname,
|
||||
});
|
||||
|
||||
if (response.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
return {
|
||||
id: response.result[0].id,
|
||||
hostname: response.result[0].hostname,
|
||||
status: response.result[0].status,
|
||||
ownershipVerifications: [
|
||||
response.result[0].ownership_verification,
|
||||
response.result[0].ownership_verification_http,
|
||||
].reduce(
|
||||
(acc, ownershipVerification) => {
|
||||
if (!ownershipVerification) return acc;
|
||||
|
||||
if (
|
||||
'http_body' in ownershipVerification &&
|
||||
'http_url' in ownershipVerification &&
|
||||
ownershipVerification.http_body &&
|
||||
ownershipVerification.http_url
|
||||
) {
|
||||
acc.push({
|
||||
type: 'http',
|
||||
body: ownershipVerification.http_body,
|
||||
url: ownershipVerification.http_url,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
'type' in ownershipVerification &&
|
||||
ownershipVerification.type === 'txt' &&
|
||||
ownershipVerification.value &&
|
||||
ownershipVerification.name
|
||||
) {
|
||||
acc.push({
|
||||
type: 'txt',
|
||||
value: ownershipVerification.value,
|
||||
name: ownershipVerification.name,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as CustomHostnameDetails['ownershipVerifications'],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// should never append. error 5xx
|
||||
throw new Error('More than one custom hostname found in cloudflare');
|
||||
}
|
||||
|
||||
async updateCustomHostname(fromHostname: string, toHostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostname =
|
||||
await this.getCustomHostnameDetails(fromHostname);
|
||||
|
||||
if (fromCustomHostname) {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return await this.registerCustomHostname(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(hostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostname = await this.getCustomHostnameDetails(hostname);
|
||||
|
||||
if (customHostname) {
|
||||
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomHostname(customHostnameId: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
return this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
|
||||
describe('generateRandomSubdomain', () => {
|
||||
it('should return a string in the format "prefix-suffix"', () => {
|
||||
const result = generateRandomSubdomain();
|
||||
|
||||
expect(result).toMatch(/^[a-z]+-[a-z]+$/);
|
||||
});
|
||||
|
||||
it('should generate different results on consecutive calls', () => {
|
||||
const result1 = generateRandomSubdomain();
|
||||
const result2 = generateRandomSubdomain();
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
|
||||
describe('getSubdomainFromEmail', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined if email is not defined', () => {
|
||||
const result = getSubdomainFromEmail(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if email is not a work email', () => {
|
||||
const result = getSubdomainFromEmail('test@gmail.com');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the domain name if email is valid and a work email', () => {
|
||||
const result = getSubdomainFromEmail('test@twenty.com');
|
||||
|
||||
expect(result).toBe('twenty');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
|
||||
describe('getSubdomainNameFromDisplayName', () => {
|
||||
it('should return a hyphen-separated, lowercase subdomain name without spaces for a valid display name', () => {
|
||||
const result = getSubdomainNameFromDisplayName('My Display Name 123');
|
||||
|
||||
expect(result).toBe('my-display-name-123');
|
||||
});
|
||||
|
||||
it('should return undefined if displayName is undefined', () => {
|
||||
const result = getSubdomainNameFromDisplayName(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle display names with special characters by removing them but keeping words and numbers', () => {
|
||||
const result = getSubdomainNameFromDisplayName('Hello!@# World$%^ 2023');
|
||||
|
||||
expect(result).toBe('hello-world-2023');
|
||||
});
|
||||
|
||||
it('should return a single word in lowercase if displayName consists of one valid word', () => {
|
||||
const result = getSubdomainNameFromDisplayName('SingleWord');
|
||||
|
||||
expect(result).toBe('singleword');
|
||||
});
|
||||
|
||||
it('should return undefined when displayName contains only special characters', () => {
|
||||
const result = getSubdomainNameFromDisplayName('!@#$%^&*()');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle display names with multiple spaces by removing them', () => {
|
||||
const result = getSubdomainNameFromDisplayName(
|
||||
' Spaced Out Name ',
|
||||
);
|
||||
|
||||
expect(result).toBe('spaced-out-name');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
export const generateRandomSubdomain = () => {
|
||||
const prefixes = [
|
||||
'cool',
|
||||
'smart',
|
||||
'fast',
|
||||
'bright',
|
||||
'shiny',
|
||||
'happy',
|
||||
'funny',
|
||||
'clever',
|
||||
'brave',
|
||||
'kind',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'calm',
|
||||
'silent',
|
||||
'lucky',
|
||||
'fierce',
|
||||
'swift',
|
||||
'mighty',
|
||||
'noble',
|
||||
'bold',
|
||||
'wise',
|
||||
'eager',
|
||||
'joyful',
|
||||
'glad',
|
||||
'zany',
|
||||
'witty',
|
||||
'bouncy',
|
||||
'graceful',
|
||||
'colorful',
|
||||
];
|
||||
const suffixes = [
|
||||
'raccoon',
|
||||
'panda',
|
||||
'whale',
|
||||
'tiger',
|
||||
'dolphin',
|
||||
'eagle',
|
||||
'penguin',
|
||||
'owl',
|
||||
'fox',
|
||||
'wolf',
|
||||
'lion',
|
||||
'bear',
|
||||
'hawk',
|
||||
'shark',
|
||||
'sparrow',
|
||||
'moose',
|
||||
'lynx',
|
||||
'falcon',
|
||||
'rabbit',
|
||||
'hedgehog',
|
||||
'monkey',
|
||||
'horse',
|
||||
'koala',
|
||||
'kangaroo',
|
||||
'elephant',
|
||||
'giraffe',
|
||||
'panther',
|
||||
'crocodile',
|
||||
'seal',
|
||||
'octopus',
|
||||
];
|
||||
|
||||
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
|
||||
return `${randomPrefix}-${randomSuffix}`;
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
|
||||
export const getSubdomainFromEmail = (email?: string) => {
|
||||
if (!isDefined(email) || !isWorkEmail(email)) return;
|
||||
|
||||
const domain = getDomainNameByEmail(email);
|
||||
|
||||
return domain.split('.')[0];
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
|
||||
export const getSubdomainNameFromDisplayName = (displayName?: string) => {
|
||||
if (!isDefined(displayName)) return;
|
||||
const displayNameWords = displayName.match(/(\w|\d)+/g);
|
||||
|
||||
if (displayNameWords) {
|
||||
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
|
||||
const isCloudflareInstanceDefined = (
|
||||
cloudflareInstance: Cloudflare | undefined | null,
|
||||
): asserts cloudflareInstance is Cloudflare => {
|
||||
if (!cloudflareInstance) {
|
||||
throw new DomainManagerException(
|
||||
'Cloudflare instance is not defined',
|
||||
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const domainManagerValidator: {
|
||||
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
|
||||
} = {
|
||||
isCloudflareInstanceDefined,
|
||||
};
|
||||
@ -242,6 +242,14 @@ export class EnvironmentVariables {
|
||||
@IsBoolean()
|
||||
IS_MULTIWORKSPACE_ENABLED = false;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((env) => env.CLOUDFLARE_ZONE_ID)
|
||||
CLOUDFLARE_API_KEY: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
|
||||
CLOUDFLARE_ZONE_ID: string;
|
||||
|
||||
// Custom Code Engine
|
||||
@IsEnum(ServerlessDriverType)
|
||||
@IsOptional()
|
||||
|
||||
@ -58,4 +58,7 @@ export class PublicWorkspaceDataOutput {
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: Workspace['subdomain'];
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname: Workspace['hostname'];
|
||||
}
|
||||
|
||||
@ -10,11 +10,6 @@ import {
|
||||
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput {
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
domainName?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ -105,6 +100,14 @@ export class UpdateWorkspaceInput {
|
||||
])
|
||||
subdomain?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Matches(
|
||||
/^(([a-zA-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-]*[A-Za-z0-9])$/,
|
||||
)
|
||||
hostname?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@ -23,6 +23,8 @@ import {
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -40,43 +42,96 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
}
|
||||
|
||||
private async validateSubdomainUpdate(newSubdomain: string) {
|
||||
const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain);
|
||||
|
||||
if (
|
||||
!subdomainAvailable ||
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN') === newSubdomain
|
||||
) {
|
||||
throw new WorkspaceException(
|
||||
'Subdomain already taken',
|
||||
WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setCustomDomain(workspace: Workspace, hostname: string) {
|
||||
const existingWorkspace = await this.workspaceRepository.findOne({
|
||||
where: { hostname },
|
||||
});
|
||||
|
||||
if (existingWorkspace && existingWorkspace.id !== workspace.id) {
|
||||
throw new WorkspaceException(
|
||||
'Domain already taken',
|
||||
WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
hostname &&
|
||||
workspace.hostname !== hostname &&
|
||||
isDefined(workspace.hostname)
|
||||
) {
|
||||
await this.domainManagerService.updateCustomHostname(
|
||||
workspace.hostname,
|
||||
hostname,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
hostname &&
|
||||
workspace.hostname !== hostname &&
|
||||
!isDefined(workspace.hostname)
|
||||
) {
|
||||
await this.domainManagerService.registerCustomHostname(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
async updateWorkspaceById(payload: Partial<Workspace> & { id: string }) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.id,
|
||||
});
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
|
||||
const subdomainAvailable = await this.isSubdomainAvailable(
|
||||
payload.subdomain,
|
||||
);
|
||||
|
||||
if (
|
||||
!subdomainAvailable ||
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN') === payload.subdomain
|
||||
) {
|
||||
throw new WorkspaceException(
|
||||
'Subdomain already taken',
|
||||
WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN,
|
||||
);
|
||||
}
|
||||
await this.validateSubdomainUpdate(payload.subdomain);
|
||||
}
|
||||
|
||||
return this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...payload,
|
||||
});
|
||||
let customDomainRegistered = false;
|
||||
|
||||
if (payload.hostname === null && isDefined(workspace.hostname)) {
|
||||
await this.domainManagerService.deleteCustomHostnameByHostnameSilently(
|
||||
workspace.hostname,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.hostname && workspace.hostname !== payload.hostname) {
|
||||
await this.setCustomDomain(workspace, payload.hostname);
|
||||
customDomainRegistered = true;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...payload,
|
||||
});
|
||||
} catch (error) {
|
||||
// revert custom domain registration on error
|
||||
if (payload.hostname && customDomainRegistered) {
|
||||
this.domainManagerService
|
||||
.deleteCustomHostnameByHostnameSilently(payload.hostname)
|
||||
.catch(() => {
|
||||
// send to sentry
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async activateWorkspace(
|
||||
|
||||
@ -9,5 +9,6 @@ export class WorkspaceException extends CustomException {
|
||||
export enum WorkspaceExceptionCode {
|
||||
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
|
||||
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
|
||||
DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
}
|
||||
|
||||
@ -33,10 +33,6 @@ import {
|
||||
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
|
||||
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
|
||||
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
|
||||
import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@ -48,6 +44,7 @@ import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
@ -218,21 +215,27 @@ export class WorkspaceResolver {
|
||||
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
|
||||
}
|
||||
|
||||
@Query(() => CustomHostnameDetails, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async getHostnameDetails(
|
||||
@AuthWorkspace() { hostname }: Workspace,
|
||||
): Promise<CustomHostnameDetails | undefined> {
|
||||
if (!hostname) return undefined;
|
||||
|
||||
return await this.domainManagerService.getCustomHostnameDetails(hostname);
|
||||
}
|
||||
|
||||
@Query(() => PublicWorkspaceDataOutput)
|
||||
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
|
||||
async getPublicWorkspaceDataBySubdomain(
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<PublicWorkspaceDataOutput | undefined> {
|
||||
try {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
let workspaceLogoWithToken = '';
|
||||
|
||||
@ -261,6 +264,7 @@ export class WorkspaceResolver {
|
||||
logo: workspaceLogoWithToken,
|
||||
displayName: workspace.displayName,
|
||||
subdomain: workspace.subdomain,
|
||||
hostname: workspace.hostname,
|
||||
authProviders: getAuthProvidersByWorkspace({
|
||||
workspace,
|
||||
systemEnabledProviders,
|
||||
|
||||
Reference in New Issue
Block a user