feat(workspace): add support for custom domain status toggle (#10114)
Introduce isCustomDomainEnabled field in Workspace entity to manage custom domain activation. Update related services, types, and logic to validate and toggle the custom domain's status dynamically based on its current state. This ensures accurate domain configurations are reflected across the system. --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@ -124,6 +124,7 @@ export class GoogleAPIsAuthController {
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@ -20,6 +20,7 @@ import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.au
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -28,6 +29,7 @@ export class GoogleAuthController {
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
@ -118,7 +120,7 @@ export class GoogleAuthController {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
currentWorkspace,
|
||||
),
|
||||
),
|
||||
|
||||
@ -131,6 +131,7 @@ export class MicrosoftAPIsAuthController {
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@ -19,6 +19,7 @@ import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/micros
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -29,6 +30,7 @@ export class MicrosoftAuthController {
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -119,7 +121,7 @@ export class MicrosoftAuthController {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
currentWorkspace,
|
||||
),
|
||||
),
|
||||
|
||||
@ -37,6 +37,7 @@ import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.s
|
||||
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class SSOAuthController {
|
||||
@ -44,6 +45,8 @@ export class SSOAuthController {
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
|
||||
private readonly sSOService: SSOService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@ -157,7 +160,7 @@ export class SSOAuthController {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspaceIdentityProvider?.workspace,
|
||||
),
|
||||
),
|
||||
|
||||
@ -14,6 +14,7 @@ import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
@ -23,6 +24,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super({
|
||||
prompt: 'select_account',
|
||||
@ -71,7 +73,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
@ -18,6 +19,7 @@ export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super({
|
||||
prompt: 'select_account',
|
||||
@ -51,7 +53,7 @@ export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -12,9 +12,9 @@ import { MicrosoftAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/a
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
|
||||
@ -22,11 +22,11 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
|
||||
) {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super({
|
||||
prompt: 'select_account',
|
||||
@ -72,7 +72,7 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
@ -13,6 +14,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super({
|
||||
prompt: 'select_account',
|
||||
@ -39,7 +41,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -14,12 +14,14 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class OIDCAuthGuard extends AuthGuard('openidconnect') {
|
||||
constructor(
|
||||
private readonly sSOService: SSOService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -88,7 +90,7 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
identityProvider?.workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -9,6 +9,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser
|
||||
import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard';
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
const createMockExecutionContext = (mockedRequest: any): ExecutionContext => {
|
||||
return {
|
||||
@ -58,6 +59,13 @@ describe('OIDCAuthGuard', () => {
|
||||
getSubdomainAndCustomDomainFromWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain:
|
||||
jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -14,12 +14,14 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class SAMLAuthGuard extends AuthGuard('saml') {
|
||||
constructor(
|
||||
private readonly sSOService: SSOService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -49,7 +51,7 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndCustomDomainFromWorkspace(
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
identityProvider?.workspace,
|
||||
),
|
||||
);
|
||||
|
||||
@ -56,6 +56,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
||||
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -459,7 +460,7 @@ export class AuthService {
|
||||
billingCheckoutSessionState,
|
||||
}: {
|
||||
loginToken: string;
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>;
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
|
||||
billingCheckoutSessionState?: string;
|
||||
}) {
|
||||
const url = this.domainManagerService.buildWorkspaceURL({
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Response, Request } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
|
||||
@Controller('cloudflare')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class CloudflareController {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
protected readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
@Post('custom-hostname-webhooks')
|
||||
@UseGuards(CloudflareSecretMatchGuard)
|
||||
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
|
||||
if (!req.body?.data?.data?.hostname) {
|
||||
handleException(
|
||||
new DomainManagerException(
|
||||
'Hostname missing',
|
||||
DomainManagerExceptionCode.INVALID_INPUT_DATA,
|
||||
),
|
||||
this.exceptionHandlerService,
|
||||
);
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
customDomain: req.body.data.data.hostname,
|
||||
});
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
|
||||
describe('CloudflareController - customHostnameWebhooks', () => {
|
||||
let controller: CloudflareController;
|
||||
let WorkspaceRepository: Repository<Workspace>;
|
||||
let environmentService: EnvironmentService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
let customDomainService: CustomDomainService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CloudflareController],
|
||||
providers: [
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useValue: {
|
||||
findOneBy: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
isCustomDomainWorking: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CustomDomainService,
|
||||
useValue: {
|
||||
getCustomDomainDetails: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: HttpExceptionHandlerService,
|
||||
useValue: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ExceptionHandlerService,
|
||||
useValue: {
|
||||
captureExceptions: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CloudflareController>(CloudflareController);
|
||||
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
customDomainService = module.get<CustomDomainService>(CustomDomainService);
|
||||
});
|
||||
|
||||
it('should handle exception and return status 200 if hostname is missing', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: {} } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret');
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update workspace for a valid hostname and save changes', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'example.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret');
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue({
|
||||
records: [
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
} as unknown as CustomDomainValidRecords);
|
||||
jest
|
||||
.spyOn(domainManagerService, 'isCustomDomainWorking')
|
||||
.mockReturnValue(true);
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'example.com',
|
||||
isCustomDomainEnabled: false,
|
||||
} as Workspace);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'example.com',
|
||||
});
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
'example.com',
|
||||
);
|
||||
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
|
||||
customDomain: 'example.com',
|
||||
isCustomDomainEnabled: true,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove customDomain if no hostname found', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'notfound.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret');
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'notfound.com',
|
||||
isCustomDomainEnabled: true,
|
||||
} as Workspace);
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'notfound.com',
|
||||
});
|
||||
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
it('should do nothing if nothing changes', async () => {
|
||||
const req = {
|
||||
headers: { 'cf-webhook-auth': 'correct-secret' },
|
||||
body: { data: { data: { hostname: 'nothing-change.com' } } },
|
||||
} as unknown as Request;
|
||||
const sendMock = jest.fn();
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: sendMock,
|
||||
} as unknown as Response;
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('correct-secret');
|
||||
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
|
||||
customDomain: 'nothing-change.com',
|
||||
isCustomDomainEnabled: true,
|
||||
} as Workspace);
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValue({
|
||||
records: [
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
} as unknown as CustomDomainValidRecords);
|
||||
jest
|
||||
.spyOn(domainManagerService, 'isCustomDomainWorking')
|
||||
.mockReturnValue(true);
|
||||
|
||||
await controller.customHostnameWebhooks(req, res);
|
||||
|
||||
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
|
||||
customDomain: 'nothing-change.com',
|
||||
});
|
||||
expect(WorkspaceRepository.save).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(sendMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -10,4 +10,5 @@ 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',
|
||||
}
|
||||
|
||||
@ -3,10 +3,13 @@ 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 { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Workspace], 'core')],
|
||||
providers: [DomainManagerService],
|
||||
exports: [DomainManagerService],
|
||||
providers: [DomainManagerService, CustomDomainService],
|
||||
exports: [DomainManagerService, CustomDomainService],
|
||||
controllers: [CloudflareController],
|
||||
})
|
||||
export class DomainManagerModule {}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType = Pick<
|
||||
Workspace,
|
||||
'subdomain' | 'customDomain' | 'isCustomDomainEnabled'
|
||||
>;
|
||||
@ -1,7 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
class CustomDomainVerification {
|
||||
class CustomDomainRecord {
|
||||
@Field(() => String)
|
||||
validationType: 'ownership' | 'ssl' | 'redirection';
|
||||
|
||||
@ -19,13 +19,13 @@ class CustomDomainVerification {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CustomDomainDetails {
|
||||
export class CustomDomainValidRecords {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
customDomain: string;
|
||||
|
||||
@Field(() => [CustomDomainVerification])
|
||||
records: Array<CustomDomainVerification>;
|
||||
@Field(() => [CustomDomainRecord])
|
||||
records: Array<CustomDomainRecord>;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class CloudflareSecretMatchGuard implements CanActivate {
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
try {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const cloudflareWebhookSecret = this.environmentService.get(
|
||||
'CLOUDFLARE_WEBHOOK_SECRET',
|
||||
);
|
||||
|
||||
if (
|
||||
!cloudflareWebhookSecret ||
|
||||
(cloudflareWebhookSecret &&
|
||||
(typeof request.headers['cf-webhook-auth'] === 'string' ||
|
||||
timingSafeEqual(
|
||||
Buffer.from(request.headers['cf-webhook-auth']),
|
||||
Buffer.from(cloudflareWebhookSecret),
|
||||
)))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
import { CloudflareSecretMatchGuard } from './cloudflare-secret.guard';
|
||||
|
||||
describe('CloudflareSecretMatchGuard.canActivate', () => {
|
||||
let guard: CloudflareSecretMatchGuard;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(() => {
|
||||
environmentService = {
|
||||
get: jest.fn(),
|
||||
} as unknown as EnvironmentService;
|
||||
guard = new CloudflareSecretMatchGuard(environmentService);
|
||||
});
|
||||
|
||||
it('should return true when the webhook secret matches', () => {
|
||||
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('valid-secret');
|
||||
|
||||
const mockContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
|
||||
|
||||
expect(guard.canActivate(mockContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when env is not set', () => {
|
||||
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
|
||||
|
||||
const mockContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
|
||||
|
||||
expect(guard.canActivate(mockContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if an error occurs', () => {
|
||||
const mockRequest = { headers: {} };
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('valid-secret');
|
||||
|
||||
const mockContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
expect(guard.canActivate(mockContext)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,271 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
|
||||
jest.mock('cloudflare');
|
||||
|
||||
describe('CustomDomainService', () => {
|
||||
let customDomainService: CustomDomainService;
|
||||
let environmentService: EnvironmentService;
|
||||
let domainManagerService: DomainManagerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CustomDomainService,
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
getFrontUrl: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
customDomainService = module.get<CustomDomainService>(CustomDomainService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
|
||||
(customDomainService as any).cloudflareClient = {
|
||||
customHostnames: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize cloudflareClient when CLOUDFLARE_API_KEY is defined', () => {
|
||||
const mockApiKey = 'test-api-key';
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(mockApiKey);
|
||||
|
||||
const instance = new CustomDomainService(environmentService, {} as any);
|
||||
|
||||
expect(environmentService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
|
||||
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
|
||||
expect(instance.cloudflareClient).toBeDefined();
|
||||
});
|
||||
|
||||
describe('registerCustomDomain', () => {
|
||||
it('should throw an error when the hostname is already registered', async () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({} as any);
|
||||
|
||||
await expect(
|
||||
customDomainService.registerCustomDomain(customDomain),
|
||||
).rejects.toThrow(DomainManagerException);
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
customDomain,
|
||||
);
|
||||
});
|
||||
|
||||
it('should register a custom domain successfully', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const createMock = jest.fn().mockResolvedValueOnce({});
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
create: createMock,
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce(undefined);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await customDomainService.registerCustomDomain(customDomain);
|
||||
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
zone_id: 'test-zone-id',
|
||||
hostname: customDomain,
|
||||
ssl: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomDomainDetails', () => {
|
||||
it('should return undefined if no custom domain details are found', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
list: jest.fn().mockResolvedValueOnce({ result: [] }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result =
|
||||
await customDomainService.getCustomDomainDetails(customDomain);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(cloudflareMock.customHostnames.list).toHaveBeenCalledWith({
|
||||
zone_id: 'test-zone-id',
|
||||
hostname: customDomain,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return domain details if a single result is found', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const mockResult = {
|
||||
id: 'custom-id',
|
||||
hostname: customDomain,
|
||||
ownership_verification: {
|
||||
type: 'txt',
|
||||
name: 'ownership',
|
||||
value: 'value',
|
||||
},
|
||||
ssl: {
|
||||
validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }],
|
||||
},
|
||||
verification_errors: [],
|
||||
};
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id');
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getFrontUrl')
|
||||
.mockReturnValue(new URL('https://front.domain'));
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
const result =
|
||||
await customDomainService.getCustomDomainDetails(customDomain);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'custom-id',
|
||||
customDomain: customDomain,
|
||||
records: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if multiple results are found', async () => {
|
||||
const customDomain = 'example.com';
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
list: jest.fn().mockResolvedValueOnce({ result: [{}, {}] }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await expect(
|
||||
customDomainService.getCustomDomainDetails(customDomain),
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomDomain', () => {
|
||||
it('should update a custom domain and register a new one', async () => {
|
||||
const fromHostname = 'old.com';
|
||||
const toHostname = 'new.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({ id: 'old-id' } as any);
|
||||
jest
|
||||
.spyOn(customDomainService, 'deleteCustomHostname')
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const registerSpy = jest
|
||||
.spyOn(customDomainService, 'registerCustomDomain')
|
||||
.mockResolvedValueOnce({} as unknown as CustomHostnameCreateResponse);
|
||||
|
||||
await customDomainService.updateCustomDomain(fromHostname, toHostname);
|
||||
|
||||
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
|
||||
fromHostname,
|
||||
);
|
||||
expect(customDomainService.deleteCustomHostname).toHaveBeenCalledWith(
|
||||
'old-id',
|
||||
);
|
||||
expect(registerSpy).toHaveBeenCalledWith(toHostname);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCustomHostnameByHostnameSilently', () => {
|
||||
it('should delete the custom hostname silently', async () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockResolvedValueOnce({ id: 'custom-id' } as any);
|
||||
const deleteMock = jest.fn();
|
||||
const cloudflareMock = {
|
||||
customHostnames: {
|
||||
delete: deleteMock,
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('test-zone-id');
|
||||
(customDomainService as any).cloudflareClient = cloudflareMock;
|
||||
|
||||
await expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
customDomain,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(deleteMock).toHaveBeenCalledWith('custom-id', {
|
||||
zone_id: 'test-zone-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should silently handle errors', async () => {
|
||||
const customDomain = 'example.com';
|
||||
|
||||
jest
|
||||
.spyOn(customDomainService, 'getCustomDomainDetails')
|
||||
.mockRejectedValueOnce(new Error('Failure'));
|
||||
|
||||
await expect(
|
||||
customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
customDomain,
|
||||
),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,179 @@
|
||||
/* @license Enterprise */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomDomainService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async registerCustomDomain(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (isDefined(await this.getCustomDomainDetails(customDomain))) {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.environmentService.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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomDomainDetails(
|
||||
customDomain: string,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const response = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname: customDomain,
|
||||
});
|
||||
|
||||
if (response.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
return {
|
||||
id: response.result[0].id,
|
||||
customDomain: response.result[0].hostname,
|
||||
records: [
|
||||
response.result[0].ownership_verification,
|
||||
...(response.result[0].ssl?.validation_records ?? []),
|
||||
]
|
||||
.map<CustomDomainValidRecords['records'][0] | undefined>(
|
||||
(record: Record<string, string>) => {
|
||||
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 ?? 'pending',
|
||||
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:
|
||||
response.result[0].verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
key: response.result[0].hostname,
|
||||
value: this.domainManagerService.getFrontUrl().hostname,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// should never append. error 5xx
|
||||
throw new Error('More than one custom hostname found in cloudflare');
|
||||
}
|
||||
|
||||
async updateCustomDomain(fromHostname: string, toHostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostname = await this.getCustomDomainDetails(fromHostname);
|
||||
|
||||
if (fromCustomHostname) {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return this.registerCustomDomain(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostname = await this.getCustomDomainDetails(customDomain);
|
||||
|
||||
if (customHostname) {
|
||||
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomHostname(customHostnameId: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
|
||||
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
|
||||
return customDomainDetails.records.every(
|
||||
({ status }) => status === 'success',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ describe('DomainManagerService', () => {
|
||||
const result = domainManagerService.getWorkspaceUrls({
|
||||
subdomain: 'subdomain',
|
||||
customDomain: 'custom-host.com',
|
||||
isCustomDomainEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@ -47,7 +48,8 @@ describe('DomainManagerService', () => {
|
||||
|
||||
const result = domainManagerService.getWorkspaceUrls({
|
||||
subdomain: 'subdomain',
|
||||
customDomain: undefined,
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@ -155,7 +157,8 @@ describe('DomainManagerService', () => {
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
customDomain: undefined,
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -177,7 +180,8 @@ describe('DomainManagerService', () => {
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
customDomain: undefined,
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
},
|
||||
pathname: '/path/to/resource',
|
||||
});
|
||||
@ -200,7 +204,8 @@ describe('DomainManagerService', () => {
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
customDomain: undefined,
|
||||
customDomain: null,
|
||||
isCustomDomainEnabled: false,
|
||||
},
|
||||
searchParams: {
|
||||
foo: 'bar',
|
||||
|
||||
@ -1,37 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import Cloudflare from 'cloudflare';
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details';
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
|
||||
|
||||
@Injectable()
|
||||
export class DomainManagerService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
) {}
|
||||
|
||||
getFrontUrl() {
|
||||
let baseUrl: URL;
|
||||
@ -78,7 +65,7 @@ export class DomainManagerService {
|
||||
}: {
|
||||
emailVerificationToken: string;
|
||||
email: string;
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>;
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
|
||||
}) {
|
||||
return this.buildWorkspaceURL({
|
||||
workspace,
|
||||
@ -92,7 +79,7 @@ export class DomainManagerService {
|
||||
pathname,
|
||||
searchParams,
|
||||
}: {
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>;
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType;
|
||||
pathname?: string;
|
||||
searchParams?: Record<string, string | number>;
|
||||
}) {
|
||||
@ -129,7 +116,7 @@ export class DomainManagerService {
|
||||
isFrontdomain && !this.isDefaultSubdomain(subdomain)
|
||||
? subdomain
|
||||
: undefined,
|
||||
customDomain: isFrontdomain ? undefined : originHostname,
|
||||
customDomain: isFrontdomain ? null : originHostname,
|
||||
};
|
||||
};
|
||||
|
||||
@ -147,7 +134,7 @@ export class DomainManagerService {
|
||||
|
||||
computeRedirectErrorUrl(
|
||||
errorMessage: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>,
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
|
||||
) {
|
||||
const url = this.buildWorkspaceURL({
|
||||
workspace,
|
||||
@ -237,149 +224,6 @@ export class DomainManagerService {
|
||||
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
|
||||
}
|
||||
|
||||
async registerCustomDomain(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (await this.getCustomDomainDetails(customDomain)) {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.environmentService.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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomDomainDetails(
|
||||
customDomain: string,
|
||||
): Promise<CustomDomainDetails | undefined> {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const response = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname: customDomain,
|
||||
});
|
||||
|
||||
if (response.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
return {
|
||||
id: response.result[0].id,
|
||||
customDomain: response.result[0].hostname,
|
||||
records: [
|
||||
response.result[0].ownership_verification,
|
||||
...(response.result[0].ssl?.validation_records ?? []),
|
||||
]
|
||||
.map<CustomDomainDetails['records'][0] | undefined>(
|
||||
(record: Record<string, string>) => {
|
||||
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 ?? 'pending',
|
||||
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:
|
||||
response.result[0].verification_errors?.[0] ===
|
||||
'custom hostname does not CNAME to this zone.'
|
||||
? 'error'
|
||||
: 'success',
|
||||
key: response.result[0].hostname,
|
||||
value: this.getFrontUrl().hostname,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// should never append. error 5xx
|
||||
throw new Error('More than one custom hostname found in cloudflare');
|
||||
}
|
||||
|
||||
async updateCustomDomain(fromHostname: string, toHostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostname = await this.getCustomDomainDetails(fromHostname);
|
||||
|
||||
if (fromCustomHostname) {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return this.registerCustomDomain(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(customDomain: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostname = await this.getCustomDomainDetails(customDomain);
|
||||
|
||||
if (customHostname) {
|
||||
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomHostname(customHostnameId: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
return this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
|
||||
private getCustomWorkspaceUrl(customDomain: string) {
|
||||
const url = this.getFrontUrl();
|
||||
|
||||
@ -396,14 +240,42 @@ export class DomainManagerService {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
workspace?: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType | null,
|
||||
) {
|
||||
if (!workspace) {
|
||||
return {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!workspace.isCustomDomainEnabled) {
|
||||
return {
|
||||
subdomain: workspace.subdomain,
|
||||
customDomain: null,
|
||||
};
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
|
||||
return customDomainDetails.records.every(
|
||||
({ status }) => status === 'success',
|
||||
);
|
||||
}
|
||||
|
||||
getWorkspaceUrls({
|
||||
subdomain,
|
||||
customDomain,
|
||||
}: Pick<Workspace, 'subdomain' | 'customDomain'>) {
|
||||
isCustomDomainEnabled,
|
||||
}: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType) {
|
||||
return {
|
||||
customUrl: customDomain
|
||||
? this.getCustomWorkspaceUrl(customDomain)
|
||||
: undefined,
|
||||
customUrl:
|
||||
isCustomDomainEnabled && customDomain
|
||||
? this.getCustomWorkspaceUrl(customDomain)
|
||||
: undefined,
|
||||
subdomainUrl: this.getTwentyWorkspaceUrl(subdomain),
|
||||
};
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -39,7 +39,7 @@ export class EmailVerificationService {
|
||||
async sendVerificationEmail(
|
||||
userId: string,
|
||||
email: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>,
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
return { success: false };
|
||||
@ -83,7 +83,7 @@ export class EmailVerificationService {
|
||||
|
||||
async resendEmailVerificationToken(
|
||||
email: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'customDomain'>,
|
||||
workspace: WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
throw new EmailVerificationException(
|
||||
|
||||
@ -820,6 +820,14 @@ export class EnvironmentVariables {
|
||||
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
|
||||
CLOUDFLARE_ZONE_ID: string;
|
||||
|
||||
@EnvironmentVariablesMetadata({
|
||||
group: EnvironmentVariablesGroup.Other,
|
||||
description: 'Random string to validate queries from Cloudflare',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
CLOUDFLARE_WEBHOOK_SECRET: string;
|
||||
|
||||
@EnvironmentVariablesMetadata({
|
||||
group: EnvironmentVariablesGroup.LLM,
|
||||
description: 'Driver for the LLM chat model',
|
||||
|
||||
@ -7,7 +7,6 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GuardRedirectService {
|
||||
@ -20,7 +19,12 @@ export class GuardRedirectService {
|
||||
dispatchErrorFromGuard(
|
||||
context: ExecutionContext,
|
||||
error: Error | CustomException,
|
||||
workspace: { id?: string; subdomain: string; customDomain?: string },
|
||||
workspace: {
|
||||
id?: string;
|
||||
subdomain: string;
|
||||
customDomain: string | null;
|
||||
isCustomDomainEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
if ('contextType' in context && context.contextType === 'graphql') {
|
||||
throw error;
|
||||
@ -32,22 +36,7 @@ export class GuardRedirectService {
|
||||
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
|
||||
}
|
||||
|
||||
getSubdomainAndCustomDomainFromWorkspace(
|
||||
workspace?: Pick<Workspace, 'subdomain' | 'customDomain'> | null,
|
||||
) {
|
||||
if (!workspace) {
|
||||
return {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
};
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
getSubdomainAndCustomDomainFromContext(context: ExecutionContext): {
|
||||
subdomain: string;
|
||||
customDomain?: string;
|
||||
} {
|
||||
getSubdomainAndCustomDomainFromContext(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const subdomainAndCustomDomainFromReferer = request.headers.referer
|
||||
@ -56,12 +45,16 @@ export class GuardRedirectService {
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
subdomain:
|
||||
subdomainAndCustomDomainFromReferer?.subdomain ??
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: subdomainAndCustomDomainFromReferer?.customDomain,
|
||||
};
|
||||
return subdomainAndCustomDomainFromReferer &&
|
||||
subdomainAndCustomDomainFromReferer.subdomain
|
||||
? {
|
||||
subdomain: subdomainAndCustomDomainFromReferer.subdomain,
|
||||
customDomain: subdomainAndCustomDomainFromReferer.customDomain,
|
||||
}
|
||||
: {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
customDomain: null,
|
||||
};
|
||||
}
|
||||
|
||||
private captureException(err: Error | CustomException, workspaceId?: string) {
|
||||
@ -76,13 +69,22 @@ export class GuardRedirectService {
|
||||
|
||||
getRedirectErrorUrlAndCaptureExceptions(
|
||||
err: Error | CustomException,
|
||||
workspace: { id?: string; subdomain: string; customDomain?: string },
|
||||
workspace: {
|
||||
id?: string;
|
||||
subdomain: string;
|
||||
customDomain: string | null;
|
||||
isCustomDomainEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
this.captureException(err, workspace.id);
|
||||
|
||||
return this.domainManagerService.computeRedirectErrorUrl(
|
||||
err instanceof AuthException ? err.message : 'Unknown error',
|
||||
workspace,
|
||||
{
|
||||
subdomain: workspace.subdomain,
|
||||
customDomain: workspace.customDomain,
|
||||
isCustomDomainEnabled: workspace.isCustomDomainEnabled ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@ -17,6 +16,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
@ -55,6 +56,10 @@ describe('WorkspaceService', () => {
|
||||
provide: DomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: CustomDomainService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: BillingSubscriptionService,
|
||||
useValue: {},
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
|
||||
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -59,6 +60,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly customDomainService: CustomDomainService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
}
|
||||
@ -111,7 +113,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
workspace.customDomain !== customDomain &&
|
||||
isDefined(workspace.customDomain)
|
||||
) {
|
||||
await this.domainManagerService.updateCustomDomain(
|
||||
await this.customDomainService.updateCustomDomain(
|
||||
workspace.customDomain,
|
||||
customDomain,
|
||||
);
|
||||
@ -122,7 +124,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
workspace.customDomain !== customDomain &&
|
||||
!isDefined(workspace.customDomain)
|
||||
) {
|
||||
await this.domainManagerService.registerCustomDomain(customDomain);
|
||||
await this.customDomainService.registerCustomDomain(customDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,7 +148,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
let customDomainRegistered = false;
|
||||
|
||||
if (payload.customDomain === null && isDefined(workspace.customDomain)) {
|
||||
await this.domainManagerService.deleteCustomHostnameByHostnameSilently(
|
||||
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
|
||||
workspace.customDomain,
|
||||
);
|
||||
}
|
||||
@ -179,7 +181,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
} catch (error) {
|
||||
// revert custom domain registration on error
|
||||
if (payload.customDomain && customDomainRegistered) {
|
||||
this.domainManagerService
|
||||
this.customDomainService
|
||||
.deleteCustomHostnameByHostnameSilently(payload.customDomain)
|
||||
.catch((err) => {
|
||||
this.exceptionHandlerService.captureExceptions([err]);
|
||||
@ -320,4 +322,25 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return customDomainDetails;
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,9 +122,9 @@ export class Workspace {
|
||||
@Column({ unique: true })
|
||||
subdomain: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ unique: true, nullable: true })
|
||||
customDomain?: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ type: 'varchar', unique: true, nullable: true })
|
||||
customDomain: string | null;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
@ -137,4 +137,8 @@ export class Workspace {
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isMicrosoftAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isCustomDomainEnabled: boolean;
|
||||
}
|
||||
|
||||
@ -17,7 +17,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 { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
@ -47,6 +46,7 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
|
||||
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
@ -229,14 +229,12 @@ export class WorkspaceResolver {
|
||||
return this.domainManagerService.getWorkspaceUrls(workspace);
|
||||
}
|
||||
|
||||
@Query(() => CustomDomainDetails, { nullable: true })
|
||||
@Mutation(() => CustomDomainValidRecords, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async getCustomDomainDetails(
|
||||
@AuthWorkspace() { customDomain }: Workspace,
|
||||
): Promise<CustomDomainDetails | undefined> {
|
||||
if (!customDomain) return undefined;
|
||||
|
||||
return await this.domainManagerService.getCustomDomainDetails(customDomain);
|
||||
async checkCustomDomainValidRecords(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<CustomDomainValidRecords | undefined> {
|
||||
return this.workspaceService.checkCustomDomainValidRecords(workspace);
|
||||
}
|
||||
|
||||
@Query(() => PublicWorkspaceDataOutput)
|
||||
|
||||
Reference in New Issue
Block a user