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:
martmull
2025-09-15 15:14:11 +02:00
committed by GitHub
parent c481fda0bd
commit 57ff06f47c
23 changed files with 466 additions and 226 deletions
@@ -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 {}
@@ -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;
}
}
}
@@ -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,
@@ -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,
});
});
});
@@ -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 {}
@@ -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';
@@ -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],
})
@@ -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);
});
});
});
@@ -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, {});
}
}
}
@@ -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'),
@@ -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 },
},
});
}
}
@@ -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}`,
);
}
}
}
}
@@ -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 {}
@@ -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;
}
}