From 695b4a21399ca653a11ffd5368fd5fddf2832b2c Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 6 Oct 2025 16:39:19 +0200 Subject: [PATCH] fix(cloudflare): cloudflare webhook (#14834) --- .../hooks/useCheckCustomDomainValidRecords.ts | 6 ++- .../pages/settings/domains/SettingsDomain.tsx | 4 ++ .../cloudflare/cloudflare.module.ts | 2 + .../controllers/dns-cloudflare.controller.ts | 43 ++++++------------- .../services/dns-cloudflare.service.ts | 31 +++++++++++++ .../services/dns-manager.service.ts | 27 ++++++++---- .../public-domain-exception-filter.ts | 7 ++- .../public-domain/public-domain.exception.ts | 1 + .../public-domain/public-domain.resolver.ts | 31 ++++++++++--- .../public-domain/public-domain.service.ts | 24 +++++++---- .../workspace/services/workspace.service.ts | 21 ++++++--- ...pace-graphql-api-exception-handler.util.ts | 1 + .../workspace/workspace.exception.ts | 1 + .../workspace/workspace.resolver.ts | 25 ++++++++++- 14 files changed, 159 insertions(+), 65 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/cloudflare/services/dns-cloudflare.service.ts diff --git a/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts b/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts index fe4e0e3b5c5..3567cb40e5e 100644 --- a/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts @@ -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) => ({ diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx index 99f6af6304e..a6efeba7d48 100644 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx @@ -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 = () => { navigate(SettingsPath.Domains)} isSaveDisabled={isSubmitting} + onSave={handleSave} /> } > diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts index 7e1481eea65..264ad12dcbc 100644 --- a/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts index b8f4fcfcab1..44c972a69b2 100644 --- a/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts @@ -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, - protected readonly workspaceService: WorkspaceService, - @InjectRepository(PublicDomain) - private readonly publicDomainRepository: Repository, - 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; } diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/services/dns-cloudflare.service.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/services/dns-cloudflare.service.ts new file mode 100644 index 00000000000..fec5417a1af --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/services/dns-cloudflare.service.ts @@ -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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts index 452a2682f29..aea15eff86d 100644 --- a/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts @@ -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) { diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts index 1b9560db939..8848ae6d5bc 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts @@ -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); } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts index 7b10a894afd..7f15aa9fcfb 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts @@ -5,4 +5,5 @@ export class PublicDomainException extends CustomException, 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, + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts index 775340686e6..bc721ec1c1e 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts @@ -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 { 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 } }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 6397a0be179..6d945a680f6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -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 { } } - 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 { return customDomainWithRecords; } + + async findByCustomDomain(customDomain: string) { + return this.workspaceRepository.findOne({ where: { customDomain } }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util.ts index 7235bf51a30..3e7b21dafb1 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util.ts @@ -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: diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts index c336f01c9cf..1d9eacfd848 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts @@ -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( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 0a89b7439a6..83ca49590e6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -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 { - 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, + ); } }