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
This commit is contained in:
@@ -1430,6 +1430,7 @@ export type Mutation = {
|
||||
assignRoleToApiKey: Scalars['Boolean'];
|
||||
authorizeApp: AuthorizeApp;
|
||||
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
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;
|
||||
|
||||
@@ -1387,6 +1387,7 @@ export type Mutation = {
|
||||
assignRoleToApiKey: Scalars['Boolean'];
|
||||
authorizeApp: AuthorizeApp;
|
||||
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
+62
@@ -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<Workspace>,
|
||||
protected readonly workspaceService: WorkspaceService,
|
||||
@InjectRepository(PublicDomain)
|
||||
private readonly publicDomainRepository: Repository<PublicDomain>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
-106
@@ -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>(DnsCloudflareController);
|
||||
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(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,
|
||||
});
|
||||
});
|
||||
});
|
||||
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+2
-1
@@ -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],
|
||||
})
|
||||
|
||||
+186
-9
@@ -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<Workspace>;
|
||||
let publicDomainRepository: Repository<PublicDomain>;
|
||||
|
||||
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<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace),
|
||||
);
|
||||
publicDomainRepository = module.get<Repository<PublicDomain>>(
|
||||
getRepositoryToken(PublicDomain),
|
||||
);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
twentyConfigService = module.get<TwentyConfigService>(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<Workspace>);
|
||||
|
||||
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<Workspace>);
|
||||
|
||||
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<Workspace>);
|
||||
|
||||
jest.spyOn(publicDomainRepository, 'findOne').mockResolvedValueOnce({
|
||||
domain: '123.custom.com',
|
||||
workspaceId: 'workspace-id1',
|
||||
} as unknown as Promise<PublicDomain>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+24
-53
@@ -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<Workspace>,
|
||||
@InjectRepository(PublicDomain)
|
||||
private readonly publicDomainRepository: Repository<PublicDomain>,
|
||||
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<Workspace> = {
|
||||
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, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -47,17 +47,17 @@ export class GuardRedirectService {
|
||||
getSubdomainAndCustomDomainFromContext(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
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'),
|
||||
|
||||
+33
@@ -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<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CheckPublicDomainsValidRecordsCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: { pattern: CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -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<PublicDomain>,
|
||||
private readonly publicDomainService: PublicDomainService,
|
||||
) {}
|
||||
|
||||
@Process(CheckPublicDomainsValidRecordsCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
CheckPublicDomainsValidRecordsCronJob.name,
|
||||
CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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 {}
|
||||
|
||||
+26
-1
@@ -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<PublicDomain>,
|
||||
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<DomainValidRecords | undefined> {
|
||||
const publicDomain = await this.publicDomainRepository.findOne({
|
||||
where: { workspaceId: workspace.id, domain },
|
||||
});
|
||||
|
||||
if (!publicDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.publicDomainService.checkPublicDomainValidRecords(publicDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user