feat(cloud): add find-workspace and email verification endpoints (#2020)

* feat: add find-workspace and email verification endpoints
* sync
This commit is contained in:
Philip Okugbe
2026-03-14 13:36:30 +00:00
committed by GitHub
parent d0ed6865cb
commit 97c459be67
25 changed files with 479 additions and 13 deletions
+1
View File
@@ -107,6 +107,7 @@
"sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
@@ -0,0 +1,142 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -0,0 +1,42 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,3 +1,4 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
}
+32
View File
@@ -1,5 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) {
@@ -7,11 +7,13 @@ import {
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(1)
@MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -7,12 +7,14 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto {
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -17,6 +17,7 @@ import {
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -36,6 +37,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable()
export class AuthService {
@@ -46,6 +48,7 @@ export class AuthService {
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -69,6 +72,14 @@ export class AuthService {
throw new UnauthorizedException(errorMessage);
}
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -247,6 +258,14 @@ export class AuthService {
template: emailTemplate,
});
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false;
@@ -12,6 +12,7 @@ import {
MinLength,
} from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto {
@IsArray()
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
@NoUrls()
name: string;
@MinLength(8)
@@ -244,7 +244,7 @@ export class WorkspaceService {
await this.billingQueue.add(
QueueJob.WELCOME_EMAIL,
{ userId: user.id },
{ delay: 60 * 1000 }, // 1m
{ delay: 30 * 60 * 1000 }, // 30m
);
} catch (err) {
this.logger.error(err);
@@ -10,7 +10,7 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validator/is-iso6391';
import { IsISO6391 } from '../../common/validators/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
+1
View File
@@ -67,6 +67,7 @@ async function bootstrap() {
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
'/api/workspace/find-by-email',
];
if (