14240 extensibility ability to create multiple custom domains for each workspace 2 (#14307)
Adds a public-domain core-module. Reorganize custom-domain files properly
This commit is contained in:
+3
@@ -169,6 +169,9 @@ export class ApprovedAccessDomainService {
|
||||
throw new ApprovedAccessDomainException(
|
||||
'Approved access domain already registered.',
|
||||
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_REGISTERED,
|
||||
{
|
||||
userFriendlyMessage: t`Approved access domain already registered.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
|
||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
|
||||
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
|
||||
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { ClientConfigModule } from './client-config/client-config.module';
|
||||
@@ -79,6 +81,8 @@ import { FileModule } from './file/file.module';
|
||||
WorkspaceInvitationModule,
|
||||
WorkspaceSSOModule,
|
||||
ApprovedAccessDomainModule,
|
||||
PublicDomainModule,
|
||||
DnsManagerModule,
|
||||
PostgresCredentialsModule,
|
||||
WorkflowApiModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type Request } from 'express';
|
||||
|
||||
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
describe('DnsCloudflareController - customHostnameWebhooks', () => {
|
||||
let controller: DnsCloudflareController;
|
||||
let dnsManagerService: DnsManagerService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DnsCloudflareController],
|
||||
providers: [
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
handleCustomDomainActivation: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DnsManagerService,
|
||||
useValue: {
|
||||
isHostnameWorking: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: HttpExceptionHandlerService,
|
||||
useValue: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ExceptionHandlerService,
|
||||
useValue: {
|
||||
captureExceptions: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<DnsCloudflareController>(DnsCloudflareController);
|
||||
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
});
|
||||
|
||||
it('should return if hostname is missing', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: {} } },
|
||||
} as unknown as Request;
|
||||
|
||||
await controller.customHostnameWebhooks(req);
|
||||
|
||||
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return if wrong alert_type', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { alert_type: 'wrong_alert_type', data: { data: {} } },
|
||||
} as unknown as Request;
|
||||
|
||||
await controller.customHostnameWebhooks(req);
|
||||
|
||||
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update workspace for a valid hostname and save changes', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: {
|
||||
alert_type: 'custom_ssl_certificate_event_type',
|
||||
data: { data: { hostname: 'example.com' } },
|
||||
},
|
||||
} as unknown as Request;
|
||||
|
||||
jest.spyOn(dnsManagerService, 'isHostnameWorking').mockResolvedValue(true);
|
||||
|
||||
await controller.customHostnameWebhooks(req);
|
||||
|
||||
expect(dnsManagerService.isHostnameWorking).toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
domainManagerService.handleCustomDomainActivation,
|
||||
).toHaveBeenCalledWith({
|
||||
customDomain: 'example.com',
|
||||
isCustomDomainWorking: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
|
||||
|
||||
@Controller()
|
||||
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
|
||||
export class DnsCloudflareController {
|
||||
constructor(
|
||||
protected readonly domainManagerService: DomainManagerService,
|
||||
protected readonly dnsManagerService: DnsManagerService,
|
||||
) {}
|
||||
|
||||
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
|
||||
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
|
||||
async customHostnameWebhooks(@Req() req: Request) {
|
||||
const alertType = req.body?.alert_type;
|
||||
|
||||
const hostname = req.body?.data?.data?.hostname;
|
||||
|
||||
if (alertType !== 'custom_ssl_certificate_event_type' || !hostname) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isCustomDomainWorking =
|
||||
await this.dnsManagerService.isHostnameWorking(hostname);
|
||||
|
||||
await this.domainManagerService.handleCustomDomainActivation({
|
||||
customDomain: hostname,
|
||||
isCustomDomainWorking,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DomainManagerModule],
|
||||
providers: [DnsManagerService],
|
||||
controllers: [DnsCloudflareController],
|
||||
exports: [DnsManagerService],
|
||||
})
|
||||
export class DnsManagerModule {}
|
||||
+5
-5
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType()
|
||||
class CustomDomainRecord {
|
||||
class DomainRecord {
|
||||
@Field(() => String)
|
||||
validationType: 'ssl' | 'redirection';
|
||||
|
||||
@@ -21,13 +21,13 @@ class CustomDomainRecord {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CustomDomainValidRecords {
|
||||
export class DomainValidRecords {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
customDomain: string;
|
||||
domain: string;
|
||||
|
||||
@Field(() => [CustomDomainRecord])
|
||||
records: Array<CustomDomainRecord>;
|
||||
@Field(() => [DomainRecord])
|
||||
records: Array<DomainRecord>;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Catch, type ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
DnsManagerException,
|
||||
DnsManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
|
||||
|
||||
@Catch(DnsManagerException)
|
||||
export class DnsManagerExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: DnsManagerException) {
|
||||
switch (exception.code) {
|
||||
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
|
||||
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
|
||||
case DnsManagerExceptionCode.INVALID_INPUT_DATA:
|
||||
case DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED:
|
||||
case DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND:
|
||||
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
|
||||
throw exception;
|
||||
default: {
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export class DnsManagerException extends CustomException<
|
||||
keyof typeof DnsManagerExceptionCode,
|
||||
true
|
||||
> {}
|
||||
|
||||
export const DnsManagerExceptionCode = appendCommonExceptionCode({
|
||||
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
|
||||
HOSTNAME_NOT_REGISTERED: 'HOSTNAME_NOT_REGISTERED',
|
||||
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
MULTIPLE_HOSTNAMES_FOUND: 'MULTIPLE_HOSTNAMES_FOUND',
|
||||
MISSING_PUBLIC_DOMAIN_URL: 'MISSING_PUBLIC_DOMAIN_URL',
|
||||
} as const);
|
||||
+1
-2
@@ -3,8 +3,7 @@ import { type ExecutionContext } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
import { CloudflareSecretMatchGuard } from './cloudflare-secret.guard';
|
||||
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
|
||||
|
||||
describe('CloudflareSecretMatchGuard.canActivate', () => {
|
||||
let guard: CloudflareSecretMatchGuard;
|
||||
+112
-57
@@ -6,23 +6,23 @@ import { type CustomHostnameCreateResponse } from 'cloudflare/resources/custom-h
|
||||
import { AuditContextMock } from 'test/utils/audit-context.mock';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { DnsManagerException } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
|
||||
|
||||
jest.mock('cloudflare');
|
||||
|
||||
describe('CustomDomainService', () => {
|
||||
let customDomainService: CustomDomainService;
|
||||
describe('DnsManagerService', () => {
|
||||
let dnsManagerService: DnsManagerService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CustomDomainService,
|
||||
DnsManagerService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
@@ -39,6 +39,7 @@ describe('CustomDomainService', () => {
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
getBaseUrl: jest.fn(),
|
||||
getPublicDomainUrl: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -50,12 +51,12 @@ describe('CustomDomainService', () => {
|
||||
],
|
||||
}).compile();
|
||||
|
||||
customDomainService = module.get<CustomDomainService>(CustomDomainService);
|
||||
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
|
||||
(customDomainService as any).cloudflareClient = {
|
||||
(dnsManagerService as any).cloudflareClient = {
|
||||
customHostnames: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
@@ -70,31 +71,28 @@ describe('CustomDomainService', () => {
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockApiKey);
|
||||
|
||||
const instance = new CustomDomainService(
|
||||
twentyConfigService,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
const instance = new DnsManagerService(twentyConfigService, {} as any);
|
||||
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
|
||||
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
|
||||
expect(instance.cloudflareClient).toBeDefined();
|
||||
});
|
||||
|
||||
describe('registerCustomDomain', () => {
|
||||
describe('registerHostname', () => {
|
||||
it('should throw an error when the hostname is already registered', async () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({} as any);
|
||||
.spyOn(dnsManagerService, 'getHostnameId')
|
||||
.mockResolvedValueOnce('hostname-id');
|
||||
|
||||
await expect(
|
||||
customDomainService.registerCustomDomain(customDomain),
|
||||
).rejects.toThrow(DomainManagerException);
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
dnsManagerService.registerHostname(customDomain),
|
||||
).rejects.toThrow(DnsManagerException);
|
||||
|
||||
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
|
||||
customDomain,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -108,12 +106,13 @@ describe('CustomDomainService', () => {
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.spyOn(dnsManagerService, 'getHostnameId')
|
||||
.mockResolvedValueOnce(undefined);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await customDomainService.registerCustomDomain(customDomain);
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await dnsManagerService.registerHostname(customDomain);
|
||||
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
zone_id: 'test-zone-id',
|
||||
@@ -123,7 +122,7 @@ describe('CustomDomainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomDomainDetails', () => {
|
||||
describe('getHostnameWithRecords', () => {
|
||||
it('should return undefined if no custom domain details are found', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const cloudflareMock = {
|
||||
@@ -133,10 +132,12 @@ describe('CustomDomainService', () => {
|
||||
};
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result =
|
||||
await customDomainService.getCustomDomainDetails(customDomain);
|
||||
const result = await dnsManagerService.getHostnameWithRecords(
|
||||
customDomain,
|
||||
{ isPublicDomain: false },
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(cloudflareMock.customHostnames.list).toHaveBeenCalledWith({
|
||||
@@ -166,14 +167,16 @@ describe('CustomDomainService', () => {
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getBaseUrl')
|
||||
.mockReturnValue(new URL('https://front.domain'));
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result =
|
||||
await customDomainService.getCustomDomainDetails(customDomain);
|
||||
const result = await dnsManagerService.getHostnameWithRecords(
|
||||
customDomain,
|
||||
{ isPublicDomain: false },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'custom-id',
|
||||
customDomain: customDomain,
|
||||
domain: customDomain,
|
||||
records: expect.any(Array),
|
||||
});
|
||||
});
|
||||
@@ -203,16 +206,66 @@ describe('CustomDomainService', () => {
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getBaseUrl')
|
||||
.mockReturnValue(new URL('https://front.domain'));
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result =
|
||||
await customDomainService.getCustomDomainDetails(customDomain);
|
||||
const result = await dnsManagerService.getHostnameWithRecords(
|
||||
customDomain,
|
||||
{ isPublicDomain: false },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'custom-id',
|
||||
customDomain: customDomain,
|
||||
domain: customDomain,
|
||||
records: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(result?.records[0].value === 'https://front.domain');
|
||||
});
|
||||
|
||||
it('should return public domain details', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const mockResult = {
|
||||
id: 'custom-id',
|
||||
hostname: customDomain,
|
||||
ownership_verification: {
|
||||
type: 'txt',
|
||||
name: 'ownership',
|
||||
value: 'value',
|
||||
},
|
||||
ssl: {
|
||||
validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }],
|
||||
},
|
||||
verification_errors: [],
|
||||
};
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getBaseUrl')
|
||||
.mockReturnValue(new URL('https://front.domain'));
|
||||
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getPublicDomainUrl')
|
||||
.mockReturnValue(new URL('https://front.public-domain'));
|
||||
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result = await dnsManagerService.getHostnameWithRecords(
|
||||
customDomain,
|
||||
{ isPublicDomain: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'custom-id',
|
||||
domain: customDomain,
|
||||
records: expect.any(Array),
|
||||
});
|
||||
|
||||
expect(result?.records[0].value === 'https://front.public-domain');
|
||||
});
|
||||
|
||||
it('should throw an error if multiple results are found', async () => {
|
||||
@@ -224,48 +277,53 @@ describe('CustomDomainService', () => {
|
||||
};
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await expect(
|
||||
customDomainService.getCustomDomainDetails(customDomain),
|
||||
dnsManagerService.getHostnameWithRecords(customDomain, {
|
||||
isPublicDomain: false,
|
||||
}),
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomDomain', () => {
|
||||
describe('updateHostname', () => {
|
||||
it('should update a custom domain and register a new one', async () => {
|
||||
const fromHostname = 'old.com';
|
||||
const toHostname = 'new.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({ id: 'old-id' } as any);
|
||||
.spyOn(dnsManagerService, 'getHostnameId')
|
||||
.mockResolvedValueOnce('old-id');
|
||||
jest
|
||||
.spyOn(customDomainService, 'deleteCustomHostname')
|
||||
.spyOn(dnsManagerService, 'deleteHostname')
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const registerSpy = jest
|
||||
.spyOn(customDomainService, 'registerCustomDomain')
|
||||
.spyOn(dnsManagerService, 'registerHostname')
|
||||
.mockResolvedValueOnce({} as unknown as CustomHostnameCreateResponse);
|
||||
|
||||
await customDomainService.updateCustomDomain(fromHostname, toHostname);
|
||||
await dnsManagerService.updateHostname(fromHostname, toHostname);
|
||||
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
|
||||
fromHostname,
|
||||
undefined,
|
||||
);
|
||||
expect(customDomainService.deleteCustomHostname).toHaveBeenCalledWith(
|
||||
expect(dnsManagerService.deleteHostname).toHaveBeenCalledWith(
|
||||
'old-id',
|
||||
undefined,
|
||||
);
|
||||
expect(registerSpy).toHaveBeenCalledWith(toHostname);
|
||||
expect(registerSpy).toHaveBeenCalledWith(toHostname, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCustomHostnameByHostnameSilently', () => {
|
||||
describe('deleteHostnameSilently', () => {
|
||||
it('should delete the custom hostname silently', async () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({ id: 'custom-id' } as any);
|
||||
.spyOn(dnsManagerService, 'getHostnameId')
|
||||
.mockResolvedValueOnce('custom-id');
|
||||
const deleteMock = jest.fn();
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
@@ -274,13 +332,12 @@ describe('CustomDomainService', () => {
|
||||
};
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
(dnsManagerService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
customDomain,
|
||||
),
|
||||
dnsManagerService.deleteHostnameSilently(customDomain),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledWith('custom-id', {
|
||||
zone_id: 'test-zone-id',
|
||||
});
|
||||
@@ -290,13 +347,11 @@ describe('CustomDomainService', () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.spyOn(dnsManagerService, 'getHostnameId')
|
||||
.mockRejectedValueOnce(new Error('Failure'));
|
||||
|
||||
await expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
customDomain,
|
||||
),
|
||||
dnsManagerService.deleteHostnameSilently(customDomain),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/* @license Enterprise */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import {
|
||||
type CustomHostnameCreateParams,
|
||||
type CustomHostnameListResponse,
|
||||
} from 'cloudflare/resources/custom-hostnames/custom-hostnames';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
DnsManagerException,
|
||||
DnsManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/cloudflare.validate';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
|
||||
|
||||
type DnsManagerOptions = {
|
||||
isPublicDomain?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DnsManagerService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.twentyConfigService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async registerHostname(customDomain: string, options?: DnsManagerOptions) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (isDefined(await this.getHostnameId(customDomain, options))) {
|
||||
throw new DnsManagerException(
|
||||
'Hostname already registered',
|
||||
DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
{ userFriendlyMessage: 'Domain is already registered' },
|
||||
);
|
||||
}
|
||||
|
||||
return this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.getZoneId(options),
|
||||
hostname: customDomain,
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
async getHostnameWithRecords(
|
||||
domain: string,
|
||||
options?: DnsManagerOptions,
|
||||
): Promise<DomainValidRecords | undefined> {
|
||||
if (
|
||||
options?.isPublicDomain &&
|
||||
!isDefined(this.domainManagerService.getPublicDomainUrl().hostname)
|
||||
) {
|
||||
throw new DnsManagerException(
|
||||
'Missing public domain URL',
|
||||
DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Public domain URL is not defined. Please set the PUBLIC_DOMAIN_URL environment variable',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const customHostname = await this.getHostnameDetails(domain, options);
|
||||
|
||||
if (!isDefined(customHostname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { hostname, id, ssl } = customHostname;
|
||||
|
||||
const statuses = this.getHostnameStatuses(customHostname);
|
||||
|
||||
// @ts-expect-error - type definition doesn't reflect the real API
|
||||
const dcvRecords = ssl?.dcv_delegation_records?.[0];
|
||||
|
||||
return {
|
||||
id: id,
|
||||
domain: hostname,
|
||||
records: [
|
||||
{
|
||||
validationType: 'redirection' as const,
|
||||
type: 'cname',
|
||||
status: statuses.redirection,
|
||||
key: hostname,
|
||||
value: options?.isPublicDomain
|
||||
? this.domainManagerService.getPublicDomainUrl().hostname
|
||||
: this.domainManagerService.getBaseUrl().hostname,
|
||||
},
|
||||
{
|
||||
validationType: 'ssl' as const,
|
||||
type: 'cname',
|
||||
status: statuses.ssl,
|
||||
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
|
||||
value:
|
||||
dcvRecords?.cname_target ??
|
||||
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async updateHostname(
|
||||
fromHostname: string,
|
||||
toHostname: string,
|
||||
options?: DnsManagerOptions,
|
||||
) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostnameId = await this.getHostnameId(
|
||||
fromHostname,
|
||||
options,
|
||||
);
|
||||
|
||||
if (fromCustomHostnameId) {
|
||||
await this.deleteHostname(fromCustomHostnameId, options);
|
||||
}
|
||||
|
||||
return this.registerHostname(toHostname, options);
|
||||
}
|
||||
|
||||
async refreshHostname(
|
||||
domainValidRecords: DomainValidRecords,
|
||||
options?: DnsManagerOptions,
|
||||
) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.edit(domainValidRecords.id, {
|
||||
zone_id: this.getZoneId(options),
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteHostnameSilently(hostname: string, options?: DnsManagerOptions) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostnameId = await this.getHostnameId(hostname, options);
|
||||
|
||||
if (customHostnameId) {
|
||||
await this.deleteHostname(customHostnameId, options);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async isHostnameWorking(hostname: string, options?: DnsManagerOptions) {
|
||||
const hostnameDetails = await this.getHostnameDetails(hostname, options);
|
||||
|
||||
if (!isDefined(hostnameDetails)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const statuses = this.getHostnameStatuses(hostnameDetails);
|
||||
|
||||
return statuses.redirection === 'success' && statuses.ssl === 'success';
|
||||
}
|
||||
|
||||
private get sslParams(): CustomHostnameCreateParams['ssl'] {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private getZoneId(options?: DnsManagerOptions): string {
|
||||
return options?.isPublicDomain
|
||||
? this.twentyConfigService.get('CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID')
|
||||
: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID');
|
||||
}
|
||||
|
||||
private async getHostnameDetails(
|
||||
hostname: string,
|
||||
options?: DnsManagerOptions,
|
||||
) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const customHostnames = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.getZoneId(options),
|
||||
hostname: hostname,
|
||||
});
|
||||
|
||||
if (customHostnames.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (customHostnames.result.length === 1) {
|
||||
return customHostnames.result[0];
|
||||
}
|
||||
|
||||
// should never happen. error 5xx
|
||||
throw new DnsManagerException(
|
||||
'More than one custom hostname found in cloudflare',
|
||||
DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND,
|
||||
{
|
||||
userFriendlyMessage: `${customHostnames.result.length} hostnames found for domain '${hostname}'. Expect 1`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getHostnameId(hostname: string, options?: DnsManagerOptions) {
|
||||
const customHostname = await this.getHostnameDetails(hostname, options);
|
||||
|
||||
if (!isDefined(customHostname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return customHostname.id;
|
||||
}
|
||||
|
||||
private getHostnameStatuses(customHostname: CustomHostnameListResponse) {
|
||||
const { ssl, verification_errors, created_at } = customHostname;
|
||||
|
||||
return {
|
||||
// wait 10s before starting the real check
|
||||
redirection:
|
||||
created_at &&
|
||||
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
|
||||
? 'pending'
|
||||
: verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
ssl:
|
||||
!ssl.status || ssl.status.startsWith('pending')
|
||||
? 'pending'
|
||||
: ssl.status === 'active'
|
||||
? 'success'
|
||||
: ssl.status,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteHostname(customHostnameId: string, options?: DnsManagerOptions) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.getZoneId(options),
|
||||
});
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,25 +3,25 @@ import { t } from '@lingui/core/macro';
|
||||
import type Cloudflare from 'cloudflare';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
DnsManagerException,
|
||||
DnsManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
|
||||
|
||||
const isCloudflareInstanceDefined = (
|
||||
cloudflareInstance: Cloudflare | undefined | null,
|
||||
): asserts cloudflareInstance is Cloudflare => {
|
||||
if (!cloudflareInstance) {
|
||||
throw new DomainManagerException(
|
||||
throw new DnsManagerException(
|
||||
'Cloudflare instance is not defined',
|
||||
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
|
||||
DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
|
||||
{
|
||||
userFriendlyMessage: t`Environnement variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
|
||||
userFriendlyMessage: t`Environment variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const domainManagerValidator: {
|
||||
export const dnsManagerValidator: {
|
||||
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
|
||||
} = {
|
||||
isCloudflareInstanceDefined,
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { handleException } from 'src/engine/utils/global-exception-handler.util';
|
||||
@Controller()
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class CloudflareController {
|
||||
constructor(
|
||||
@InjectRepository(Workspace)
|
||||
protected readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
|
||||
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
|
||||
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
|
||||
try {
|
||||
// Cloudflare documentation is inaccurate - some webhooks lack the hostname field.
|
||||
// Fallback to extracting hostname from validation_records.
|
||||
const hostname =
|
||||
req.body?.data?.data?.hostname ??
|
||||
req.body?.data?.data?.ssl?.validation_records?.[0]?.txt_name?.replace(
|
||||
/^_acme-challenge\./,
|
||||
'',
|
||||
);
|
||||
|
||||
if (!hostname) {
|
||||
handleException({
|
||||
exception: new DomainManagerException(
|
||||
'Hostname missing',
|
||||
DomainManagerExceptionCode.INVALID_INPUT_DATA,
|
||||
{ userFriendlyMessage: 'Hostname missing' },
|
||||
),
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
customDomain: hostname,
|
||||
});
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const customDomainDetails =
|
||||
await this.customDomainService.getCustomDomainDetails(hostname);
|
||||
|
||||
const workspaceUpdated: Partial<Workspace> = {
|
||||
customDomain: workspace.customDomain,
|
||||
};
|
||||
|
||||
if (!customDomainDetails) {
|
||||
workspaceUpdated.customDomain = null;
|
||||
}
|
||||
|
||||
workspaceUpdated.isCustomDomainEnabled = customDomainDetails
|
||||
? this.domainManagerService.isCustomDomainWorking(customDomainDetails)
|
||||
: false;
|
||||
|
||||
if (
|
||||
workspaceUpdated.isCustomDomainEnabled !==
|
||||
workspace.isCustomDomainEnabled ||
|
||||
workspaceUpdated.customDomain !== workspace.customDomain
|
||||
) {
|
||||
await this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...workspaceUpdated,
|
||||
});
|
||||
|
||||
await auditService.insertWorkspaceEvent(
|
||||
CUSTOM_DOMAIN_ACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
} catch (err) {
|
||||
handleException({
|
||||
exception: new DomainManagerException(
|
||||
err.message ?? 'Unknown error occurred',
|
||||
DomainManagerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
{ userFriendlyMessage: 'Unknown error occurred' },
|
||||
),
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
}
|
||||
}
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Request, type Response } from 'express';
|
||||
import { AuditContextMock } from 'test/utils/audit-context.mock';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
|
||||
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('CloudflareController - customHostnameWebhooks', () => {
|
||||
let controller: CloudflareController;
|
||||
let WorkspaceRepository: Repository<Workspace>;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
let customDomainService: CustomDomainService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CloudflareController],
|
||||
providers: [
|
||||
{
|
||||
provide: getRepositoryToken(Workspace),
|
||||
useValue: {
|
||||
findOneBy: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
isCustomDomainWorking: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CustomDomainService,
|
||||
useValue: {
|
||||
getCustomDomainDetails: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: HttpExceptionHandlerService,
|
||||
useValue: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ExceptionHandlerService,
|
||||
useValue: {
|
||||
captureExceptions: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
createContext: AuditContextMock,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CloudflareController>(CloudflareController);
|
||||
WorkspaceRepository = module.get(getRepositoryToken(Workspace));
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
customDomainService = module.get<CustomDomainService>(CustomDomainService);
|
||||
});
|
||||
|
||||
it('should handle exception and return status 200 if hostname is missing', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: {} } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update workspace for a valid hostname and save changes', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'example.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue({
|
||||
records: [
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
} as unknown as CustomDomainValidRecords);
|
||||
jest
|
||||
.spyOn(domainManagerService, 'isCustomDomainWorking')
|
||||
.mockReturnValue(true);
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'example.com',
|
||||
isCustomDomainEnabled: false,
|
||||
} as Workspace);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'example.com',
|
||||
});
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
'example.com',
|
||||
);
|
||||
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
|
||||
customDomain: 'example.com',
|
||||
isCustomDomainEnabled: true,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove customDomain if no hostname found', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'notfound.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'notfound.com',
|
||||
isCustomDomainEnabled: true,
|
||||
} as Workspace);
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'notfound.com',
|
||||
});
|
||||
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
it('should do nothing if nothing changes', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'nothing-change.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'nothing-change.com',
|
||||
isCustomDomainEnabled: true,
|
||||
} as Workspace);
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue({
|
||||
records: [
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
} as unknown as CustomDomainValidRecords);
|
||||
jest
|
||||
.spyOn(domainManagerService, 'isCustomDomainWorking')
|
||||
.mockReturnValue(true);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'nothing-change.com',
|
||||
});
|
||||
expect(WorkspaceRepository.save).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export class DomainManagerException extends CustomException<
|
||||
keyof typeof DomainManagerExceptionCode,
|
||||
true
|
||||
> {}
|
||||
|
||||
export const DomainManagerExceptionCode = appendCommonExceptionCode({
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
|
||||
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
|
||||
} as const);
|
||||
+4
-20
@@ -1,29 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerResolver } from 'src/engine/core-modules/domain-manager/domain-manager.resolver';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, TypeOrmModule.forFeature([Workspace])],
|
||||
providers: [
|
||||
DomainManagerResolver,
|
||||
DomainManagerService,
|
||||
CustomDomainService,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
],
|
||||
exports: [
|
||||
DomainManagerService,
|
||||
CustomDomainService,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
],
|
||||
controllers: [CloudflareController],
|
||||
imports: [TypeOrmModule.forFeature([Workspace]), AuditModule],
|
||||
providers: [DomainManagerService],
|
||||
exports: [DomainManagerService],
|
||||
})
|
||||
export class DomainManagerModule {}
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards, UsePipes } from '@nestjs/common';
|
||||
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@Resolver()
|
||||
export class DomainManagerResolver {
|
||||
constructor(private readonly customDomainService: CustomDomainService) {}
|
||||
|
||||
@Mutation(() => CustomDomainValidRecords, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async checkCustomDomainValidRecords(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
return this.customDomainService.checkCustomDomainValidRecords(workspace);
|
||||
}
|
||||
}
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { type CustomHostnameCreateParams } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CustomDomainService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly auditService: AuditService,
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {
|
||||
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.twentyConfigService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private get sslParams(): CustomHostnameCreateParams['ssl'] {
|
||||
return {
|
||||
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 registerCustomDomain(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (isDefined(await this.getCustomDomainDetails(customDomain))) {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
{ userFriendlyMessage: 'Hostname already registered' },
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname: customDomain,
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomDomainDetails(
|
||||
customDomain: string,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const response = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname: customDomain,
|
||||
});
|
||||
|
||||
if (response.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
const { hostname, id, ssl, verification_errors, created_at } =
|
||||
response.result[0];
|
||||
// @ts-expect-error - type definition doesn't reflect the real API
|
||||
const dcvRecords = ssl?.dcv_delegation_records?.[0];
|
||||
|
||||
return {
|
||||
id: id,
|
||||
customDomain: hostname,
|
||||
records: [
|
||||
{
|
||||
validationType: 'redirection' as const,
|
||||
type: 'cname',
|
||||
status:
|
||||
// wait 10s before starting the real check
|
||||
created_at &&
|
||||
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
|
||||
? 'pending'
|
||||
: verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
key: hostname,
|
||||
value: this.domainManagerService.getBaseUrl().hostname,
|
||||
},
|
||||
{
|
||||
validationType: 'ssl' as const,
|
||||
type: 'cname',
|
||||
status:
|
||||
!ssl.status || ssl.status.startsWith('pending')
|
||||
? 'pending'
|
||||
: ssl.status === 'active'
|
||||
? 'success'
|
||||
: ssl.status,
|
||||
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
|
||||
value:
|
||||
dcvRecords?.cname_target ??
|
||||
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// should never append. error 5xx
|
||||
throw new Error('More than one custom hostname found in cloudflare');
|
||||
}
|
||||
|
||||
async updateCustomDomain(fromHostname: string, toHostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostname = await this.getCustomDomainDetails(fromHostname);
|
||||
|
||||
if (fromCustomHostname) {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return this.registerCustomDomain(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostname = await this.getCustomDomainDetails(customDomain);
|
||||
|
||||
if (customHostname) {
|
||||
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomHostname(customHostnameId: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshCustomDomain(
|
||||
customDomainDetails: CustomDomainValidRecords,
|
||||
) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.edit(customDomainDetails.id, {
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
async checkCustomDomainValidRecords(workspace: Workspace) {
|
||||
if (!workspace.customDomain) return;
|
||||
|
||||
const customDomainDetails = await this.getCustomDomainDetails(
|
||||
workspace.customDomain,
|
||||
);
|
||||
|
||||
if (!customDomainDetails) return;
|
||||
|
||||
await this.refreshCustomDomain(customDomainDetails);
|
||||
|
||||
const isCustomDomainWorking =
|
||||
this.domainManagerService.isCustomDomainWorking(customDomainDetails);
|
||||
|
||||
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
|
||||
workspace.isCustomDomainEnabled = isCustomDomainWorking;
|
||||
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
const auditService = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
auditService.insertWorkspaceEvent(
|
||||
workspace.isCustomDomainEnabled
|
||||
? CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return customDomainDetails;
|
||||
}
|
||||
}
|
||||
+7
@@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
|
||||
import { DomainManagerService } from './domain-manager.service';
|
||||
|
||||
@@ -76,6 +77,12 @@ describe('DomainManagerService', () => {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AuditService,
|
||||
useValue: {
|
||||
createContext: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+48
-7
@@ -5,13 +5,14 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
|
||||
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
|
||||
@Injectable()
|
||||
export class DomainManagerService {
|
||||
@@ -19,6 +20,7 @@ export class DomainManagerService {
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
getFrontUrl() {
|
||||
@@ -41,6 +43,10 @@ export class DomainManagerService {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
getPublicDomainUrl(): URL {
|
||||
return new URL(this.twentyConfigService.get('PUBLIC_DOMAIN_URL'));
|
||||
}
|
||||
|
||||
private appendSearchParams(
|
||||
url: URL,
|
||||
searchParams: Record<string, string | number | boolean>,
|
||||
@@ -236,12 +242,6 @@ export class DomainManagerService {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
|
||||
return customDomainDetails.records.every(
|
||||
({ status }) => status === 'success',
|
||||
);
|
||||
}
|
||||
|
||||
getWorkspaceUrls({
|
||||
subdomain,
|
||||
customDomain,
|
||||
@@ -255,4 +255,45 @@ export class DomainManagerService {
|
||||
subdomainUrl: this.getTwentyWorkspaceUrl(subdomain),
|
||||
};
|
||||
}
|
||||
|
||||
async handleCustomDomainActivation({
|
||||
customDomain,
|
||||
isCustomDomainWorking,
|
||||
}: {
|
||||
customDomain: string;
|
||||
isCustomDomainWorking: boolean;
|
||||
}) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
customDomain,
|
||||
});
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const workspaceUpdated: Partial<Workspace> = {
|
||||
customDomain: workspace.customDomain,
|
||||
};
|
||||
|
||||
if (!isCustomDomainWorking) {
|
||||
workspaceUpdated.customDomain = null;
|
||||
}
|
||||
|
||||
workspaceUpdated.isCustomDomainEnabled = isCustomDomainWorking;
|
||||
|
||||
if (
|
||||
workspaceUpdated.isCustomDomainEnabled !==
|
||||
workspace.isCustomDomainEnabled ||
|
||||
workspaceUpdated.customDomain !== workspace.customDomain
|
||||
) {
|
||||
await this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...workspaceUpdated,
|
||||
});
|
||||
|
||||
await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('PublicDomain')
|
||||
export class PublicDomainDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
domain: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
isValidated: boolean;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Field, ArgsType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class PublicDomainInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
domain: string;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
PublicDomainException,
|
||||
PublicDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/public-domain/public-domain.exception';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(PublicDomainException)
|
||||
export class PublicDomainExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: PublicDomainException) {
|
||||
switch (exception.code) {
|
||||
case PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED:
|
||||
case PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN:
|
||||
throw new UserInputError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity({ name: 'publicDomain', schema: 'core' })
|
||||
@ObjectType()
|
||||
export class PublicDomain {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ type: 'varchar', nullable: false, unique: true })
|
||||
domain: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false, nullable: false })
|
||||
isValidated: boolean;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.publicDomains, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class PublicDomainException extends CustomException<PublicDomainExceptionCode> {}
|
||||
|
||||
export enum PublicDomainExceptionCode {
|
||||
PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED',
|
||||
DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN',
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
import { PublicDomainResolver } from 'src/engine/core-modules/public-domain/public-domain.resolver';
|
||||
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([PublicDomain, Workspace]),
|
||||
DnsManagerModule,
|
||||
],
|
||||
providers: [PublicDomainService, PublicDomainResolver],
|
||||
})
|
||||
export class PublicDomainModule {}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { PublicDomainExceptionFilter } from 'src/engine/core-modules/public-domain/public-domain-exception-filter';
|
||||
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
|
||||
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
|
||||
import { PublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/public-domain.input';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(
|
||||
PublicDomainExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
@Resolver()
|
||||
export class PublicDomainResolver {
|
||||
constructor(private readonly publicDomainService: PublicDomainService) {}
|
||||
|
||||
@Mutation(() => PublicDomainDTO)
|
||||
async createPublicDomain(
|
||||
@Args() { domain }: PublicDomainInput,
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
): Promise<PublicDomainDTO> {
|
||||
return this.publicDomainService.createPublicDomain({
|
||||
domain,
|
||||
workspace: currentWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deletePublicDomain(
|
||||
@Args() { domain }: PublicDomainInput,
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
): Promise<boolean> {
|
||||
await this.publicDomainService.deletePublicDomain({
|
||||
domain,
|
||||
workspace: currentWorkspace,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
import {
|
||||
PublicDomainException,
|
||||
PublicDomainExceptionCode,
|
||||
} from 'src/engine/core-modules/public-domain/public-domain.exception';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class PublicDomainService {
|
||||
constructor(
|
||||
private readonly dnsManagerService: DnsManagerService,
|
||||
@InjectRepository(PublicDomain)
|
||||
private readonly publicDomainRepository: Repository<PublicDomain>,
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
async deletePublicDomain({
|
||||
domain,
|
||||
workspace,
|
||||
}: {
|
||||
domain: string;
|
||||
workspace: Workspace;
|
||||
}): Promise<void> {
|
||||
const formattedDomain = domain.trim().toLowerCase();
|
||||
|
||||
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
|
||||
isPublicDomain: true,
|
||||
});
|
||||
|
||||
await this.publicDomainRepository.delete({
|
||||
domain: formattedDomain,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
async createPublicDomain({
|
||||
domain,
|
||||
workspace,
|
||||
}: {
|
||||
domain: string;
|
||||
workspace: Workspace;
|
||||
}): Promise<PublicDomainDTO> {
|
||||
const formattedDomain = domain.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
await this.workspaceRepository.findOneBy({
|
||||
customDomain: formattedDomain,
|
||||
})
|
||||
) {
|
||||
throw new PublicDomainException(
|
||||
'Domain already used for workspace custom domain',
|
||||
PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN,
|
||||
{
|
||||
userFriendlyMessage: t`Domain already used for workspace custom domain`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
await this.publicDomainRepository.findOneBy({
|
||||
domain: formattedDomain,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
) {
|
||||
throw new PublicDomainException(
|
||||
'Public domain already registered',
|
||||
PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED,
|
||||
{
|
||||
userFriendlyMessage: t`Public domain already registered`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const publicDomain = this.publicDomainRepository.create({
|
||||
domain: formattedDomain,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
await this.dnsManagerService.registerHostname(formattedDomain, {
|
||||
isPublicDomain: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.publicDomainRepository.insert(publicDomain);
|
||||
} catch (error) {
|
||||
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
|
||||
isPublicDomain: true,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return publicDomain;
|
||||
}
|
||||
}
|
||||
@@ -892,6 +892,15 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
SERVER_URL = 'http://localhost:3000';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ServerConfig,
|
||||
description: 'Base URL for public domains',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
@IsOptional()
|
||||
PUBLIC_DOMAIN_URL: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ServerConfig,
|
||||
isSensitive: true,
|
||||
@@ -963,6 +972,15 @@ export class ConfigVariables {
|
||||
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
|
||||
CLOUDFLARE_ZONE_ID: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ServerConfig,
|
||||
description: 'Base URL for public domains',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
@ValidateIf((env) => env.PUBLIC_DOMAIN_URL)
|
||||
CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.Other,
|
||||
description: 'Random string to validate queries from Cloudflare',
|
||||
|
||||
+16
-11
@@ -6,7 +6,6 @@ import { type Repository } from 'typeorm';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
@@ -26,6 +25,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
let service: WorkspaceService;
|
||||
@@ -34,7 +35,7 @@ describe('WorkspaceService', () => {
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let workspaceCacheStorageService: WorkspaceCacheStorageService;
|
||||
let messageQueueService: MessageQueueService;
|
||||
let customDomainService: CustomDomainService;
|
||||
let dnsManagerService: DnsManagerService;
|
||||
let billingSubscriptionService: BillingSubscriptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -49,6 +50,12 @@ describe('WorkspaceService', () => {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(PublicDomain),
|
||||
useValue: {
|
||||
findOneBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspace),
|
||||
useValue: {
|
||||
@@ -86,7 +93,7 @@ describe('WorkspaceService', () => {
|
||||
UserWorkspaceService,
|
||||
UserService,
|
||||
DomainManagerService,
|
||||
CustomDomainService,
|
||||
DnsManagerService,
|
||||
TwentyConfigService,
|
||||
EmailService,
|
||||
OnboardingService,
|
||||
@@ -128,8 +135,8 @@ describe('WorkspaceService', () => {
|
||||
messageQueueService = module.get<MessageQueueService>(
|
||||
getQueueToken(MessageQueue.deleteCascadeQueue),
|
||||
);
|
||||
customDomainService = module.get<CustomDomainService>(CustomDomainService);
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently = jest.fn();
|
||||
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
|
||||
dnsManagerService.deleteHostnameSilently = jest.fn();
|
||||
billingSubscriptionService = module.get<BillingSubscriptionService>(
|
||||
BillingSubscriptionService,
|
||||
);
|
||||
@@ -267,9 +274,9 @@ describe('WorkspaceService', () => {
|
||||
|
||||
await service.deleteWorkspace(mockWorkspace.id, false);
|
||||
|
||||
expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently,
|
||||
).toHaveBeenCalledWith(customDomain);
|
||||
expect(dnsManagerService.deleteHostnameSilently).toHaveBeenCalledWith(
|
||||
customDomain,
|
||||
);
|
||||
expect(workspaceRepository.delete).toHaveBeenCalledWith(mockWorkspace.id);
|
||||
});
|
||||
|
||||
@@ -288,9 +295,7 @@ describe('WorkspaceService', () => {
|
||||
|
||||
await service.deleteWorkspace(mockWorkspace.id, true);
|
||||
|
||||
expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(dnsManagerService.deleteHostnameSilently).not.toHaveBeenCalled();
|
||||
expect(workspaceRepository.softDelete).toHaveBeenCalledWith({
|
||||
id: mockWorkspace.id,
|
||||
});
|
||||
|
||||
+7
-6
@@ -1,16 +1,17 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import {
|
||||
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
} from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import {
|
||||
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
} from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
|
||||
@Command({
|
||||
name: 'cron:domain-manager:check-custom-domain-valid-records',
|
||||
description: 'Starts a cron job to check custom domain valid records hourly',
|
||||
name: 'cron:workspace:check-custom-domain-valid-records',
|
||||
description:
|
||||
'Starts a cron job to check workspace custom domain valid records hourly',
|
||||
})
|
||||
export class CheckCustomDomainValidRecordsCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
+3
-3
@@ -7,8 +7,8 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN = '0 * * * *';
|
||||
|
||||
@@ -17,7 +17,7 @@ export class CheckCustomDomainValidRecordsCronJob {
|
||||
constructor(
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
@Process(CheckCustomDomainValidRecordsCronJob.name)
|
||||
@@ -39,7 +39,7 @@ export class CheckCustomDomainValidRecordsCronJob {
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
try {
|
||||
await this.customDomainService.checkCustomDomainValidRecords(workspace);
|
||||
await this.workspaceService.checkCustomDomainValidRecords(workspace);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[${CheckCustomDomainValidRecordsCronJob.name}] Cannot check custom domain for workspaces: ${error.message}`,
|
||||
+69
-18
@@ -7,11 +7,11 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import {
|
||||
@@ -43,6 +43,11 @@ import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage
|
||||
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 { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@@ -53,6 +58,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
constructor(
|
||||
@InjectRepository(Workspace)
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(PublicDomain)
|
||||
private readonly publicDomainRepository: Repository<PublicDomain>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace)
|
||||
@@ -65,8 +72,9 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
private readonly dnsManagerService: DnsManagerService,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly auditService: AuditService,
|
||||
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
@@ -117,22 +125,30 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
}
|
||||
|
||||
if (
|
||||
customDomain &&
|
||||
workspace.customDomain !== customDomain &&
|
||||
isDefined(workspace.customDomain)
|
||||
await this.publicDomainRepository.findOneBy({
|
||||
domain: customDomain,
|
||||
})
|
||||
) {
|
||||
await this.customDomainService.updateCustomDomain(
|
||||
workspace.customDomain,
|
||||
customDomain,
|
||||
throw new WorkspaceException(
|
||||
'Domain is already registered as public domain',
|
||||
WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN,
|
||||
{
|
||||
userFriendlyMessage: t`Domain is already registered as public domain`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
customDomain &&
|
||||
workspace.customDomain !== customDomain &&
|
||||
!isDefined(workspace.customDomain)
|
||||
) {
|
||||
await this.customDomainService.registerCustomDomain(customDomain);
|
||||
if (!isDefined(customDomain) || workspace.customDomain === customDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(workspace.customDomain)) {
|
||||
await this.dnsManagerService.updateHostname(
|
||||
workspace.customDomain,
|
||||
customDomain,
|
||||
);
|
||||
} else {
|
||||
await this.dnsManagerService.registerHostname(customDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
let customDomainRegistered = false;
|
||||
|
||||
if (payload.customDomain === null && isDefined(workspace.customDomain)) {
|
||||
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
await this.dnsManagerService.deleteHostnameSilently(
|
||||
workspace.customDomain,
|
||||
);
|
||||
workspace.isCustomDomainEnabled = false;
|
||||
@@ -220,8 +236,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
} catch (error) {
|
||||
// revert custom domain registration on error
|
||||
if (payload.customDomain && customDomainRegistered) {
|
||||
this.customDomainService
|
||||
.deleteCustomHostnameByHostnameSilently(payload.customDomain)
|
||||
this.dnsManagerService
|
||||
.deleteHostnameSilently(payload.customDomain)
|
||||
.catch((err) => {
|
||||
this.exceptionHandlerService.captureExceptions([err]);
|
||||
});
|
||||
@@ -347,7 +363,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
);
|
||||
|
||||
if (workspace.customDomain) {
|
||||
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
await this.dnsManagerService.deleteHostnameSilently(
|
||||
workspace.customDomain,
|
||||
);
|
||||
this.logger.log(`workspace ${id} custom domain deleted`);
|
||||
@@ -487,4 +503,39 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkCustomDomainValidRecords(workspace: Workspace) {
|
||||
if (!workspace.customDomain) return;
|
||||
|
||||
const customDomainWithRecords =
|
||||
await this.dnsManagerService.getHostnameWithRecords(
|
||||
workspace.customDomain,
|
||||
);
|
||||
|
||||
if (!customDomainWithRecords) return;
|
||||
|
||||
await this.dnsManagerService.refreshHostname(customDomainWithRecords);
|
||||
|
||||
const isCustomDomainWorking =
|
||||
await this.dnsManagerService.isHostnameWorking(workspace.customDomain);
|
||||
|
||||
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
|
||||
workspace.isCustomDomainEnabled = isCustomDomainWorking;
|
||||
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
analytics.insertWorkspaceEvent(
|
||||
workspace.isCustomDomainEnabled
|
||||
? CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return customDomainWithRecords;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ import {
|
||||
|
||||
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const workspaceAutoResolverOpts: AutoResolverOpts<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-hand
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
|
||||
registerEnumType(WorkspaceActivationStatus, {
|
||||
name: 'WorkspaceActivationStatus',
|
||||
@@ -115,6 +116,9 @@ export class Workspace {
|
||||
)
|
||||
approvedAccessDomains: Relation<ApprovedAccessDomain[]>;
|
||||
|
||||
@OneToMany(() => PublicDomain, (publicDomain) => publicDomain.workspace)
|
||||
publicDomains: Relation<PublicDomain[]>;
|
||||
|
||||
@Field({ nullable: true })
|
||||
workspaceMembersCount: number;
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
@@ -26,11 +25,15 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
|
||||
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
import { WorkspaceService } from './services/workspace.service';
|
||||
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,12 +41,18 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
TypeOrmModule.forFeature([BillingSubscription]),
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
AuditModule,
|
||||
BillingModule,
|
||||
FileModule,
|
||||
TokenModule,
|
||||
FileUploadModule,
|
||||
WorkspaceMetadataCacheModule,
|
||||
NestjsQueryTypeOrmModule.forFeature([User, Workspace, UserWorkspace]),
|
||||
NestjsQueryTypeOrmModule.forFeature([
|
||||
User,
|
||||
Workspace,
|
||||
UserWorkspace,
|
||||
PublicDomain,
|
||||
]),
|
||||
UserWorkspaceModule,
|
||||
WorkspaceManagerModule,
|
||||
FeatureFlagModule,
|
||||
@@ -54,6 +63,7 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
WorkspaceCacheStorageModule,
|
||||
RoleModule,
|
||||
AgentModule,
|
||||
DnsManagerModule,
|
||||
DomainManagerModule,
|
||||
CoreViewModule,
|
||||
],
|
||||
@@ -61,11 +71,13 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
}),
|
||||
],
|
||||
exports: [WorkspaceService],
|
||||
exports: [WorkspaceService, CheckCustomDomainValidRecordsCronCommand],
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceWorkspaceMemberListener,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
|
||||
@@ -65,10 +65,9 @@ import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { getRequest } from 'src/utils/extract-request';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
import { WorkspaceService } from './services/workspace.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
|
||||
|
||||
const OriginHeader = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext) => {
|
||||
@@ -387,4 +386,12 @@ export class WorkspaceResolver {
|
||||
workspaceGraphqlApiExceptionHandler(err);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => DomainValidRecords, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async checkCustomDomainValidRecords(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<DomainValidRecords | undefined> {
|
||||
return this.workspaceService.checkCustomDomainValidRecords(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user