From 57ff06f47c1a6843fc506401e911cdc1b152043f Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 15 Sep 2025 15:14:11 +0200 Subject: [PATCH] 1487 extensibility look into public domains to identify workspace (#14456) - identify workspace based on public domains - add cron job to validate public domains - add endpoint to validate a public domain --- .../src/generated-metadata/graphql.ts | 6 + .../twenty-front/src/generated/graphql.ts | 6 + .../commands/cron-register-all.command.ts | 6 + .../commands/database-command.module.ts | 2 + .../cloudflare/cloudflare.module.ts | 19 ++ .../controllers/dns-cloudflare.controller.ts | 62 ++++++ .../guards/cloudflare-secret.guard.ts | 0 .../guards/cloudflare-secret.spec.ts | 2 +- .../engine/core-modules/core-engine.module.ts | 2 + .../dns-cloudflare.controller.spec.ts | 106 ---------- .../controllers/dns-cloudflare.controller.ts | 45 ---- .../dns-manager/dns-manager.module.ts | 2 - .../services/dns-manager.service.ts | 2 +- ...re.validate.ts => dns-manager.validate.ts} | 0 .../domain-manager/domain-manager.module.ts | 3 +- .../services/domain-manager.service.spec.ts | 195 +++++++++++++++++- .../services/domain-manager.service.ts | 77 +++---- .../services/guard-redirect.service.ts | 12 +- ...blic-domains-valid-records.cron.command.ts | 33 +++ ...k-public-domains-valid-records.cron.job.ts | 49 +++++ .../public-domain/public-domain.module.ts | 10 +- .../public-domain/public-domain.resolver.ts | 27 ++- .../public-domain/public-domain.service.ts | 26 +++ 23 files changed, 466 insertions(+), 226 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts rename packages/twenty-server/src/engine/core-modules/{dns-manager => cloudflare}/guards/cloudflare-secret.guard.ts (100%) rename packages/twenty-server/src/engine/core-modules/{dns-manager => cloudflare}/guards/cloudflare-secret.spec.ts (97%) delete mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts rename packages/twenty-server/src/engine/core-modules/dns-manager/validator/{cloudflare.validate.ts => dns-manager.validate.ts} (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index fe6e9ed7bd..47348e64ee 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1430,6 +1430,7 @@ export type Mutation = { assignRoleToApiKey: Scalars['Boolean']; authorizeApp: AuthorizeApp; checkCustomDomainValidRecords?: Maybe; + checkPublicDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createAgentChatThread: AgentChatThread; @@ -1613,6 +1614,11 @@ export type MutationAuthorizeAppArgs = { }; +export type MutationCheckPublicDomainValidRecordsArgs = { + domain: Scalars['String']; +}; + + export type MutationCheckoutSessionArgs = { plan?: BillingPlanKey; recurringInterval: SubscriptionInterval; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 2aaa648258..4c8cb20bf9 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1387,6 +1387,7 @@ export type Mutation = { assignRoleToApiKey: Scalars['Boolean']; authorizeApp: AuthorizeApp; checkCustomDomainValidRecords?: Maybe; + checkPublicDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; createAgentChatThread: AgentChatThread; @@ -1564,6 +1565,11 @@ export type MutationAuthorizeAppArgs = { }; +export type MutationCheckPublicDomainValidRecordsArgs = { + domain: Scalars['String']; +}; + + export type MutationCheckoutSessionArgs = { plan?: BillingPlanKey; recurringInterval: SubscriptionInterval; 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 97a3f51ed5..d409fe1a75 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 @@ -17,6 +17,7 @@ import { WorkflowHandleStaledRunsCronCommand } from 'src/modules/workflow/workfl 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'; +import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command'; @Command({ name: 'cron:register:all', @@ -36,6 +37,7 @@ export class CronRegisterAllCommand extends CommandRunner { private readonly workflowCronTriggerCronCommand: WorkflowCronTriggerCronCommand, private readonly cleanupOrphanedFilesCronCommand: CleanupOrphanedFilesCronCommand, private readonly checkCustomDomainValidRecordsCronCommand: CheckCustomDomainValidRecordsCronCommand, + private readonly checkPublicDomainsValidRecordsCronCommand: CheckPublicDomainsValidRecordsCronCommand, private readonly workflowRunEnqueueCronCommand: WorkflowRunEnqueueCronCommand, private readonly workflowHandleStaledRunsCronCommand: WorkflowHandleStaledRunsCronCommand, private readonly workflowCleanWorkflowRunsCronCommand: WorkflowCleanWorkflowRunsCronCommand, @@ -82,6 +84,10 @@ export class CronRegisterAllCommand extends CommandRunner { name: 'CheckCustomDomainValidRecords', command: this.checkCustomDomainValidRecordsCronCommand, }, + { + name: 'CheckPublicDomainsValidRecords', + command: this.checkPublicDomainsValidRecordsCronCommand, + }, { name: 'WorkflowCronTrigger', command: this.workflowCronTriggerCronCommand, 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 fdd51bfb65..1696866110 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -23,6 +23,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo 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'; +import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod FeatureFlagModule, TriggerModule, WorkspaceCleanerModule, + PublicDomainModule, ], providers: [ DataSeedWorkspaceCommand, diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts new file mode 100644 index 0000000000..7e1481eea6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/cloudflare.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module'; +import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; +import { DnsCloudflareController } from 'src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller'; + +@Module({ + imports: [ + NestjsQueryTypeOrmModule.forFeature([PublicDomain, Workspace]), + WorkspaceModule, + PublicDomainModule, + ], + controllers: [DnsCloudflareController], +}) +export class CloudflareModule {} diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts new file mode 100644 index 0000000000..b8f4fcfcab --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts @@ -0,0 +1,62 @@ +/* @license Enterprise */ + +import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Request } from 'express'; +import { Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service'; +import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard'; + +@Controller() +@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter) +export class DnsCloudflareController { + constructor( + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + protected readonly workspaceService: WorkspaceService, + @InjectRepository(PublicDomain) + private readonly publicDomainRepository: Repository, + protected readonly publicDomainService: PublicDomainService, + ) {} + + @Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare']) + @UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard) + async customHostnameWebhooks(@Req() req: Request) { + const hostname = req.body?.data?.data?.hostname; + + if (!hostname) { + return; + } + + try { + const workspace = await this.workspaceRepository.findOneBy({ + customDomain: hostname, + }); + + if (isDefined(workspace)) { + await this.workspaceService.checkCustomDomainValidRecords(workspace); + } + + const publicDomain = await this.publicDomainRepository.findOneBy({ + domain: hostname, + }); + + if (isDefined(publicDomain)) { + await this.publicDomainService.checkPublicDomainValidRecords( + publicDomain, + ); + } + } catch { + return; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard.ts rename to packages/twenty-server/src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard.ts diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/guards/cloudflare-secret.spec.ts similarity index 97% rename from packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts rename to packages/twenty-server/src/engine/core-modules/cloudflare/guards/cloudflare-secret.spec.ts index d45e29e104..d5d4a027da 100644 --- a/packages/twenty-server/src/engine/core-modules/dns-manager/guards/cloudflare-secret.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/guards/cloudflare-secret.spec.ts @@ -3,7 +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 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard'; +import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard'; describe('CloudflareSecretMatchGuard.canActivate', () => { let guard: CloudflareSecretMatchGuard; 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 74e65f4e3b..19a0267f68 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 @@ -55,6 +55,7 @@ import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.modu 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 { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module'; import { AuditModule } from './audit/audit.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -82,6 +83,7 @@ import { FileModule } from './file/file.module'; WorkspaceSSOModule, ApprovedAccessDomainModule, PublicDomainModule, + CloudflareModule, DnsManagerModule, PostgresCredentialsModule, WorkflowApiModule, 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 deleted file mode 100644 index eb1a682b1f..0000000000 --- a/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 151ee11fa1..0000000000 --- a/packages/twenty-server/src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* @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 index 0f07c8e29f..11cbc620d2 100644 --- 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 @@ -2,12 +2,10 @@ 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/dns-manager/services/dns-manager.service.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts index 94f15d33c0..452a2682f2 100644 --- a/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/dns-manager/services/dns-manager.service.ts @@ -13,7 +13,7 @@ import { 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 { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/dns-manager.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'; diff --git a/packages/twenty-server/src/engine/core-modules/dns-manager/validator/cloudflare.validate.ts b/packages/twenty-server/src/engine/core-modules/dns-manager/validator/dns-manager.validate.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/dns-manager/validator/cloudflare.validate.ts rename to packages/twenty-server/src/engine/core-modules/dns-manager/validator/dns-manager.validate.ts 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 bb59b03cf4..de33d44c15 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 @@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; @Module({ - imports: [TypeOrmModule.forFeature([Workspace]), AuditModule], + imports: [TypeOrmModule.forFeature([Workspace, PublicDomain]), AuditModule], providers: [DomainManagerService], exports: [DomainManagerService], }) 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 a282a416b2..c1da8dc7fd 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 @@ -1,11 +1,11 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { type 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 { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; import { DomainManagerService } from './domain-manager.service'; @@ -62,6 +62,8 @@ describe('DomainManagerService', () => { }); let domainManagerService: DomainManagerService; let twentyConfigService: TwentyConfigService; + let workspaceRepository: Repository; + let publicDomainRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -69,7 +71,16 @@ describe('DomainManagerService', () => { DomainManagerService, { provide: getRepositoryToken(Workspace), - useClass: Repository, + useValue: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(PublicDomain), + useValue: { + findOne: jest.fn(), + }, }, { provide: TwentyConfigService, @@ -77,15 +88,15 @@ describe('DomainManagerService', () => { get: jest.fn(), }, }, - { - provide: AuditService, - useValue: { - createContext: jest.fn(), - }, - }, ], }).compile(); + workspaceRepository = module.get>( + getRepositoryToken(Workspace), + ); + publicDomainRepository = module.get>( + getRepositoryToken(PublicDomain), + ); domainManagerService = module.get(DomainManagerService); twentyConfigService = module.get(TwentyConfigService); @@ -207,4 +218,170 @@ describe('DomainManagerService', () => { expect(result.searchParams.get('baz')).toBe('123'); }); }); + + describe('getWorkspaceByOriginOrDefaultWorkspace', () => { + it('should return default workspace if IS_MULTIWORKSPACE_ENABLED=false', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: false, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'find').mockResolvedValueOnce([ + { + id: 'workspace-id', + }, + ] as unknown as Workspace[]); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://example.com', + ); + + expect(result?.id).toEqual('workspace-id'); + }); + + it('should return 1st workspace if multiple workspaces when IS_MULTIWORKSPACE_ENABLED=false', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: false, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'find').mockResolvedValueOnce([ + { + id: 'workspace-id1', + }, + { + id: 'workspace-id2', + }, + ] as unknown as Workspace[]); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://example.com', + ); + + expect(result?.id).toEqual('workspace-id1'); + }); + + it('should return workspace by subdomain', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: true, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValueOnce({ + id: 'workspace-id1', + subdomain: '123', + } as unknown as Promise); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://123.example.com', + ); + + expect(result?.id).toEqual('workspace-id1'); + }); + + it('should return workspace by customDomain', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: true, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValueOnce({ + id: 'workspace-id1', + customDomain: '123.custom.com', + } as unknown as Promise); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://123.custom.com', + ); + + expect(result?.id).toEqual('workspace-id1'); + }); + + it('should return workspace by publicDomain', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: true, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValueOnce({ + id: 'workspace-id1', + } as unknown as Promise); + + jest.spyOn(publicDomainRepository, 'findOne').mockResolvedValueOnce({ + domain: '123.custom.com', + workspaceId: 'workspace-id1', + } as unknown as Promise); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://123.custom.com', + ); + + expect(result?.id).toEqual('workspace-id1'); + }); + + it('should return undefined if nothing found', async () => { + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + const env = { + FRONTEND_URL: 'https://example.com', + IS_MULTIWORKSPACE_ENABLED: true, + }; + + // @ts-expect-error legacy noImplicitAny + return env[key]; + }); + + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValueOnce(null); + + jest.spyOn(publicDomainRepository, 'findOne').mockResolvedValueOnce(null); + + const result = + await domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( + 'https://123.custom.com', + ); + + expect(result).toEqual(undefined); + }); + }); }); 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 6b1d8c405f..a280d2f866 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 @@ -10,17 +10,17 @@ import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/ut 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 { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.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 { constructor( @InjectRepository(Workspace) private readonly workspaceRepository: Repository, + @InjectRepository(PublicDomain) + private readonly publicDomainRepository: Repository, private readonly twentyConfigService: TwentyConfigService, - private readonly auditService: AuditService, ) {} getFrontUrl() { @@ -100,7 +100,7 @@ export class DomainManagerService { return url; } - getSubdomainAndCustomDomainFromUrl = (url: string) => { + getSubdomainAndDomainFromUrl = (url: string) => { const { hostname: originHostname } = new URL(url); const frontDomain = this.getFrontUrl().hostname; @@ -114,7 +114,7 @@ export class DomainManagerService { isFrontdomain && !this.isDefaultSubdomain(subdomain) ? subdomain : undefined, - customDomain: isFrontdomain ? null : originHostname, + domain: isFrontdomain ? null : originHostname, }; }; @@ -168,19 +168,31 @@ export class DomainManagerService { return this.getDefaultWorkspace(); } - const { subdomain, customDomain } = - this.getSubdomainAndCustomDomainFromUrl(origin); + const { subdomain, domain } = this.getSubdomainAndDomainFromUrl(origin); - if (!customDomain && !subdomain) return; + if (!domain && !subdomain) return; - const where = isDefined(customDomain) ? { customDomain } : { subdomain }; + const where = isDefined(domain) ? { customDomain: domain } : { subdomain }; - return ( + const workspaceFromCustomDomainOrSubdomain = (await this.workspaceRepository.findOne({ where, relations: ['workspaceSSOIdentityProviders'], - })) ?? undefined - ); + })) ?? undefined; + + if (isDefined(workspaceFromCustomDomainOrSubdomain) || !isDefined(domain)) { + return workspaceFromCustomDomainOrSubdomain; + } + + const publicDomainFromCustomDomain = + await this.publicDomainRepository.findOne({ + where: { + domain, + }, + relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'], + }); + + return publicDomainFromCustomDomain?.workspace; } private extractSubdomain(params?: { email?: string; displayName?: string }) { @@ -255,45 +267,4 @@ 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/guard-redirect/services/guard-redirect.service.ts b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts index 408d8f6fa0..9bd0c00d06 100644 --- a/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/guard-redirect/services/guard-redirect.service.ts @@ -47,17 +47,17 @@ export class GuardRedirectService { getSubdomainAndCustomDomainFromContext(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); - const subdomainAndCustomDomainFromReferer = request.headers.referer - ? this.domainManagerService.getSubdomainAndCustomDomainFromUrl( + const subdomainAndDomainFromReferer = request.headers.referer + ? this.domainManagerService.getSubdomainAndDomainFromUrl( request.headers.referer, ) : null; - return subdomainAndCustomDomainFromReferer && - subdomainAndCustomDomainFromReferer.subdomain + return subdomainAndDomainFromReferer && + subdomainAndDomainFromReferer.subdomain ? { - subdomain: subdomainAndCustomDomainFromReferer.subdomain, - customDomain: subdomainAndCustomDomainFromReferer.customDomain, + subdomain: subdomainAndDomainFromReferer.subdomain, + customDomain: subdomainAndDomainFromReferer.domain, } : { subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command.ts b/packages/twenty-server/src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command.ts new file mode 100644 index 0000000000..4c347f04cb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command.ts @@ -0,0 +1,33 @@ +import { Command, CommandRunner } from 'nest-commander'; + +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_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN, + CheckPublicDomainsValidRecordsCronJob, +} from 'src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job'; + +@Command({ + name: 'cron:public-domain:check-public-domains-valid-records', + description: + 'Starts a cron job to check workspace public domains valid records hourly', +}) +export class CheckPublicDomainsValidRecordsCronCommand extends CommandRunner { + constructor( + @InjectMessageQueue(MessageQueue.cronQueue) + private readonly messageQueueService: MessageQueueService, + ) { + super(); + } + + async run(): Promise { + await this.messageQueueService.addCron({ + jobName: CheckPublicDomainsValidRecordsCronJob.name, + data: undefined, + options: { + repeat: { pattern: CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN }, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts b/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts new file mode 100644 index 0000000000..a5f86509d1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts @@ -0,0 +1,49 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository, Raw } from 'typeorm'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +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 { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service'; +import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; +import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; + +export const CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN = '0 * * * *'; + +@Processor(MessageQueue.cronQueue) +export class CheckPublicDomainsValidRecordsCronJob { + constructor( + @InjectRepository(PublicDomain) + private readonly publicDomainRepository: Repository, + private readonly publicDomainService: PublicDomainService, + ) {} + + @Process(CheckPublicDomainsValidRecordsCronJob.name) + @SentryCronMonitor( + CheckPublicDomainsValidRecordsCronJob.name, + CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN, + ) + async handle(): Promise { + const publicDomains = await this.publicDomainRepository.find({ + where: { + isValidated: false, + createdAt: Raw( + (alias) => `EXTRACT(HOUR FROM ${alias}) = EXTRACT(HOUR FROM NOW())`, + ), + }, + }); + + for (const publicDomain of publicDomains) { + try { + await this.publicDomainService.checkPublicDomainValidRecords( + publicDomain, + ); + } catch (error) { + throw new Error( + `[${CheckPublicDomainsValidRecordsCronJob.name}] Cannot check public domain: ${error.message}`, + ); + } + } + } +} 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 index 1efb5c2580..07ba444f7b 100644 --- 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 @@ -7,12 +7,20 @@ import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domai 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'; +import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command'; +import { CheckPublicDomainsValidRecordsCronJob } from 'src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job'; @Module({ imports: [ NestjsQueryTypeOrmModule.forFeature([PublicDomain, Workspace]), DnsManagerModule, ], - providers: [PublicDomainService, PublicDomainResolver], + exports: [CheckPublicDomainsValidRecordsCronCommand, PublicDomainService], + providers: [ + PublicDomainService, + PublicDomainResolver, + CheckPublicDomainsValidRecordsCronCommand, + CheckPublicDomainsValidRecordsCronJob, + ], }) 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 index bc9e4ada11..48405ebbf8 100644 --- 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 @@ -1,5 +1,8 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; 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'; @@ -10,6 +13,8 @@ import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/publ 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'; +import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records'; +import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity'; @UseGuards(WorkspaceAuthGuard) @UsePipes(ResolverValidationPipe) @@ -19,7 +24,11 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; ) @Resolver() export class PublicDomainResolver { - constructor(private readonly publicDomainService: PublicDomainService) {} + constructor( + @InjectRepository(PublicDomain) + private readonly publicDomainRepository: Repository, + private readonly publicDomainService: PublicDomainService, + ) {} @Mutation(() => PublicDomainDTO) async createPublicDomain( @@ -44,4 +53,20 @@ export class PublicDomainResolver { return true; } + + @Mutation(() => DomainValidRecords, { nullable: true }) + async checkPublicDomainValidRecords( + @Args() { domain }: PublicDomainInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const publicDomain = await this.publicDomainRepository.findOne({ + where: { workspaceId: workspace.id, domain }, + }); + + if (!publicDomain) { + return; + } + + return this.publicDomainService.checkPublicDomainValidRecords(publicDomain); + } } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts index 5bbbbb9baa..07a114d90c 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts @@ -101,4 +101,30 @@ export class PublicDomainService { return publicDomain; } + + async checkPublicDomainValidRecords(publicDomain: PublicDomain) { + const publicDomainWithRecords = + await this.dnsManagerService.getHostnameWithRecords(publicDomain.domain, { + isPublicDomain: true, + }); + + if (!publicDomainWithRecords) return; + + await this.dnsManagerService.refreshHostname(publicDomainWithRecords, { + isPublicDomain: true, + }); + + const isCustomDomainWorking = + await this.dnsManagerService.isHostnameWorking(publicDomain.domain, { + isPublicDomain: true, + }); + + if (publicDomain.isValidated !== isCustomDomainWorking) { + publicDomain.isValidated = isCustomDomainWorking; + + await this.publicDomainRepository.save(publicDomain); + } + + return publicDomainWithRecords; + } }