Skip to content

Commit

Permalink
feat(cloudflare): add webhook support for custom hostname updates
Browse files Browse the repository at this point in the history
Introduce a Cloudflare controller to handle webhook events for custom domain updates. This includes verifying secrets, validating input, and updating workspace configurations accordingly. Adjusted related services and entities for compatibility and added a new environment variable for webhook secret.
  • Loading branch information
AMoreaux committed Feb 11, 2025
1 parent 14e3d75 commit 7b2f9e8
Show file tree
Hide file tree
Showing 11 changed files with 346 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const SettingsCustomDomainRecords = ({
<TableBody>
{records.map((record) => {
return (
<TableRow>
<TableRow key={record.key}>
<TableCell>
<TextInputV2
value={record.key}
Expand Down
3 changes: 2 additions & 1 deletion packages/twenty-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,5 @@ FRONT_PORT=3001
# SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt"
# CLOUDFLARE_API_KEY=
# CLOUDFLARE_ZONE_ID=
# CLOUDFLARE_ZONE_ID=
# CLOUDFLARE_WEBHOOK_SECRET=
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Controller, Post, Req, Res, UseFilters } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';

import { Response } from 'express';
import { Repository } from 'typeorm';

import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';

@Controller('cloudflare')
@UseFilters(AuthRestApiExceptionFilter)
export class CloudflareController {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly environmentService: EnvironmentService,
) {}

@Post('custom-hostname-webhooks')
async customHostnameWebhooks(@Req() req: any, @Res() res: Response) {
const cloudflareWebhookSecret = this.environmentService.get(
'CLOUDFLARE_WEBHOOK_SECRET',
);

if (
cloudflareWebhookSecret &&
req.headers['cf-webhook-auth'] !== cloudflareWebhookSecret
) {
throw new DomainManagerException(
'Invalid secret',
DomainManagerExceptionCode.INVALID_INPUT_DATA,
);
}

if (!req.body.data.data.hostname) {
handleException(
new DomainManagerException(
'Hostname missing',
DomainManagerExceptionCode.INVALID_INPUT_DATA,
),
this.exceptionHandlerService,
);

return res.status(200).send();
}

const workspace = await this.workspaceRepository.findOneBy({
customDomain: req.body.data.data.hostname,
});

if (!workspace) return;

const customDomainDetails =
await this.domainManagerService.getCustomDomainDetails(
req.body.data.data.hostname,
);

const workspaceUpdated: Partial<Workspace> = {
customDomain: workspace.customDomain,
};

if (!customDomainDetails && workspace) {
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,
});
}

return res.status(200).send();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';

import { Repository } from 'typeorm';
import { Request, Response } from 'express';

import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details';

describe('CloudflareController - customHostnameWebhooks', () => {
let controller: CloudflareController;
let WorkspaceRepository: Repository<Workspace>;
let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CloudflareController],
providers: [
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {
findOneBy: jest.fn(),
save: jest.fn(),
},
},
{
provide: DomainManagerService,
useValue: {
getCustomDomainDetails: jest.fn(),
isCustomDomainWorking: jest.fn(),
},
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();

controller = module.get<CloudflareController>(CloudflareController);
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
});

it('should throw an error if the webhook secret does not match', async () => {
const req = {
headers: { 'cf-webhook-auth': 'wrong-secret' },
body: { data: { data: { hostname: 'example.com' } } },
} as unknown as Request;

const res = {} as Response;

jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret');

await expect(controller.customHostnameWebhooks(req, res)).rejects.toThrow(
'Invalid secret',
);
});

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(environmentService, '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(environmentService, 'get').mockReturnValue('correct-secret');
jest
.spyOn(domainManagerService, 'getCustomDomainDetails')
.mockResolvedValue({
records: [
{
success: true,
},
],
} as unknown as CustomDomainDetails);
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(domainManagerService.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(environmentService, 'get').mockReturnValue('correct-secret');
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
customDomain: 'notfound.com',
isCustomDomainEnabled: true,
} as Workspace);

jest
.spyOn(domainManagerService, '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 change', 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(environmentService, 'get').mockReturnValue('correct-secret');
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
customDomain: 'nothing-change.com',
isCustomDomainEnabled: true,
} as Workspace);
jest
.spyOn(domainManagerService, 'getCustomDomainDetails')
.mockResolvedValue({
records: [
{
success: true,
},
],
} as unknown as CustomDomainDetails);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum DomainManagerExceptionCode {
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
INVALID_INPUT_DATA = 'INVALID_INPUT_DATA',
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';

import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';

@Module({
imports: [TypeOrmModule.forFeature([Workspace], 'core')],
providers: [DomainManagerService],
exports: [DomainManagerService],
controllers: [CloudflareController],
})
export class DomainManagerModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export class DomainManagerService {
type: 'dv',
settings: {
http2: 'on',
min_tls_version: '1.2',
min_tls_version: '1.0',
tls_1_3: 'on',
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
early_hints: 'on',
Expand Down Expand Up @@ -397,6 +397,12 @@ export class DomainManagerService {
return url.toString();
}

isCustomDomainWorking(customDomainDetails: CustomDomainDetails) {
return customDomainDetails.records.every(
({ status }) => status === 'success',
);
}

getWorkspaceUrls({
subdomain,
customDomain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,15 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string;

@EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other,
subGroup: EnvironmentVariablesSubGroup.CloudflareConfig,
description: 'Random string to validate queries from Cloudflare',
})
@IsString()
@IsOptional()
CLOUDFLARE_WEBHOOK_SECRET: string;

@EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.Other,
subGroup: EnvironmentVariablesSubGroup.LLM,
Expand Down
Loading

0 comments on commit 7b2f9e8

Please sign in to comment.