fix(cloudflare): cloudflare webhook (#14834)
This commit is contained in:
+4
-2
@@ -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 {}
|
||||
|
||||
+12
-31
@@ -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;
|
||||
}
|
||||
|
||||
+31
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-9
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+26
-5
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-8
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
+14
-7
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user