feat(domain-manager): refactor custom domain validation and improve c… (#13388)
This commit is contained in:
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApiKeyException extends CustomException {
|
||||
declare code: ApiKeyExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiKeyExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class ApiKeyException extends CustomException<ApiKeyExceptionCode> {}
|
||||
|
||||
export enum ApiKeyExceptionCode {
|
||||
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
|
||||
|
||||
+1
-10
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApprovedAccessDomainException extends CustomException {
|
||||
declare code: ApprovedAccessDomainExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApprovedAccessDomainExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class ApprovedAccessDomainException extends CustomException<ApprovedAccessDomainExceptionCode> {}
|
||||
|
||||
export enum ApprovedAccessDomainExceptionCode {
|
||||
APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND',
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class AuditException extends CustomException {
|
||||
declare code: AuditExceptionCode;
|
||||
constructor(message: string, code: AuditExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class AuditException extends CustomException<AuditExceptionCode> {}
|
||||
|
||||
export enum AuditExceptionCode {
|
||||
INVALID_TYPE = 'INVALID_TYPE',
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export class AuthException extends CustomException {
|
||||
declare code: AuthExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: AuthExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class AuthException extends CustomException<
|
||||
keyof typeof AuthExceptionCode
|
||||
> {}
|
||||
|
||||
export enum AuthExceptionCode {
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND',
|
||||
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
|
||||
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
|
||||
INSUFFICIENT_SCOPES = 'INSUFFICIENT_SCOPES',
|
||||
UNAUTHENTICATED = 'UNAUTHENTICATED',
|
||||
INVALID_DATA = 'INVALID_DATA',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
OAUTH_ACCESS_DENIED = 'OAUTH_ACCESS_DENIED',
|
||||
SSO_AUTH_FAILED = 'SSO_AUTH_FAILED',
|
||||
USE_SSO_AUTH = 'USE_SSO_AUTH',
|
||||
SIGNUP_DISABLED = 'SIGNUP_DISABLED',
|
||||
GOOGLE_API_AUTH_DISABLED = 'GOOGLE_API_AUTH_DISABLED',
|
||||
MICROSOFT_API_AUTH_DISABLED = 'MICROSOFT_API_AUTH_DISABLED',
|
||||
MISSING_ENVIRONMENT_VARIABLE = 'MISSING_ENVIRONMENT_VARIABLE',
|
||||
INVALID_JWT_TOKEN_TYPE = 'INVALID_JWT_TOKEN_TYPE',
|
||||
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED = 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
|
||||
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED = 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
|
||||
}
|
||||
export const AuthExceptionCode = appendCommonExceptionCode({
|
||||
USER_NOT_FOUND: 'USER_NOT_FOUND',
|
||||
USER_WORKSPACE_NOT_FOUND: 'USER_WORKSPACE_NOT_FOUND',
|
||||
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
|
||||
CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
|
||||
INVALID_INPUT: 'INVALID_INPUT',
|
||||
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
|
||||
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
|
||||
UNAUTHENTICATED: 'UNAUTHENTICATED',
|
||||
INVALID_DATA: 'INVALID_DATA',
|
||||
OAUTH_ACCESS_DENIED: 'OAUTH_ACCESS_DENIED',
|
||||
SSO_AUTH_FAILED: 'SSO_AUTH_FAILED',
|
||||
USE_SSO_AUTH: 'USE_SSO_AUTH',
|
||||
SIGNUP_DISABLED: 'SIGNUP_DISABLED',
|
||||
GOOGLE_API_AUTH_DISABLED: 'GOOGLE_API_AUTH_DISABLED',
|
||||
MICROSOFT_API_AUTH_DISABLED: 'MICROSOFT_API_AUTH_DISABLED',
|
||||
MISSING_ENVIRONMENT_VARIABLE: 'MISSING_ENVIRONMENT_VARIABLE',
|
||||
INVALID_JWT_TOKEN_TYPE: 'INVALID_JWT_TOKEN_TYPE',
|
||||
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
|
||||
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
|
||||
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
|
||||
} as const);
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class BillingException extends CustomException {
|
||||
constructor(message: string, code: BillingExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class BillingException extends CustomException<BillingExceptionCode> {}
|
||||
|
||||
export enum BillingExceptionCode {
|
||||
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class CaptchaException extends CustomException {
|
||||
declare code: CaptchaExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: CaptchaExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class CaptchaException extends CustomException<CaptchaExceptionCode> {}
|
||||
|
||||
export enum CaptchaExceptionCode {
|
||||
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
|
||||
|
||||
+66
-45
@@ -42,58 +42,79 @@ export class CloudflareController {
|
||||
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
|
||||
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
|
||||
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
|
||||
if (!req.body?.data?.data?.hostname) {
|
||||
try {
|
||||
// Cloudflare documentation is inaccurate - some webhooks lack the hostname field.
|
||||
// Fallback to extracting hostname from validation_records.
|
||||
const hostname =
|
||||
req.body?.data?.data?.hostname ??
|
||||
req.body?.data?.data?.ssl?.validation_records?.[0]?.txt_name?.replace(
|
||||
/^_acme-challenge\./,
|
||||
'',
|
||||
);
|
||||
|
||||
if (!hostname) {
|
||||
handleException({
|
||||
exception: new DomainManagerException(
|
||||
'Hostname missing',
|
||||
DomainManagerExceptionCode.INVALID_INPUT_DATA,
|
||||
{ userFriendlyMessage: 'Hostname missing' },
|
||||
),
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
customDomain: hostname,
|
||||
});
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const customDomainDetails =
|
||||
await this.customDomainService.getCustomDomainDetails(hostname);
|
||||
|
||||
const workspaceUpdated: Partial<Workspace> = {
|
||||
customDomain: workspace.customDomain,
|
||||
};
|
||||
|
||||
if (!customDomainDetails) {
|
||||
workspaceUpdated.customDomain = null;
|
||||
}
|
||||
|
||||
workspaceUpdated.isCustomDomainEnabled = customDomainDetails
|
||||
? this.domainManagerService.isCustomDomainWorking(customDomainDetails)
|
||||
: false;
|
||||
|
||||
if (
|
||||
workspaceUpdated.isCustomDomainEnabled !==
|
||||
workspace.isCustomDomainEnabled ||
|
||||
workspaceUpdated.customDomain !== workspace.customDomain
|
||||
) {
|
||||
await this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...workspaceUpdated,
|
||||
});
|
||||
|
||||
await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
} catch (err) {
|
||||
handleException({
|
||||
exception: new DomainManagerException(
|
||||
'Hostname missing',
|
||||
DomainManagerExceptionCode.INVALID_INPUT_DATA,
|
||||
err.message ?? 'Unknown error occurred',
|
||||
DomainManagerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
{ userFriendlyMessage: 'Unknown error occurred' },
|
||||
),
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
customDomain: req.body.data.data.hostname,
|
||||
});
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const customDomainDetails =
|
||||
await this.customDomainService.getCustomDomainDetails(
|
||||
req.body.data.data.hostname,
|
||||
);
|
||||
|
||||
const workspaceUpdated: Partial<Workspace> = {
|
||||
customDomain: workspace.customDomain,
|
||||
};
|
||||
|
||||
if (!customDomainDetails && workspace) {
|
||||
workspaceUpdated.customDomain = null;
|
||||
}
|
||||
|
||||
workspaceUpdated.isCustomDomainEnabled = customDomainDetails
|
||||
? this.domainManagerService.isCustomDomainWorking(customDomainDetails)
|
||||
: false;
|
||||
|
||||
if (
|
||||
workspaceUpdated.isCustomDomainEnabled !==
|
||||
workspace.isCustomDomainEnabled ||
|
||||
workspaceUpdated.customDomain !== workspace.customDomain
|
||||
) {
|
||||
await this.workspaceRepository.save({
|
||||
...workspace,
|
||||
...workspaceUpdated,
|
||||
});
|
||||
|
||||
await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import {
|
||||
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
} from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:domain-manager:check-custom-domain-valid-records',
|
||||
description: 'Starts a cron job to check custom domain valid records hourly',
|
||||
})
|
||||
export class CheckCustomDomainValidRecordsCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: CheckCustomDomainValidRecordsCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: { pattern: CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Not, Repository, Raw } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
|
||||
export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN = '0 * * * *';
|
||||
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class CheckCustomDomainValidRecordsCronJob {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
) {}
|
||||
|
||||
@Process(CheckCustomDomainValidRecordsCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
CheckCustomDomainValidRecordsCronJob.name,
|
||||
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
const workspaces = await this.workspaceRepository.find({
|
||||
where: {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
customDomain: Not(IsNull()),
|
||||
createdAt: Raw(
|
||||
(alias) => `EXTRACT(HOUR FROM ${alias}) = EXTRACT(HOUR FROM NOW())`,
|
||||
),
|
||||
},
|
||||
select: ['id', 'customDomain', 'isCustomDomainEnabled'],
|
||||
});
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
try {
|
||||
await this.customDomainService.checkCustomDomainValidRecords(workspace);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[${CheckCustomDomainValidRecordsCronJob.name}] Cannot check custom domain for workspaces: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-12
@@ -1,14 +1,15 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export class DomainManagerException extends CustomException {
|
||||
constructor(message: string, code: DomainManagerExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class DomainManagerException extends CustomException<
|
||||
keyof typeof DomainManagerExceptionCode,
|
||||
true
|
||||
> {}
|
||||
|
||||
export enum DomainManagerExceptionCode {
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
|
||||
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
|
||||
INVALID_INPUT_DATA = 'INVALID_INPUT_DATA',
|
||||
}
|
||||
export const DomainManagerExceptionCode = appendCommonExceptionCode({
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
|
||||
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
|
||||
} as const);
|
||||
|
||||
+15
-2
@@ -3,14 +3,27 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerResolver } from 'src/engine/core-modules/domain-manager/domain-manager.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, TypeOrmModule.forFeature([Workspace], 'core')],
|
||||
providers: [DomainManagerService, CustomDomainService],
|
||||
exports: [DomainManagerService, CustomDomainService],
|
||||
providers: [
|
||||
DomainManagerResolver,
|
||||
DomainManagerService,
|
||||
CustomDomainService,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
],
|
||||
exports: [
|
||||
DomainManagerService,
|
||||
CustomDomainService,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
],
|
||||
controllers: [CloudflareController],
|
||||
})
|
||||
export class DomainManagerModule {}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards, UsePipes } from '@nestjs/common';
|
||||
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@Resolver()
|
||||
export class DomainManagerResolver {
|
||||
constructor(private readonly customDomainService: CustomDomainService) {}
|
||||
|
||||
@Mutation(() => CustomDomainValidRecords, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async checkCustomDomainValidRecords(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
return this.customDomainService.checkCustomDomainValidRecords(workspace);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -3,17 +3,17 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
@ObjectType()
|
||||
class CustomDomainRecord {
|
||||
@Field(() => String)
|
||||
validationType: 'ownership' | 'ssl' | 'redirection';
|
||||
validationType: 'ssl' | 'redirection';
|
||||
|
||||
@Field(() => String)
|
||||
type: 'txt' | 'cname';
|
||||
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
type: 'cname';
|
||||
|
||||
@Field(() => String)
|
||||
status: string;
|
||||
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String)
|
||||
value: string;
|
||||
}
|
||||
|
||||
+17
-23
@@ -1,4 +1,5 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
|
||||
@@ -9,6 +10,7 @@ import { DomainManagerException } from 'src/engine/core-modules/domain-manager/d
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
jest.mock('cloudflare');
|
||||
|
||||
@@ -39,6 +41,12 @@ describe('CustomDomainService', () => {
|
||||
getBaseUrl: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -62,7 +70,12 @@ describe('CustomDomainService', () => {
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockApiKey);
|
||||
|
||||
const instance = new CustomDomainService(twentyConfigService, {} as any);
|
||||
const instance = new CustomDomainService(
|
||||
twentyConfigService,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
|
||||
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
|
||||
@@ -138,6 +151,9 @@ describe('CustomDomainService', () => {
|
||||
hostname: customDomain,
|
||||
ownership_verification: undefined,
|
||||
verification_errors: [],
|
||||
ssl: {
|
||||
dcv_delegation_records: [],
|
||||
},
|
||||
};
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
@@ -284,26 +300,4 @@ describe('CustomDomainService', () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomDomainWorking', () => {
|
||||
it('should return true if all records have success status', () => {
|
||||
const customDomainDetails = {
|
||||
records: [{ status: 'success' }, { status: 'success' }],
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
customDomainService.isCustomDomainWorking(customDomainDetails),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if any record does not have success status', () => {
|
||||
const customDomainDetails = {
|
||||
records: [{ status: 'success' }, { status: 'pending' }],
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
customDomainService.isCustomDomainWorking(customDomainDetails),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+107
-78
@@ -1,8 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CustomHostnameCreateParams } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
@@ -12,6 +15,10 @@ import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomDomainService {
|
||||
@@ -20,6 +27,9 @@ export class CustomDomainService {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly auditService: AuditService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {
|
||||
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
@@ -28,6 +38,22 @@ export class CustomDomainService {
|
||||
}
|
||||
}
|
||||
|
||||
private get sslParams(): CustomHostnameCreateParams['ssl'] {
|
||||
return {
|
||||
method: 'txt',
|
||||
type: 'dv',
|
||||
settings: {
|
||||
http2: 'on',
|
||||
min_tls_version: '1.2',
|
||||
tls_1_3: 'on',
|
||||
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
|
||||
early_hints: 'on',
|
||||
},
|
||||
bundle_method: 'ubiquitous',
|
||||
wildcard: false,
|
||||
};
|
||||
}
|
||||
|
||||
async registerCustomDomain(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
@@ -35,25 +61,14 @@ export class CustomDomainService {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
{ userFriendlyMessage: 'Hostname already registered' },
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname: customDomain,
|
||||
ssl: {
|
||||
method: 'txt',
|
||||
type: 'dv',
|
||||
settings: {
|
||||
http2: 'on',
|
||||
min_tls_version: '1.2',
|
||||
tls_1_3: 'on',
|
||||
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
|
||||
early_hints: 'on',
|
||||
},
|
||||
bundle_method: 'ubiquitous',
|
||||
wildcard: false,
|
||||
},
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,70 +87,45 @@ export class CustomDomainService {
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
const { hostname, id, ssl, verification_errors, created_at } =
|
||||
response.result[0];
|
||||
// @ts-expect-error - type definition doesn't reflect the real API
|
||||
const dcvRecords = ssl?.dcv_delegation_records?.[0];
|
||||
|
||||
return {
|
||||
id: response.result[0].id,
|
||||
customDomain: response.result[0].hostname,
|
||||
id: id,
|
||||
customDomain: hostname,
|
||||
records: [
|
||||
response.result[0].ownership_verification,
|
||||
...(response.result[0].ssl?.validation_records ?? []),
|
||||
]
|
||||
.map<CustomDomainValidRecords['records'][0] | undefined>((record) => {
|
||||
if (!record) return;
|
||||
|
||||
if (
|
||||
'txt_name' in record &&
|
||||
'txt_value' in record &&
|
||||
record.txt_name &&
|
||||
record.txt_value
|
||||
) {
|
||||
return {
|
||||
validationType: 'ssl' as const,
|
||||
type: 'txt' as const,
|
||||
status:
|
||||
!response.result[0].ssl.status ||
|
||||
response.result[0].ssl.status.startsWith('pending')
|
||||
? 'pending'
|
||||
: response.result[0].ssl.status,
|
||||
key: record.txt_name,
|
||||
value: record.txt_value,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
'type' in record &&
|
||||
record.type === 'txt' &&
|
||||
record.value &&
|
||||
record.name
|
||||
) {
|
||||
return {
|
||||
validationType: 'ownership' as const,
|
||||
type: 'txt' as const,
|
||||
status: response.result[0].status ?? 'pending',
|
||||
key: record.name,
|
||||
value: record.value,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter(isDefined)
|
||||
.concat([
|
||||
{
|
||||
validationType: 'redirection' as const,
|
||||
type: 'cname' as const,
|
||||
status:
|
||||
// wait 10s before starting the real check
|
||||
response.result[0].created_at &&
|
||||
new Date().getTime() -
|
||||
new Date(response.result[0].created_at).getTime() <
|
||||
1000 * 10
|
||||
? 'pending'
|
||||
: response.result[0].verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
key: response.result[0].hostname,
|
||||
value: this.domainManagerService.getBaseUrl().hostname,
|
||||
},
|
||||
]),
|
||||
{
|
||||
validationType: 'redirection' as const,
|
||||
type: 'cname',
|
||||
status:
|
||||
// wait 10s before starting the real check
|
||||
created_at &&
|
||||
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
|
||||
? 'pending'
|
||||
: verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
key: hostname,
|
||||
value: this.domainManagerService.getBaseUrl().hostname,
|
||||
},
|
||||
{
|
||||
validationType: 'ssl' as const,
|
||||
type: 'cname',
|
||||
status:
|
||||
!ssl.status || ssl.status.startsWith('pending')
|
||||
? 'pending'
|
||||
: ssl.status === 'active'
|
||||
? 'success'
|
||||
: ssl.status,
|
||||
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
|
||||
value:
|
||||
dcvRecords?.cname_target ??
|
||||
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,9 +169,48 @@ export class CustomDomainService {
|
||||
});
|
||||
}
|
||||
|
||||
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
|
||||
return customDomainDetails.records.every(
|
||||
({ status }) => status === 'success',
|
||||
private async refreshCustomDomain(
|
||||
customDomainDetails: CustomDomainValidRecords,
|
||||
) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.edit(customDomainDetails.id, {
|
||||
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
|
||||
ssl: this.sslParams,
|
||||
});
|
||||
}
|
||||
|
||||
async checkCustomDomainValidRecords(workspace: Workspace) {
|
||||
if (!workspace.customDomain) return;
|
||||
|
||||
const customDomainDetails = await this.getCustomDomainDetails(
|
||||
workspace.customDomain,
|
||||
);
|
||||
|
||||
if (!customDomainDetails) return;
|
||||
|
||||
await this.refreshCustomDomain(customDomainDetails);
|
||||
|
||||
const isCustomDomainWorking =
|
||||
this.domainManagerService.isCustomDomainWorking(customDomainDetails);
|
||||
|
||||
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
|
||||
workspace.isCustomDomainEnabled = isCustomDomainWorking;
|
||||
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
analytics.insertWorkspaceEvent(
|
||||
workspace.isCustomDomainEnabled
|
||||
? CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return customDomainDetails;
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -1,4 +1,5 @@
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
@@ -12,6 +13,9 @@ const isCloudflareInstanceDefined = (
|
||||
throw new DomainManagerException(
|
||||
'Cloudflare instance is not defined',
|
||||
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
|
||||
{
|
||||
userFriendlyMessage: t`Environnement variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+1
-10
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class EmailVerificationException extends CustomException {
|
||||
declare code: EmailVerificationExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: EmailVerificationExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage ?? message);
|
||||
}
|
||||
}
|
||||
export class EmailVerificationException extends CustomException<EmailVerificationExceptionCode> {}
|
||||
|
||||
export enum EmailVerificationExceptionCode {
|
||||
EMAIL_VERIFICATION_NOT_REQUIRED = 'EMAIL_VERIFICATION_NOT_REQUIRED',
|
||||
|
||||
+1
-7
@@ -1,13 +1,7 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class FeatureFlagException extends CustomException {
|
||||
constructor(message: string, code: FeatureFlagExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class FeatureFlagException extends CustomException<FeatureFlagExceptionCode> {}
|
||||
|
||||
export enum FeatureFlagExceptionCode {
|
||||
INVALID_FEATURE_FLAG_KEY = 'INVALID_FEATURE_FLAG_KEY',
|
||||
FEATURE_FLAG_IS_NOT_PUBLIC = 'FEATURE_FLAG_IS_NOT_PUBLIC',
|
||||
FEATURE_FLAG_NOT_FOUND = 'FEATURE_FLAG_NOT_FOUND',
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { UnknownException } from 'src/utils/custom-exception';
|
||||
|
||||
describe('featureFlagValidator', () => {
|
||||
describe('assertIsFeatureFlagKey', () => {
|
||||
@@ -7,7 +7,7 @@ describe('featureFlagValidator', () => {
|
||||
expect(() =>
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
'IS_AI_ENABLED',
|
||||
new CustomException('Error', 'Error'),
|
||||
new UnknownException('Error', 'Error'),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
@@ -16,14 +16,14 @@ describe('featureFlagValidator', () => {
|
||||
expect(() =>
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
'IS_WORKFLOW_FILTERING_ENABLED',
|
||||
new CustomException('Error', 'Error'),
|
||||
new UnknownException('Error', 'Error'),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error if featureFlagKey is invalid', () => {
|
||||
const invalidKey = 'InvalidKey';
|
||||
const exception = new CustomException('Error', 'Error');
|
||||
const exception = new UnknownException('Error', 'Error');
|
||||
|
||||
expect(() =>
|
||||
featureFlagValidator.assertIsFeatureFlagKey(invalidKey, exception),
|
||||
|
||||
-2
@@ -4,7 +4,6 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
@@ -22,7 +21,6 @@ export class CleanupOrphanedFilesCronJob {
|
||||
@InjectRepository(FileEntity, 'core')
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
private readonly fileMetadataService: FileMetadataService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
@Process(CleanupOrphanedFilesCronJob.name)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export enum FileExceptionCode {
|
||||
UNAUTHENTICATED = 'UNAUTHENTICATED',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
}
|
||||
export class FileException extends CustomException<
|
||||
keyof typeof FileExceptionCode
|
||||
> {}
|
||||
|
||||
export class FileException extends CustomException {
|
||||
constructor(message: string, code: FileExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export const FileExceptionCode = appendCommonExceptionCode({
|
||||
UNAUTHENTICATED: 'UNAUTHENTICATED',
|
||||
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
||||
} as const);
|
||||
|
||||
@@ -170,42 +170,6 @@ export class ForbiddenError extends BaseGraphQLError {
|
||||
}
|
||||
}
|
||||
|
||||
export class PersistedQueryNotFoundError extends BaseGraphQLError {
|
||||
constructor(customException: CustomException);
|
||||
|
||||
constructor(message?: string, extensions?: RestrictedGraphQLErrorExtensions);
|
||||
|
||||
constructor(
|
||||
messageOrException?: string | CustomException,
|
||||
extensions?: RestrictedGraphQLErrorExtensions,
|
||||
) {
|
||||
super(
|
||||
messageOrException || 'PersistedQueryNotFound',
|
||||
ErrorCode.PERSISTED_QUERY_NOT_FOUND,
|
||||
extensions,
|
||||
);
|
||||
Object.defineProperty(this, 'name', {
|
||||
value: 'PersistedQueryNotFoundError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PersistedQueryNotSupportedError extends BaseGraphQLError {
|
||||
constructor(
|
||||
messageOrException?: string | CustomException,
|
||||
extensions?: RestrictedGraphQLErrorExtensions,
|
||||
) {
|
||||
super(
|
||||
messageOrException || 'PersistedQueryNotSupported',
|
||||
ErrorCode.PERSISTED_QUERY_NOT_SUPPORTED,
|
||||
extensions,
|
||||
);
|
||||
Object.defineProperty(this, 'name', {
|
||||
value: 'PersistedQueryNotSupportedError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInputError extends BaseGraphQLError {
|
||||
constructor(exception: CustomException);
|
||||
|
||||
|
||||
+1
-10
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class RecordTransformerException extends CustomException {
|
||||
declare code: RecordTransformerExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: RecordTransformerExceptionCode,
|
||||
userFriendlyMessage?: string,
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class RecordTransformerException extends CustomException<RecordTransformerExceptionCode> {}
|
||||
|
||||
export enum RecordTransformerExceptionCode {
|
||||
INVALID_URL = 'INVALID_URL',
|
||||
|
||||
+12
-6
@@ -43,7 +43,7 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
|
||||
throw new RecordTransformerException(
|
||||
`Invalid country code ${countryCode}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_COUNTRY_CODE,
|
||||
t`Invalid country code ${countryCode}`,
|
||||
{ userFriendlyMessage: t`Invalid country code ${countryCode}` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
|
||||
throw new RecordTransformerException(
|
||||
`Invalid calling code ${callingCode}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_CALLING_CODE,
|
||||
t`Invalid calling code ${callingCode}`,
|
||||
{ userFriendlyMessage: t`Invalid calling code ${callingCode}` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
|
||||
throw new RecordTransformerException(
|
||||
`Provided country code and calling code are conflicting`,
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE,
|
||||
t`Provided country code and calling code are conflicting`,
|
||||
{
|
||||
userFriendlyMessage: t`Provided country code and calling code are conflicting`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -91,7 +93,7 @@ const parsePhoneNumberExceptionWrapper = ({
|
||||
throw new RecordTransformerException(
|
||||
`Provided phone number is invalid ${number}`,
|
||||
RecordTransformerExceptionCode.INVALID_PHONE_NUMBER,
|
||||
t`Provided phone number is invalid ${number}`,
|
||||
{ userFriendlyMessage: t`Provided phone number is invalid ${number}` },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -115,7 +117,9 @@ const validateAndInferMetadataFromPrimaryPhoneNumber = ({
|
||||
throw new RecordTransformerException(
|
||||
'Provided and inferred country code are conflicting',
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_COUNTRY_CODE,
|
||||
t`Provided and inferred country code are conflicting`,
|
||||
{
|
||||
userFriendlyMessage: t`Provided and inferred country code are conflicting`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +131,9 @@ const validateAndInferMetadataFromPrimaryPhoneNumber = ({
|
||||
throw new RecordTransformerException(
|
||||
'Provided and inferred calling code are conflicting',
|
||||
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE,
|
||||
t`Provided and inferred calling code are conflicting`,
|
||||
{
|
||||
userFriendlyMessage: t`Provided and inferred calling code are conflicting`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-6
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class SearchException extends CustomException {
|
||||
declare code: SearchExceptionCode;
|
||||
constructor(message: string, code: SearchExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class SearchException extends CustomException<SearchExceptionCode> {}
|
||||
|
||||
export enum SearchExceptionCode {
|
||||
LABEL_IDENTIFIER_FIELD_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_NOT_FOUND',
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class SSOException extends CustomException {
|
||||
constructor(message: string, code: SSOExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class SSOException extends CustomException<SSOExceptionCode> {}
|
||||
|
||||
export enum SSOExceptionCode {
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
INVALID_SSO_CONFIGURATION = 'INVALID_SSO_CONFIGURATION',
|
||||
IDENTITY_PROVIDER_NOT_FOUND = 'IDENTITY_PROVIDER_NOT_FOUND',
|
||||
INVALID_ISSUER_URL = 'INVALID_ISSUER_URL',
|
||||
INVALID_IDP_TYPE = 'INVALID_IDP_TYPE',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ThrottlerException extends CustomException {
|
||||
constructor(message: string, code: ThrottlerExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class ThrottlerException extends CustomException<ThrottlerExceptionCode> {}
|
||||
|
||||
export enum ThrottlerExceptionCode {
|
||||
LIMIT_REACHED = 'LIMIT_REACHED',
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class SendEmailToolException extends CustomException {
|
||||
constructor(message: string, code: SendEmailToolExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class SendEmailToolException extends CustomException<SendEmailToolExceptionCode> {}
|
||||
|
||||
export enum SendEmailToolExceptionCode {
|
||||
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
|
||||
|
||||
@@ -972,6 +972,15 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
CLOUDFLARE_WEBHOOK_SECRET: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.Other,
|
||||
description:
|
||||
'Id to generate value for CNAME record to validate ownership and manage ssl for custom hostname with Cloudflare',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
CLOUDFLARE_DCV_DELEGATION_ID: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
|
||||
+1
-6
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ConfigVariableException extends CustomException {
|
||||
declare code: ConfigVariableExceptionCode;
|
||||
constructor(message: string, code: ConfigVariableExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class ConfigVariableException extends CustomException<ConfigVariableExceptionCode> {}
|
||||
|
||||
export enum ConfigVariableExceptionCode {
|
||||
DATABASE_CONFIG_DISABLED = 'DATABASE_CONFIG_DISABLED',
|
||||
|
||||
+1
-6
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class TwoFactorAuthenticationException extends CustomException {
|
||||
declare code: TwoFactorAuthenticationExceptionCode;
|
||||
constructor(message: string, code: TwoFactorAuthenticationExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class TwoFactorAuthenticationException extends CustomException<TwoFactorAuthenticationExceptionCode> {}
|
||||
|
||||
export enum TwoFactorAuthenticationExceptionCode {
|
||||
INVALID_CONFIGURATION = 'INVALID_CONFIGURATION',
|
||||
|
||||
+1
-6
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class UserWorkspaceException extends CustomException {
|
||||
declare code: UserWorkspaceExceptionCode;
|
||||
constructor(message: string, code: UserWorkspaceExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class UserWorkspaceException extends CustomException<UserWorkspaceExceptionCode> {}
|
||||
|
||||
export enum UserWorkspaceExceptionCode {
|
||||
USER_WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class UserException extends CustomException {
|
||||
constructor(message: string, code: UserExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class UserException extends CustomException<UserExceptionCode> {}
|
||||
|
||||
export enum UserExceptionCode {
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WebhookException extends CustomException {
|
||||
declare code: WebhookExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: WebhookExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
export class WebhookException extends CustomException<WebhookExceptionCode> {}
|
||||
|
||||
export enum WebhookExceptionCode {
|
||||
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceInvitationException extends CustomException {
|
||||
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class WorkspaceInvitationException extends CustomException<WorkspaceInvitationExceptionCode> {}
|
||||
|
||||
export enum WorkspaceInvitationExceptionCode {
|
||||
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',
|
||||
|
||||
+1
-38
@@ -8,14 +8,10 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
|
||||
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import {
|
||||
@@ -67,10 +63,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
|
||||
@@ -182,6 +176,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
workspace.customDomain,
|
||||
);
|
||||
workspace.isCustomDomainEnabled = false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -401,38 +396,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
return !existingWorkspace;
|
||||
}
|
||||
|
||||
async checkCustomDomainValidRecords(workspace: Workspace) {
|
||||
if (!workspace.customDomain) return;
|
||||
|
||||
const customDomainDetails =
|
||||
await this.customDomainService.getCustomDomainDetails(
|
||||
workspace.customDomain,
|
||||
);
|
||||
|
||||
if (!customDomainDetails) return;
|
||||
|
||||
const isCustomDomainWorking =
|
||||
this.domainManagerService.isCustomDomainWorking(customDomainDetails);
|
||||
|
||||
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
|
||||
workspace.isCustomDomainEnabled = isCustomDomainWorking;
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
const analytics = this.auditService.createContext({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
analytics.insertWorkspaceEvent(
|
||||
workspace.isCustomDomainEnabled
|
||||
? CUSTOM_DOMAIN_ACTIVATED_EVENT
|
||||
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return customDomainDetails;
|
||||
}
|
||||
|
||||
private async validateSecurityPermissions({
|
||||
payload,
|
||||
userWorkspaceId,
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
describe('workspaceGraphqlApiExceptionHandler', () => {
|
||||
it('should throw NotFoundError when WorkspaceExceptionCode is SUBDOMAIN_NOT_FOUND', () => {
|
||||
@@ -48,7 +47,7 @@ describe('workspaceGraphqlApiExceptionHandler', () => {
|
||||
const error = new WorkspaceException('Unknown error', 'UNKNOWN_CODE');
|
||||
|
||||
expect(() => workspaceGraphqlApiExceptionHandler(error)).toThrow(
|
||||
CustomException,
|
||||
WorkspaceException,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceException extends CustomException {
|
||||
declare code: WorkspaceExceptionCode;
|
||||
constructor(message: string, code: WorkspaceExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
export class WorkspaceException extends CustomException<WorkspaceExceptionCode> {}
|
||||
|
||||
export enum WorkspaceExceptionCode {
|
||||
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
|
||||
|
||||
@@ -5,11 +5,9 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
@@ -26,6 +24,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
|
||||
import { Workspace } from './workspace.entity';
|
||||
@@ -38,7 +37,6 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
TypeOrmModule.forFeature([BillingSubscription], 'core'),
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
DomainManagerModule,
|
||||
BillingModule,
|
||||
FileModule,
|
||||
TokenModule,
|
||||
@@ -56,9 +54,9 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
TypeORMModule,
|
||||
PermissionsModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
AuditModule,
|
||||
RoleModule,
|
||||
AgentModule,
|
||||
DomainManagerModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
|
||||
@@ -25,7 +25,6 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
|
||||
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
@@ -70,7 +69,7 @@ import { Workspace } from './workspace.entity';
|
||||
import { WorkspaceService } from './services/workspace.service';
|
||||
|
||||
const OriginHeader = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
(_: unknown, ctx: ExecutionContext) => {
|
||||
const request = getRequest(ctx);
|
||||
|
||||
return request.headers['origin'];
|
||||
@@ -318,14 +317,6 @@ export class WorkspaceResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CustomDomainValidRecords, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async checkCustomDomainValidRecords(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
return this.workspaceService.checkCustomDomainValidRecords(workspace);
|
||||
}
|
||||
|
||||
@Query(() => PublicWorkspaceDataOutput)
|
||||
@UseGuards(PublicEndpointGuard)
|
||||
async getPublicWorkspaceDataByDomain(
|
||||
|
||||
Reference in New Issue
Block a user