From b84f4075e533fcb4e1766d331ac8545936147f40 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 11 Sep 2025 12:24:57 +0200 Subject: [PATCH] 14240 extensibility ability to create multiple custom domains for each workspace 2 (#14307) Adds a public-domain core-module. Reorganize custom-domain files properly --- .../src/generated-metadata/graphql.ts | 58 ++-- .../twenty-front/src/generated/graphql.ts | 54 ++-- .../SettingsCustomDomainRecords.tsx | 10 +- .../hooks/useCheckCustomDomainValidRecords.ts | 9 +- .../states/customDomainRecordsState.ts | 4 +- .../queries/checkCustomDomainValidRecords.ts | 2 +- .../pages/settings/domains/SettingsDomain.tsx | 20 +- .../commands/cron-register-all.command.ts | 2 +- .../commands/database-command.module.ts | 4 +- .../1757013851879-addPublicDomainEntity.ts | 21 ++ .../approved-access-domain.service.ts | 3 + .../engine/core-modules/core-engine.module.ts | 4 + .../dns-cloudflare.controller.spec.ts | 106 +++++++ .../controllers/dns-cloudflare.controller.ts | 45 +++ .../dns-manager/dns-manager.module.ts | 13 + .../dtos/domain-valid-records.ts} | 10 +- .../dns-manager-exception-filter.ts | 27 ++ .../exceptions/dns-manager.exception.ts | 18 ++ .../guards/cloudflare-secret.guard.ts | 0 .../guards/cloudflare-secret.spec.ts | 3 +- .../services/dns-manager.service.spec.ts} | 169 ++++++++---- .../services/dns-manager.service.ts | 261 ++++++++++++++++++ .../validator/cloudflare.validate.ts | 14 +- .../controllers/cloudflare.controller.ts | 123 --------- .../controllers/cloudflare.spec.ts | 218 --------------- .../domain-manager.exception.ts | 15 - .../domain-manager/domain-manager.module.ts | 24 +- .../domain-manager/domain-manager.resolver.ts | 23 -- .../services/custom-domain.service.ts | 216 --------------- .../services/domain-manager.service.spec.ts | 7 + .../services/domain-manager.service.ts | 55 +++- .../public-domain/dtos/public-domain.dto.ts | 20 ++ .../public-domain/dtos/public-domain.input.ts | 11 + .../public-domain-exception-filter.ts | 22 ++ .../public-domain/public-domain.entity.ts | 43 +++ .../public-domain/public-domain.exception.ts | 8 + .../public-domain/public-domain.module.ts | 18 ++ .../public-domain/public-domain.resolver.ts | 47 ++++ .../public-domain/public-domain.service.ts | 104 +++++++ .../twenty-config/config-variables.ts | 18 ++ .../__tests__/workspace.service.spec.ts | 27 +- ...ustom-domain-valid-records.cron.command.ts | 13 +- ...ck-custom-domain-valid-records.cron.job.ts | 6 +- .../workspace/services/workspace.service.ts | 87 ++++-- .../workspace/workspace.auto-resolver-opts.ts | 3 +- .../workspace/workspace.entity.ts | 4 + .../workspace/workspace.module.ts | 28 +- .../workspace/workspace.resolver.ts | 15 +- 48 files changed, 1215 insertions(+), 797 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1757013851879-addPublicDomainEntity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/dns-manager.module.ts rename packages/twenty-server/src/engine/core-modules/{domain-manager/dtos/custom-domain-valid-records.ts => dns-manager/dtos/domain-valid-records.ts} (74%) create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager.exception.ts rename packages/twenty-server/src/engine/core-modules/{domain-manager => dns-manager}/guards/cloudflare-secret.guard.ts (100%) rename packages/twenty-server/src/engine/core-modules/{domain-manager => dns-manager}/guards/cloudflare-secret.spec.ts (94%) rename packages/twenty-server/src/engine/core-modules/{domain-manager/services/custom-domain.service.spec.ts => dns-manager/services/dns-manager.service.spec.ts} (61%) create mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts rename packages/twenty-server/src/engine/core-modules/{domain-manager => dns-manager}/validator/cloudflare.validate.ts (53%) delete mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.resolver.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts rename packages/twenty-server/src/engine/core-modules/{domain-manager => workspace}/crons/commands/check-custom-domain-valid-records.cron.command.ts (79%) rename packages/twenty-server/src/engine/core-modules/{domain-manager => workspace}/crons/jobs/check-custom-domain-valid-records.cron.job.ts (87%) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ec491543ba..6ecba74c2e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -868,22 +868,6 @@ export type CursorPaging = { last?: InputMaybe; }; -export type CustomDomainRecord = { - __typename?: 'CustomDomainRecord'; - key: Scalars['String']; - status: Scalars['String']; - type: Scalars['String']; - validationType: Scalars['String']; - value: Scalars['String']; -}; - -export type CustomDomainValidRecords = { - __typename?: 'CustomDomainValidRecords'; - customDomain: Scalars['String']; - id: Scalars['UUID']; - records: Array; -}; - /** Database Event Action */ export enum DatabaseEventAction { CREATED = 'CREATED', @@ -961,6 +945,22 @@ export enum DistantTableUpdate { TABLE_DELETED = 'TABLE_DELETED' } +export type DomainRecord = { + __typename?: 'DomainRecord'; + key: Scalars['String']; + status: Scalars['String']; + type: Scalars['String']; + validationType: Scalars['String']; + value: Scalars['String']; +}; + +export type DomainValidRecords = { + __typename?: 'DomainValidRecords'; + domain: Scalars['String']; + id: Scalars['UUID']; + records: Array; +}; + export type DuplicateWorkflowVersionStepInput = { stepId: Scalars['String']; workflowVersionId: Scalars['String']; @@ -1410,7 +1410,7 @@ export type Mutation = { assignRoleToAgent: Scalars['Boolean']; assignRoleToApiKey: Scalars['Boolean']; authorizeApp: AuthorizeApp; - checkCustomDomainValidRecords?: Maybe; + checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createAgentChatThread: AgentChatThread; @@ -1438,6 +1438,7 @@ export type Mutation = { createPageLayout: PageLayout; createPageLayoutTab: PageLayoutTab; createPageLayoutWidget: PageLayoutWidget; + createPublicDomain: PublicDomain; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1462,6 +1463,7 @@ export type Mutation = { deletePageLayout: PageLayout; deletePageLayoutTab: Scalars['Boolean']; deletePageLayoutWidget: PageLayoutWidget; + deletePublicDomain: Scalars['Boolean']; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1734,6 +1736,11 @@ export type MutationCreatePageLayoutWidgetArgs = { }; +export type MutationCreatePublicDomainArgs = { + domain: Scalars['String']; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1849,6 +1856,11 @@ export type MutationDeletePageLayoutWidgetArgs = { }; +export type MutationDeletePublicDomainArgs = { + domain: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -2593,6 +2605,14 @@ export type PostgresCredentials = { workspaceId: Scalars['UUID']; }; +export type PublicDomain = { + __typename?: 'PublicDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type PublicFeatureFlag = { __typename?: 'PublicFeatureFlag'; key: FeatureFlagKey; @@ -5403,7 +5423,7 @@ export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorks export type CheckCustomDomainValidRecordsMutationVariables = Exact<{ [key: string]: never; }>; -export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'CustomDomainValidRecords', id: string, customDomain: string, records: Array<{ __typename?: 'CustomDomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null }; +export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'DomainValidRecords', id: string, domain: string, records: Array<{ __typename?: 'DomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null }; export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ inviteHash: Scalars['String']; @@ -12604,7 +12624,7 @@ export const CheckCustomDomainValidRecordsDocument = gql` mutation CheckCustomDomainValidRecords { checkCustomDomainValidRecords { id - customDomain + domain records { type key diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 5c82d3c754..cd7ff875ae 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -832,22 +832,6 @@ export type CursorPaging = { last?: InputMaybe; }; -export type CustomDomainRecord = { - __typename?: 'CustomDomainRecord'; - key: Scalars['String']; - status: Scalars['String']; - type: Scalars['String']; - validationType: Scalars['String']; - value: Scalars['String']; -}; - -export type CustomDomainValidRecords = { - __typename?: 'CustomDomainValidRecords'; - customDomain: Scalars['String']; - id: Scalars['UUID']; - records: Array; -}; - /** Database Event Action */ export enum DatabaseEventAction { CREATED = 'CREATED', @@ -925,6 +909,22 @@ export enum DistantTableUpdate { TABLE_DELETED = 'TABLE_DELETED' } +export type DomainRecord = { + __typename?: 'DomainRecord'; + key: Scalars['String']; + status: Scalars['String']; + type: Scalars['String']; + validationType: Scalars['String']; + value: Scalars['String']; +}; + +export type DomainValidRecords = { + __typename?: 'DomainValidRecords'; + domain: Scalars['String']; + id: Scalars['UUID']; + records: Array; +}; + export type DuplicateWorkflowVersionStepInput = { stepId: Scalars['String']; workflowVersionId: Scalars['String']; @@ -1367,7 +1367,7 @@ export type Mutation = { assignRoleToAgent: Scalars['Boolean']; assignRoleToApiKey: Scalars['Boolean']; authorizeApp: AuthorizeApp; - checkCustomDomainValidRecords?: Maybe; + checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createAgentChatThread: AgentChatThread; @@ -1394,6 +1394,7 @@ export type Mutation = { createPageLayout: PageLayout; createPageLayoutTab: PageLayoutTab; createPageLayoutWidget: PageLayoutWidget; + createPublicDomain: PublicDomain; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1417,6 +1418,7 @@ export type Mutation = { deletePageLayout: PageLayout; deletePageLayoutTab: Scalars['Boolean']; deletePageLayoutWidget: PageLayoutWidget; + deletePublicDomain: Scalars['Boolean']; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1670,6 +1672,11 @@ export type MutationCreatePageLayoutWidgetArgs = { }; +export type MutationCreatePublicDomainArgs = { + domain: Scalars['String']; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1780,6 +1787,11 @@ export type MutationDeletePageLayoutWidgetArgs = { }; +export type MutationDeletePublicDomainArgs = { + domain: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -2504,6 +2516,14 @@ export type PostgresCredentials = { workspaceId: Scalars['UUID']; }; +export type PublicDomain = { + __typename?: 'PublicDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type PublicFeatureFlag = { __typename?: 'PublicFeatureFlag'; key: FeatureFlagKey; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx index a13aa151c5..e133c444b1 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx @@ -7,8 +7,8 @@ import styled from '@emotion/styled'; import { Button } from 'twenty-ui/input'; import { useDebouncedCallback } from 'use-debounce'; import { - type CustomDomainRecord, - type CustomDomainValidRecords, + type DomainRecord, + type DomainValidRecords, } from '~/generated/graphql'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { capitalize } from 'twenty-shared/utils'; @@ -49,7 +49,7 @@ const StyledButton = styled(Button)` export const SettingsCustomDomainRecords = ({ records, }: { - records: CustomDomainValidRecords['records']; + records: DomainValidRecords['records']; }) => { const { customDomainRecords } = useRecoilValue(customDomainRecordsState); const currentWorkspace = useRecoilValue(currentWorkspaceState); @@ -67,7 +67,7 @@ export const SettingsCustomDomainRecords = ({ ]; const defaultValues: { status: string; color: ThemeColor } = - currentWorkspace?.customDomain === customDomainRecords?.customDomain + currentWorkspace?.customDomain === customDomainRecords?.domain ? { status: 'success', color: 'green', @@ -78,7 +78,7 @@ export const SettingsCustomDomainRecords = ({ }; const rows = rowsDefinitions.map< - { name: string; status: string; color: ThemeColor } & CustomDomainRecord + { name: string; status: string; color: ThemeColor } & DomainRecord >((row) => { const record = records.find( ({ validationType }) => validationType === row.validationType, 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 78d66ff963..fe4e0e3b5c 100644 --- a/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useCheckCustomDomainValidRecords.ts @@ -1,5 +1,5 @@ import { useCheckCustomDomainValidRecordsMutation } from '~/generated-metadata/graphql'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilState } 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'; @@ -9,9 +9,14 @@ export const useCheckCustomDomainValidRecords = () => { useCheckCustomDomainValidRecordsMutation(); const { enqueueErrorSnackBar } = useSnackBar(); - const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState); + const [{ isLoading }, setCustomDomainRecords] = useRecoilState( + customDomainRecordsState, + ); const checkCustomDomainRecords = () => { + if (isLoading) { + return; + } setCustomDomainRecords((currentState) => ({ ...currentState, isLoading: true, diff --git a/packages/twenty-front/src/modules/settings/domains/states/customDomainRecordsState.ts b/packages/twenty-front/src/modules/settings/domains/states/customDomainRecordsState.ts index aead27b6ea..076b7c87e1 100644 --- a/packages/twenty-front/src/modules/settings/domains/states/customDomainRecordsState.ts +++ b/packages/twenty-front/src/modules/settings/domains/states/customDomainRecordsState.ts @@ -1,8 +1,8 @@ -import { type CustomDomainValidRecords } from '~/generated/graphql'; +import { type DomainValidRecords } from '~/generated/graphql'; import { createState } from 'twenty-ui/utilities'; export const customDomainRecordsState = createState<{ - customDomainRecords: CustomDomainValidRecords | null; + customDomainRecords: DomainValidRecords | null; isLoading: boolean; }>({ key: 'customDomainRecordsState', diff --git a/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts b/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts index 5740f776b2..4f85bd9387 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/queries/checkCustomDomainValidRecords.ts @@ -4,7 +4,7 @@ export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS = gql` mutation CheckCustomDomainValidRecords { checkCustomDomainValidRecords { id - customDomain + domain records { type key diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx index 16882ab028..2e88454f34 100644 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx @@ -5,8 +5,6 @@ import { import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain'; -import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; @@ -21,6 +19,9 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain'; +import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain'; +import { useState } from 'react'; export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID = 'subdomain-change-confirmation-modal'; @@ -61,6 +62,7 @@ export const SettingsDomain = () => { const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const [updateWorkspace] = useUpdateWorkspaceMutation(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const [isSubmitting, setIsSubmitting] = useState(false); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, @@ -88,6 +90,10 @@ export const SettingsDomain = () => { customDomain: string | null, currentWorkspace: CurrentWorkspace, ) => { + if (isSubmitting) { + return; + } + setIsSubmitting(true); updateWorkspace({ variables: { input: { @@ -106,6 +112,7 @@ export const SettingsDomain = () => { enqueueSuccessSnackBar({ message: t`Custom domain updated`, }); + setIsSubmitting(false); }, onError: (error: ApolloError) => { if ( @@ -120,6 +127,7 @@ export const SettingsDomain = () => { enqueueErrorSnackBar({ apolloError: error, }); + setIsSubmitting(false); }, }); }; @@ -128,6 +136,10 @@ export const SettingsDomain = () => { subdomain: string, currentWorkspace: CurrentWorkspace, ) => { + if (isSubmitting) { + return; + } + setIsSubmitting(true); updateWorkspace({ variables: { input: { @@ -148,6 +160,7 @@ export const SettingsDomain = () => { enqueueErrorSnackBar({ apolloError: error, }); + setIsSubmitting(false); }, onCompleted: async () => { const currentUrl = new URL(window.location.href); @@ -164,6 +177,7 @@ export const SettingsDomain = () => { enqueueSuccessSnackBar({ message: t`Subdomain updated`, }); + setIsSubmitting(false); await redirectToWorkspaceDomain(currentUrl.toString()); }, @@ -222,7 +236,7 @@ export const SettingsDomain = () => { actionButton={ navigate(SettingsPath.Domains)} - isSaveDisabled={form.formState.isSubmitting} + isSaveDisabled={isSubmitting} /> } > diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts index 2654895060..97a3f51ed5 100644 --- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts +++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts @@ -2,7 +2,6 @@ import { Logger } from '@nestjs/common'; import { Command, CommandRunner } from 'nest-commander'; -import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command'; import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/crons/commands/cleanup-orphaned-files.cron.command'; import { CronTriggerCronCommand } from 'src/engine/metadata-modules/trigger/crons/commands/cron-trigger.cron.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; @@ -17,6 +16,7 @@ import { WorkflowCleanWorkflowRunsCronCommand } from 'src/modules/workflow/workf import { WorkflowHandleStaledRunsCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-handle-staled-runs.cron.command'; import { WorkflowRunEnqueueCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-run-enqueue.cron.command'; import { WorkflowCronTriggerCronCommand } from 'src/modules/workflow/workflow-trigger/automated-trigger/crons/commands/workflow-cron-trigger.cron.command'; +import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command'; @Command({ name: 'cron:register:all', diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index f711021517..fdd51bfb65 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -7,7 +7,6 @@ import { ConfirmationQuestion } from 'src/database/commands/questions/confirmati import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; -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 { FileModule } from 'src/engine/core-modules/file/file.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -23,6 +22,7 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar- import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module'; import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module'; +import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; @Module({ imports: [ @@ -33,7 +33,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au CalendarEventImportManagerModule, AutomatedTriggerModule, FileModule, - DomainManagerModule, + WorkspaceModule, WorkflowRunQueueModule, // Data seeding dependencies TypeORMModule, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1757013851879-addPublicDomainEntity.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1757013851879-addPublicDomainEntity.ts new file mode 100644 index 0000000000..362f31b8ae --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1757013851879-addPublicDomainEntity.ts @@ -0,0 +1,21 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddPublicDomainEntity1757013851879 implements MigrationInterface { + name = 'AddPublicDomainEntity1757013851879'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."publicDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "UQ_1311e24fbd049c561c53a274f2a" UNIQUE ("domain"), CONSTRAINT "PK_ff55a0f1bc3b6e2c32feff734b1" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."publicDomain" ADD CONSTRAINT "FK_7e9ca5fd7aa30b8396ea3d1d6be" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."publicDomain" DROP CONSTRAINT "FK_7e9ca5fd7aa30b8396ea3d1d6be"`, + ); + await queryRunner.query(`DROP TABLE "core"."publicDomain"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 34b2e22ed2..a83dd9b820 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -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.`, + }, ); } diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index c796a42bdf..74e65f4e3b 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts new file mode 100644 index 0000000000..eb1a682b1f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts @@ -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); + dnsManagerService = module.get(DnsManagerService); + domainManagerService = + module.get(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, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts new file mode 100644 index 0000000000..151ee11fa1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts @@ -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; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/dns-manager.module.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/dns-manager.module.ts new file mode 100644 index 0000000000..0f07c8e29f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/dns-manager.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/dtos/domain-valid-records.ts similarity index 74% rename from packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/dtos/domain-valid-records.ts index cc861bbc0f..7a65a463aa 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/dtos/domain-valid-records.ts @@ -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; + @Field(() => [DomainRecord]) + records: Array; } diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter.ts new file mode 100644 index 0000000000..5234f2dbd2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter.ts @@ -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); + } + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager.exception.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager.exception.ts new file mode 100644 index 0000000000..1a572d712d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/exceptions/dns-manager.exception.ts @@ -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); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard.ts diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts similarity index 94% rename from packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts index e47419027c..d45e29e104 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/guards/cloudflare-secret.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.spec.ts similarity index 61% rename from packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.spec.ts index 9b6c8811a7..9467dc661d 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.spec.ts @@ -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); + dnsManagerService = module.get(DnsManagerService); twentyConfigService = module.get(TwentyConfigService); domainManagerService = module.get(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(); }); }); 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 new file mode 100644 index 0000000000..94f15d33c0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts @@ -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 { + 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), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/validator/cloudflare.validate.ts similarity index 53% rename from packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/validator/cloudflare.validate.ts index ab956ef6df..575210dab2 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/validator/cloudflare.validate.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/validator/cloudflare.validate.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts deleted file mode 100644 index c43fd94ea0..0000000000 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.controller.ts +++ /dev/null @@ -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, - 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 = { - 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(); - } - } -} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts deleted file mode 100644 index c4a6984c33..0000000000 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/controllers/cloudflare.spec.ts +++ /dev/null @@ -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; - 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); - WorkspaceRepository = module.get(getRepositoryToken(Workspace)); - twentyConfigService = module.get(TwentyConfigService); - domainManagerService = - module.get(DomainManagerService); - customDomainService = module.get(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(); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts deleted file mode 100644 index 879667ea69..0000000000 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.exception.ts +++ /dev/null @@ -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); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts index 08dd01c1b2..bb59b03cf4 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.resolver.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.resolver.ts deleted file mode 100644 index 22e0f28c02..0000000000 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/domain-manager.resolver.ts +++ /dev/null @@ -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 { - return this.customDomainService.checkCustomDomainValidRecords(workspace); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts deleted file mode 100644 index 4cc1e35fdf..0000000000 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts +++ /dev/null @@ -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, - ) { - 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 { - 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; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts index 3c0806e26d..a282a416b2 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.spec.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts index 92df10e55a..6b1d8c405f 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts @@ -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, 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, @@ -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 = { + 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, {}); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts new file mode 100644 index 0000000000..4701522e08 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.input.ts b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.input.ts new file mode 100644 index 0000000000..b21bf3eb58 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.input.ts @@ -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; +} 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 new file mode 100644 index 0000000000..1b9560db93 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts @@ -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); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts new file mode 100644 index 0000000000..fc75d0b5a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts @@ -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; +} 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 new file mode 100644 index 0000000000..7b10a894af --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts @@ -0,0 +1,8 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class PublicDomainException extends CustomException {} + +export enum PublicDomainExceptionCode { + PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED', + DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN', +} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts new file mode 100644 index 0000000000..1efb5c2580 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts new file mode 100644 index 0000000000..bc9e4ada11 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts @@ -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 { + return this.publicDomainService.createPublicDomain({ + domain, + workspace: currentWorkspace, + }); + } + + @Mutation(() => Boolean) + async deletePublicDomain( + @Args() { domain }: PublicDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise { + await this.publicDomainService.deletePublicDomain({ + domain, + workspace: currentWorkspace, + }); + + return true; + } +} 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 new file mode 100644 index 0000000000..5bbbbb9baa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts @@ -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, + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + ) {} + + async deletePublicDomain({ + domain, + workspace, + }: { + domain: string; + workspace: Workspace; + }): Promise { + 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 { + 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; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 9bbefe47bf..a2f7b03321 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/__tests__/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/__tests__/workspace.service.spec.ts index c5c34f389e..bbe9aea1fe 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/__tests__/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/__tests__/workspace.service.spec.ts @@ -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; 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( getQueueToken(MessageQueue.deleteCascadeQueue), ); - customDomainService = module.get(CustomDomainService); - customDomainService.deleteCustomHostnameByHostnameSilently = jest.fn(); + dnsManagerService = module.get(DnsManagerService); + dnsManagerService.deleteHostnameSilently = jest.fn(); billingSubscriptionService = module.get( 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, }); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command.ts b/packages/twenty-server/src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command.ts similarity index 79% rename from packages/twenty-server/src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command.ts rename to packages/twenty-server/src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command.ts index 18a49323b2..8982c98b69 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command.ts @@ -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( diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job.ts b/packages/twenty-server/src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job.ts similarity index 87% rename from packages/twenty-server/src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job.ts rename to packages/twenty-server/src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job.ts index 4f49997dac..28a3cff13b 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job.ts @@ -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, - 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}`, 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 eacab93c94..a646a64451 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 @@ -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 { constructor( @InjectRepository(Workspace) private readonly workspaceRepository: Repository, + @InjectRepository(PublicDomain) + private readonly publicDomainRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, @InjectRepository(UserWorkspace) @@ -65,8 +72,9 @@ export class WorkspaceService extends TypeOrmQueryService { 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 { } 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 { 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 { } 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 { ); 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 { } } } + + 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; + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.auto-resolver-opts.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.auto-resolver-opts.ts index cd1d91471e..3f212b62b1 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.auto-resolver-opts.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.auto-resolver-opts.ts @@ -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 diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 193a87ab3a..e473df5bfb 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -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; + @OneToMany(() => PublicDomain, (publicDomain) => publicDomain.workspace) + publicDomains: Relation; + @Field({ nullable: true }) workspaceMembersCount: number; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 4aec14be93..d46662dead 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -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 {} 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 87ee2afbf6..70da88ebfb 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,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 { + return this.workspaceService.checkCustomDomainValidRecords(workspace); + } }