fix(cloudflare): cloudflare webhook (#14834)

This commit is contained in:
Antoine Moreaux
2025-10-06 14:39:19 +00:00
committed by GitHub
parent bc2070410c
commit 695b4a2139
14 changed files with 159 additions and 65 deletions
@@ -1,20 +1,22 @@
import { useCheckCustomDomainValidRecordsMutation } from '~/generated-metadata/graphql';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
export const useCheckCustomDomainValidRecords = () => {
const [checkCustomDomainValidRecords] =
useCheckCustomDomainValidRecordsMutation();
const { enqueueErrorSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [{ isLoading }, setCustomDomainRecords] = useRecoilState(
customDomainRecordsState,
);
const checkCustomDomainRecords = () => {
if (isLoading) {
if (isLoading || !currentWorkspace?.customDomain) {
return;
}
setCustomDomainRecords((currentState) => ({
@@ -24,12 +24,14 @@ import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdoma
import { useState } from 'react';
import { getSubdomainValidationSchema } from '@/settings/domains/utils/get-subdomain-validation-schema';
import { getDomainValidationSchema } from '@/settings/domains/utils/get-domain-validation-schema';
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
'subdomain-change-confirmation-modal';
export const SettingsDomain = () => {
const navigate = useNavigateSettings();
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
const { t } = useLingui();
const validationSchema = z
@@ -93,6 +95,7 @@ export const SettingsDomain = () => {
message: t`Custom domain updated`,
});
setIsSubmitting(false);
checkCustomDomainRecords();
},
onError: (error: ApolloError) => {
if (
@@ -217,6 +220,7 @@ export const SettingsDomain = () => {
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSubmitting}
onSave={handleSave}
/>
}
>
@@ -7,6 +7,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DnsCloudflareController } from 'src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller';
import { DnsCloudflareService } from 'src/engine/core-modules/cloudflare/services/dns-cloudflare.service';
@Module({
imports: [
@@ -14,6 +15,7 @@ import { DnsCloudflareController } from 'src/engine/core-modules/cloudflare/cont
WorkspaceModule,
PublicDomainModule,
],
providers: [DnsCloudflareService],
controllers: [DnsCloudflareController],
})
export class CloudflareModule {}
@@ -1,31 +1,22 @@
/* @license Enterprise */
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard';
import { DnsCloudflareService } from 'src/engine/core-modules/cloudflare/services/dns-cloudflare.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Controller()
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
export class DnsCloudflareController {
constructor(
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
protected readonly workspaceService: WorkspaceService,
@InjectRepository(PublicDomain)
private readonly publicDomainRepository: Repository<PublicDomain>,
protected readonly publicDomainService: PublicDomainService,
protected readonly dnsCloudflareService: DnsCloudflareService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@@ -33,28 +24,18 @@ export class DnsCloudflareController {
async customHostnameWebhooks(@Req() req: Request) {
const hostname = req.body?.data?.data?.hostname;
if (!hostname) {
const zoneIds = [
this.twentyConfigService.get('CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID'),
this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
];
// since notification are not scoped to a zone, we need to check if the zone is in the list of zones
if (!hostname || !zoneIds.includes(req.body?.data?.metadata?.zone.id)) {
return;
}
try {
const workspace = await this.workspaceRepository.findOneBy({
customDomain: hostname,
});
if (isDefined(workspace)) {
await this.workspaceService.checkCustomDomainValidRecords(workspace);
}
const publicDomain = await this.publicDomainRepository.findOneBy({
domain: hostname,
});
if (isDefined(publicDomain)) {
await this.publicDomainService.checkPublicDomainValidRecords(
publicDomain,
);
}
await this.dnsCloudflareService.checkHostname(hostname);
} catch {
return;
}
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class DnsCloudflareService {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly publicDomainService: PublicDomainService,
) {}
async checkHostname(hostname: string) {
const workspace = await this.workspaceService.findByCustomDomain(hostname);
if (isDefined(workspace)) {
await this.workspaceService.checkCustomDomainValidRecords(workspace);
}
const publicDomain = await this.publicDomainService.findByDomain(hostname);
if (isDefined(publicDomain)) {
await this.publicDomainService.checkPublicDomainValidRecords(
publicDomain,
);
}
}
}
@@ -6,7 +6,7 @@ import {
type CustomHostnameCreateParams,
type CustomHostnameListResponse,
} from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { isDefined } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import {
DnsManagerException,
@@ -130,16 +130,25 @@ export class DnsManagerService {
return this.registerHostname(toHostname, options);
}
async refreshHostname(
domainValidRecords: DomainValidRecords,
options?: DnsManagerOptions,
) {
async refreshHostname(hostname: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.edit(domainValidRecords.id, {
zone_id: this.getZoneId(options),
ssl: this.sslParams,
});
const publicDomainWithRecords = await this.getHostnameWithRecords(
hostname,
options,
);
assertIsDefinedOrThrow(publicDomainWithRecords);
await this.cloudflareClient.customHostnames.edit(
publicDomainWithRecords.id,
{
zone_id: this.getZoneId(options),
ssl: this.sslParams,
},
);
return publicDomainWithRecords;
}
async deleteHostnameSilently(hostname: string, options?: DnsManagerOptions) {
@@ -6,7 +6,10 @@ 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';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(PublicDomainException)
export class PublicDomainExceptionFilter implements ExceptionFilter {
@@ -15,6 +18,8 @@ export class PublicDomainExceptionFilter implements ExceptionFilter {
case PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED:
case PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN:
throw new UserInputError(exception);
case PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND:
throw new NotFoundError(exception);
default:
assertUnreachable(exception.code);
}
@@ -5,4 +5,5 @@ export class PublicDomainException extends CustomException<PublicDomainException
export enum PublicDomainExceptionCode {
PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED',
DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN',
PUBLIC_DOMAIN_NOT_FOUND = 'PUBLIC_DOMAIN_NOT_FOUND',
}
@@ -1,8 +1,9 @@
import { Query, Args, Mutation, Resolver } from '@nestjs/graphql';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
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';
@@ -15,6 +16,11 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import {
PublicDomainException,
PublicDomainExceptionCode,
} from 'src/engine/core-modules/public-domain/public-domain.exception';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@@ -28,6 +34,7 @@ export class PublicDomainResolver {
@InjectRepository(PublicDomain)
private readonly publicDomainRepository: Repository<PublicDomain>,
private readonly publicDomainService: PublicDomainService,
private readonly dnsManagerService: DnsManagerService,
) {}
@Query(() => [PublicDomainDTO])
@@ -72,10 +79,24 @@ export class PublicDomainResolver {
where: { workspaceId: workspace.id, domain },
});
if (!publicDomain) {
return;
}
assertIsDefinedOrThrow(
publicDomain,
new PublicDomainException(
`Public domain ${domain} not found`,
PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND,
),
);
return this.publicDomainService.checkPublicDomainValidRecords(publicDomain);
const domainValidRecords = await this.dnsManagerService.refreshHostname(
domain,
{
isPublicDomain: true,
},
);
return this.publicDomainService.checkPublicDomainValidRecords(
publicDomain,
domainValidRecords,
);
}
}
@@ -13,6 +13,7 @@ import {
PublicDomainExceptionCode,
} from 'src/engine/core-modules/public-domain/public-domain.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
@Injectable()
export class PublicDomainService {
@@ -105,18 +106,21 @@ export class PublicDomainService {
return publicDomain;
}
async checkPublicDomainValidRecords(publicDomain: PublicDomain) {
async checkPublicDomainValidRecords(
publicDomain: PublicDomain,
domainValidRecords?: DomainValidRecords,
): Promise<DomainValidRecords | undefined> {
const publicDomainWithRecords =
await this.dnsManagerService.getHostnameWithRecords(publicDomain.domain, {
isPublicDomain: true,
});
domainValidRecords ??
(await this.dnsManagerService.getHostnameWithRecords(
publicDomain.domain,
{
isPublicDomain: true,
},
));
if (!publicDomainWithRecords) return;
await this.dnsManagerService.refreshHostname(publicDomainWithRecords, {
isPublicDomain: true,
});
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(publicDomain.domain, {
isPublicDomain: true,
@@ -130,4 +134,8 @@ export class PublicDomainService {
return publicDomainWithRecords;
}
async findByDomain(domain: string) {
return this.publicDomainRepository.findOne({ where: { domain } });
}
}
@@ -49,6 +49,7 @@ 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 { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -509,17 +510,19 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
}
}
async checkCustomDomainValidRecords(workspace: Workspace) {
if (!workspace.customDomain) return;
async checkCustomDomainValidRecords(
workspace: Workspace,
domainValidRecord?: DomainValidRecords,
) {
assertIsDefinedOrThrow(workspace.customDomain);
const customDomainWithRecords =
await this.dnsManagerService.getHostnameWithRecords(
domainValidRecord ??
(await this.dnsManagerService.getHostnameWithRecords(
workspace.customDomain,
);
));
if (!customDomainWithRecords) return;
await this.dnsManagerService.refreshHostname(customDomainWithRecords);
assertIsDefinedOrThrow(customDomainWithRecords);
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(workspace.customDomain);
@@ -543,4 +546,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
return customDomainWithRecords;
}
async findByCustomDomain(customDomain: string) {
return this.workspaceRepository.findOne({ where: { customDomain } });
}
}
@@ -15,6 +15,7 @@ export const workspaceGraphqlApiExceptionHandler = (error: Error) => {
switch (error.code) {
case WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND:
case WorkspaceExceptionCode.WORKSPACE_NOT_FOUND:
case WorkspaceExceptionCode.CUSTOM_DOMAIN_NOT_FOUND:
throw new NotFoundError(error);
case WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN:
case WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN:
@@ -9,6 +9,7 @@ export enum WorkspaceExceptionCode {
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
WORKSPACE_CUSTOM_DOMAIN_DISABLED = 'WORKSPACE_CUSTOM_DOMAIN_DISABLED',
ENVIRONMENT_VAR_NOT_ENABLED = 'ENVIRONMENT_VAR_NOT_ENABLED',
CUSTOM_DOMAIN_NOT_FOUND = 'CUSTOM_DOMAIN_NOT_FOUND',
}
export const WorkspaceNotFoundDefaultError = new WorkspaceException(
@@ -65,7 +65,12 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
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';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import {
WorkspaceException,
WorkspaceExceptionCode,
WorkspaceNotFoundDefaultError,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
@@ -94,6 +99,7 @@ export class WorkspaceResolver {
private readonly roleService: RoleService,
private readonly agentService: AgentService,
private readonly viewService: ViewService,
private readonly dnsManagerService: DnsManagerService,
) {}
@Query(() => Workspace)
@@ -388,6 +394,21 @@ export class WorkspaceResolver {
async checkCustomDomainValidRecords(
@AuthWorkspace() workspace: Workspace,
): Promise<DomainValidRecords | undefined> {
return this.workspaceService.checkCustomDomainValidRecords(workspace);
assertIsDefinedOrThrow(
workspace.customDomain,
new WorkspaceException(
`Custom domain not found`,
WorkspaceExceptionCode.CUSTOM_DOMAIN_NOT_FOUND,
),
);
const domainValidRecords = await this.dnsManagerService.refreshHostname(
workspace.customDomain,
);
return this.workspaceService.checkCustomDomainValidRecords(
workspace,
domainValidRecords,
);
}
}