mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 20:02:38 +10:00
feat: cloud and ee (#805)
* stripe init git submodules for enterprise modules * * Cloud billing UI - WIP * Proxy websockets in dev mode * Separate workspace login and creation for cloud * Other fixes * feat: billing (cloud) * * add domain service * prepare links from workspace hostname * WIP * Add exchange token generation * Validate JWT token type during verification * domain service * add SkipTransform decorator * * updates (server) * add new packages * new sso migration file * WIP * Fix hostname generation * WIP * WIP * Reduce input error font-size * set max password length * jwt package * license page - WIP * * License management UI * Move license key store to db * add reflector * SSO enforcement * * Add default plan * Add usePlan hook * * Fix auth container margin in mobile * Redirect login and home to select page in cloud * update .gitignore * Default to yearly * * Trial messaging * Handle ended trials * Don't set to readonly on collab disconnect (Cloud) * Refine trial (UI) * Fix bug caused by using jotai optics atom in AppHeader component * configurable database maximum pool * Close SSO form on save * wip * sync * Only show sign-in in cloud * exclude base api part from workspaceId check * close db connection beforeApplicationShutdown * Add health/live endpoint * clear cookie on hostname change * reset currentUser atom * Change text * return 401 if workspace does not match * feat: show user workspace list in cloud login page * sync * Add home path * Prefetch to speed up queries * * Add robots.txt * Disallow login and forgot password routes * wildcard user-agent * Fix space query cache * fix * fix * use space uuid for recent pages * prefetch billing plans * enhance license page * sync
This commit is contained in:
@ -47,23 +47,28 @@
|
||||
"@nestjs/platform-socket.io": "^11.0.10",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.10",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.41.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"fix-esm": "^1.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "^15.11.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.27.5",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.1.0",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.3",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
@ -73,6 +78,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -87,6 +93,7 @@
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@ -14,6 +14,21 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { HealthModule } from './integrations/health/health.module';
|
||||
import { ExportModule } from './integrations/export/export.module';
|
||||
import { ImportModule } from './integrations/import/import.module';
|
||||
import { SecurityModule } from './integrations/security/security.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
if (require('./ee/ee.module')?.EeModule) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
enterpriseModules.push(require('./ee/ee.module')?.EeModule);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.CLOUD === 'true') {
|
||||
console.warn('Failed to load enterprise modules. Exiting program.\n', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -34,6 +49,8 @@ import { ImportModule } from './integrations/import/import.module';
|
||||
imports: [EnvironmentModule],
|
||||
}),
|
||||
EventEmitterModule.forRoot(),
|
||||
SecurityModule,
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@ -32,13 +32,10 @@ export class AuthenticationExtension implements Extension {
|
||||
let jwtPayload: JwtCollabPayload;
|
||||
|
||||
try {
|
||||
jwtPayload = await this.tokenService.verifyJwt(token);
|
||||
jwtPayload = await this.tokenService.verifyJwt(token, JwtType.COLLAB);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid collab token');
|
||||
}
|
||||
if (jwtPayload.type !== JwtType.COLLAB) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const userId = jwtPayload.sub;
|
||||
const workspaceId = jwtPayload.workspaceId;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { CollabAppModule } from './collab-app.module';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
@ -25,7 +25,8 @@ async function bootstrap() {
|
||||
|
||||
app.enableCors();
|
||||
|
||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
|
||||
const reflector = app.get(Reflector);
|
||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const logger = new Logger('CollabServer');
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const SKIP_TRANSFORM_KEY = 'SKIP_TRANSFORM';
|
||||
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);
|
||||
@ -6,10 +6,15 @@ import {
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private environmentService: EnvironmentService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@ -26,10 +31,40 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
handleRequest(err: any, user: any, info: any, ctx: ExecutionContext) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
|
||||
this.setJoinedWorkspacesCookie(user, ctx);
|
||||
return user;
|
||||
}
|
||||
|
||||
setJoinedWorkspacesCookie(user: any, ctx: ExecutionContext) {
|
||||
if (this.environmentService.isCloud()) {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const res = ctx.switchToHttp().getResponse();
|
||||
|
||||
const workspaceId = user?.workspace?.id;
|
||||
let hosts = [];
|
||||
try {
|
||||
hosts = req.cookies.workspaces ? JSON.parse(req.cookies.hosts) : [];
|
||||
} catch (err) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
if (!hosts.includes(workspaceId)) {
|
||||
hosts.push(workspaceId);
|
||||
}
|
||||
|
||||
// todo: revisit
|
||||
res.setCookie('joinedWorkspaces', JSON.stringify(hosts), {
|
||||
httpOnly: false,
|
||||
domain: '.' + this.environmentService.getSubdomainHost(),
|
||||
path: '/',
|
||||
expires: addDays(new Date(), 365),
|
||||
secure: this.environmentService.isHttps(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import {
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SKIP_TRANSFORM_KEY } from '../decorators/skip-transform.decorator';
|
||||
export interface Response<T> {
|
||||
data: T;
|
||||
}
|
||||
@ -13,15 +15,18 @@ export interface Response<T> {
|
||||
export class TransformHttpResponseInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
{
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<Response<T> | any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const path = request.url;
|
||||
const skipTransform = this.reflector.get(
|
||||
SKIP_TRANSFORM_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
|
||||
// Skip interceptor for the /api/health path
|
||||
if (path === '/api/health') {
|
||||
if (skipTransform) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,8 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
(req as any).workspaceId = workspace.id;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
@ -24,6 +24,7 @@ import { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { addDays } from 'date-fns';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@ -35,14 +36,13 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(
|
||||
@Req() req,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() loginInput: LoginDto,
|
||||
) {
|
||||
const authToken = await this.authService.login(
|
||||
loginInput,
|
||||
req.raw.workspaceId,
|
||||
);
|
||||
validateSsoEnforcement(workspace);
|
||||
|
||||
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||
this.setAuthCookie(res, authToken);
|
||||
}
|
||||
|
||||
@ -53,10 +53,11 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() createAdminUserDto: CreateAdminUserDto,
|
||||
) {
|
||||
if (this.environmentService.isCloud()) throw new NotFoundException();
|
||||
const { workspace, authToken } =
|
||||
await this.authService.setup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.authService.setup(createAdminUserDto);
|
||||
this.setAuthCookie(res, authToken);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -76,7 +77,8 @@ export class AuthController {
|
||||
@Body() forgotPasswordDto: ForgotPasswordDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.authService.forgotPassword(forgotPasswordDto, workspace.id);
|
||||
validateSsoEnforcement(workspace);
|
||||
return this.authService.forgotPassword(forgotPasswordDto, workspace);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
8
apps/server/src/core/auth/auth.util.ts
Normal file
8
apps/server/src/core/auth/auth.util.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
export function validateSsoEnforcement(workspace: Workspace) {
|
||||
if (workspace.enforceSso) {
|
||||
throw new BadRequestException('This workspace has enforced SSO login.');
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@ -22,6 +22,7 @@ export class CreateUserDto {
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
COLLAB = 'collab',
|
||||
EXCHANGE = 'exchange',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@ -14,3 +15,9 @@ export type JwtCollabPayload = {
|
||||
workspaceId: string;
|
||||
type: 'collab';
|
||||
};
|
||||
|
||||
export type JwtExchangePayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
type: 'exchange';
|
||||
};
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class SetupGuard implements CanActivate {
|
||||
constructor(private workspaceRepo: WorkspaceRepo) {}
|
||||
constructor(
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
if (this.environmentService.isCloud()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceCount = await this.workspaceRepo.count();
|
||||
if (workspaceCount > 0) {
|
||||
throw new ForbiddenException('Workspace setup already completed.');
|
||||
|
||||
@ -22,13 +22,13 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto';
|
||||
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
|
||||
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
|
||||
import { PasswordResetDto } from '../dto/password-reset.dto';
|
||||
import { UserToken } from '@docmost/db/types/entity.types';
|
||||
import { UserToken, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { UserTokenType } from '../auth.constants';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -38,7 +38,7 @@ export class AuthService {
|
||||
private userRepo: UserRepo,
|
||||
private userTokenRepo: UserTokenRepo,
|
||||
private mailService: MailService,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@ -46,7 +46,9 @@ export class AuthService {
|
||||
const user = await this.userRepo.findByEmail(
|
||||
loginDto.email,
|
||||
workspaceId,
|
||||
true,
|
||||
{
|
||||
includePassword: true
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
@ -68,8 +70,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||
const user = await this.signupService.initialSetup(createAdminUserDto);
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
const { workspace, user } =
|
||||
await this.signupService.initialSetup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
return { workspace, authToken };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
@ -113,11 +118,11 @@ export class AuthService {
|
||||
|
||||
async forgotPassword(
|
||||
forgotPasswordDto: ForgotPasswordDto,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findByEmail(
|
||||
forgotPasswordDto.email,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
@ -125,7 +130,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const token = nanoIdGen(16);
|
||||
const resetLink = `${this.environmentService.getAppUrl()}/password-reset?token=${token}`;
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
|
||||
await this.userTokenRepo.insertUserToken({
|
||||
token: token,
|
||||
@ -199,7 +205,7 @@ export class AuthService {
|
||||
userTokenDto: VerifyUserTokenDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const userToken = await this.userTokenRepo.findById(
|
||||
const userToken: UserToken = await this.userTokenRepo.findById(
|
||||
userTokenDto.token,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
@ -32,7 +32,7 @@ export class SignupService {
|
||||
|
||||
if (userCheck) {
|
||||
throw new BadRequestException(
|
||||
'You already have an account on this workspace',
|
||||
'An account with this email already exists in this workspace',
|
||||
);
|
||||
}
|
||||
|
||||
@ -72,11 +72,14 @@ export class SignupService {
|
||||
createAdminUserDto: CreateAdminUserDto,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
return await executeTx(
|
||||
let user: User,
|
||||
workspace: Workspace = null;
|
||||
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
// create user
|
||||
const user = await this.userRepo.insertUser(
|
||||
user = await this.userRepo.insertUser(
|
||||
{
|
||||
name: createAdminUserDto.name,
|
||||
email: createAdminUserDto.email,
|
||||
@ -92,7 +95,7 @@ export class SignupService {
|
||||
name: createAdminUserDto.workspaceName,
|
||||
};
|
||||
|
||||
const workspace = await this.workspaceService.create(
|
||||
workspace = await this.workspaceService.create(
|
||||
user,
|
||||
workspaceData,
|
||||
trx,
|
||||
@ -103,5 +106,7 @@ export class SignupService {
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { JwtCollabPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import {
|
||||
JwtCollabPayload,
|
||||
JwtExchangePayload,
|
||||
JwtPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
@ -34,9 +39,29 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, { expiresIn });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string) {
|
||||
return this.jwtService.verifyAsync(token, {
|
||||
async generateExchangeToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtExchangePayload = {
|
||||
sub: userId,
|
||||
workspaceId: workspaceId,
|
||||
type: JwtType.EXCHANGE,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '10s' });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
if (payload.type !== tokenType) {
|
||||
throw new UnauthorizedException(
|
||||
'Invalid JWT token. Token type does not match.',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// CLOUD ENV
|
||||
if (this.environmentService.isCloud()) {
|
||||
if (req.raw.workspaceId && req.raw.workspaceId !== payload.workspaceId) {
|
||||
throw new BadRequestException('Workspace does not match');
|
||||
throw new UnauthorizedException('Workspace does not match');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,19 +12,35 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('me')
|
||||
async getUserIno(
|
||||
async getUserInfo(
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { user: authUser, workspace };
|
||||
const memberCount = await this.workspaceRepo.getActiveUserCount(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
|
||||
const workspaceInfo = {
|
||||
...rest,
|
||||
memberCount,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
|
||||
return { user: authUser, workspace: workspaceInfo };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WorkspaceController } from './workspace.controller';
|
||||
import { WorkspaceService } from '../services/workspace.service';
|
||||
|
||||
describe('WorkspaceController', () => {
|
||||
let controller: WorkspaceController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [WorkspaceController],
|
||||
providers: [WorkspaceService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<WorkspaceController>(WorkspaceController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -33,6 +33,7 @@ import {
|
||||
import { addDays } from 'date-fns';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace')
|
||||
@ -60,7 +61,8 @@ export class WorkspaceController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async updateWorkspace(
|
||||
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() dto: UpdateWorkspaceDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@ -71,7 +73,21 @@ export class WorkspaceController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
|
||||
const updatedWorkspace = await this.workspaceService.update(
|
||||
workspace.id,
|
||||
dto,
|
||||
);
|
||||
|
||||
if (
|
||||
dto.hostname &&
|
||||
dto.hostname === updatedWorkspace.hostname &&
|
||||
workspace.hostname !== updatedWorkspace.hostname
|
||||
) {
|
||||
// log user out of old hostname
|
||||
res.clearCookie('authToken');
|
||||
}
|
||||
|
||||
return updatedWorkspace;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -102,8 +118,6 @@ export class WorkspaceController {
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.workspaceService.deactivateUser();
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -172,7 +186,7 @@ export class WorkspaceController {
|
||||
|
||||
return this.workspaceInvitationService.createInvitation(
|
||||
inviteUserDto,
|
||||
workspace.id,
|
||||
workspace,
|
||||
user,
|
||||
);
|
||||
}
|
||||
@ -193,7 +207,7 @@ export class WorkspaceController {
|
||||
|
||||
return this.workspaceInvitationService.resendInvitation(
|
||||
revokeInviteDto.invitationId,
|
||||
workspace.id,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
|
||||
@ -238,6 +252,13 @@ export class WorkspaceController {
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/check-hostname')
|
||||
async checkHostname(@Body() checkHostnameDto: CheckHostnameDto) {
|
||||
return this.workspaceService.checkHostname(checkHostnameDto.hostname);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/link')
|
||||
async getInviteLink(
|
||||
@ -258,7 +279,7 @@ export class WorkspaceController {
|
||||
const inviteLink =
|
||||
await this.workspaceInvitationService.getInvitationLinkById(
|
||||
inviteDto.invitationId,
|
||||
workspace.id,
|
||||
workspace,
|
||||
);
|
||||
|
||||
return { inviteLink };
|
||||
|
||||
8
apps/server/src/core/workspace/dto/check-hostname.dto.ts
Normal file
8
apps/server/src/core/workspace/dto/check-hostname.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { MinLength } from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CheckHostnameDto {
|
||||
@MinLength(1)
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
hostname: string;
|
||||
}
|
||||
@ -1,8 +1,14 @@
|
||||
import {IsAlphanumeric, IsOptional, IsString, MaxLength, MinLength} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import {
|
||||
IsAlphanumeric,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@MinLength(4)
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
@ -12,6 +18,7 @@ export class CreateWorkspaceDto {
|
||||
@MinLength(4)
|
||||
@MaxLength(30)
|
||||
@IsAlphanumeric()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim().toLowerCase())
|
||||
hostname?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from './create-workspace.dto';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
emailDomains: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceSso: boolean;
|
||||
}
|
||||
|
||||
@ -12,17 +12,18 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import {
|
||||
Group,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { TokenService } from '../../auth/services/token.service';
|
||||
import { nanoIdGen } from '../../../common/helpers';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { DomainService } from 'src/integrations/environment/domain.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@ -31,7 +32,7 @@ export class WorkspaceInvitationService {
|
||||
private userRepo: UserRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private mailService: MailService,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
private tokenService: TokenService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
@ -88,7 +89,7 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async createInvitation(
|
||||
inviteUserDto: InviteUserDto,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
authUser: User,
|
||||
): Promise<void> {
|
||||
const { emails, role, groupIds } = inviteUserDto;
|
||||
@ -102,7 +103,7 @@ export class WorkspaceInvitationService {
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.where('users.email', 'in', emails)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.where('users.workspaceId', '=', workspace.id)
|
||||
.execute();
|
||||
|
||||
let existingUserEmails = [];
|
||||
@ -121,7 +122,7 @@ export class WorkspaceInvitationService {
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.where('groups.workspaceId', '=', workspace.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@ -129,7 +130,7 @@ export class WorkspaceInvitationService {
|
||||
email: email,
|
||||
role: role,
|
||||
token: nanoIdGen(16),
|
||||
workspaceId: workspaceId,
|
||||
workspaceId: workspace.id,
|
||||
invitedById: authUser.id,
|
||||
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
||||
}));
|
||||
@ -156,6 +157,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
authUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -269,13 +271,13 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async resendInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
@ -284,7 +286,7 @@ export class WorkspaceInvitationService {
|
||||
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
await this.sendInvitationMail(
|
||||
@ -292,6 +294,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
invitedByUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
}
|
||||
|
||||
@ -308,17 +311,23 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async getInvitationLinkById(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<string> {
|
||||
const token = await this.getInvitationTokenById(invitationId, workspaceId);
|
||||
return this.buildInviteLink(invitationId, token.token);
|
||||
const token = await this.getInvitationTokenById(invitationId, workspace.id);
|
||||
return this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken: token.token,
|
||||
hostname: workspace.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
async buildInviteLink(
|
||||
invitationId: string,
|
||||
inviteToken: string,
|
||||
): Promise<string> {
|
||||
return `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
|
||||
async buildInviteLink(opts: {
|
||||
invitationId: string;
|
||||
inviteToken: string;
|
||||
hostname?: string;
|
||||
}): Promise<string> {
|
||||
const { invitationId, inviteToken, hostname } = opts;
|
||||
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`;
|
||||
}
|
||||
|
||||
async sendInvitationMail(
|
||||
@ -326,8 +335,13 @@ export class WorkspaceInvitationService {
|
||||
inviteeEmail: string,
|
||||
inviteToken: string,
|
||||
invitedByName: string,
|
||||
hostname?: string,
|
||||
): Promise<void> {
|
||||
const inviteLink = await this.buildInviteLink(invitationId, inviteToken);
|
||||
const inviteLink = await this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken,
|
||||
hostname,
|
||||
});
|
||||
|
||||
const emailTemplate = InvitationEmail({
|
||||
inviteLink,
|
||||
|
||||
@ -21,6 +21,11 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { addDays } from 'date-fns';
|
||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@ -31,6 +36,8 @@ export class WorkspaceService {
|
||||
private groupRepo: GroupRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private userRepo: UserRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@ -50,14 +57,33 @@ export class WorkspaceService {
|
||||
async getWorkspacePublicData(workspaceId: string) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id'])
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('authProviders')
|
||||
.select([
|
||||
'authProviders.id',
|
||||
'authProviders.name',
|
||||
'authProviders.type',
|
||||
])
|
||||
.where('authProviders.isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId),
|
||||
).as('authProviders'),
|
||||
)
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
return workspace;
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
@ -68,12 +94,30 @@ export class WorkspaceService {
|
||||
return await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
let hostname = undefined;
|
||||
let trialEndAt = undefined;
|
||||
let status = undefined;
|
||||
let plan = undefined;
|
||||
|
||||
if (this.environmentService.isCloud()) {
|
||||
// generate unique hostname
|
||||
hostname = await this.generateHostname(
|
||||
createWorkspaceDto.hostname ?? createWorkspaceDto.name,
|
||||
);
|
||||
trialEndAt = addDays(new Date(), 14);
|
||||
status = WorkspaceStatus.Active;
|
||||
plan = 'standard';
|
||||
}
|
||||
|
||||
// create workspace
|
||||
const workspace = await this.workspaceRepo.insertWorkspace(
|
||||
{
|
||||
name: createWorkspaceDto.name,
|
||||
hostname: createWorkspaceDto.hostname,
|
||||
description: createWorkspaceDto.description,
|
||||
hostname,
|
||||
status,
|
||||
trialEndAt,
|
||||
plan,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
@ -91,6 +135,7 @@ export class WorkspaceService {
|
||||
workspaceId: workspace.id,
|
||||
role: UserRole.OWNER,
|
||||
})
|
||||
.where('users.id', '=', user.id)
|
||||
.execute();
|
||||
|
||||
// add user to default group created above
|
||||
@ -182,21 +227,54 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
async update(workspaceId: string, updateWorkspaceDto: UpdateWorkspaceDto) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
if (updateWorkspaceDto.enforceSso) {
|
||||
const sso = await this.db
|
||||
.selectFrom('authProviders')
|
||||
.selectAll()
|
||||
.where('isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (sso && sso?.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one active SSO provider to enforce SSO.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateWorkspaceDto.name) {
|
||||
workspace.name = updateWorkspaceDto.name;
|
||||
if (updateWorkspaceDto.emailDomains) {
|
||||
const regex =
|
||||
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
|
||||
|
||||
const emailDomains = updateWorkspaceDto.emailDomains || [];
|
||||
|
||||
updateWorkspaceDto.emailDomains = emailDomains
|
||||
.map((domain) => regex.exec(domain)?.[0])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (updateWorkspaceDto.logo) {
|
||||
workspace.logo = updateWorkspaceDto.logo;
|
||||
if (updateWorkspaceDto.hostname) {
|
||||
const hostname = updateWorkspaceDto.hostname;
|
||||
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
|
||||
throw new BadRequestException('Hostname already exists.');
|
||||
}
|
||||
if (await this.workspaceRepo.hostnameExists(hostname)) {
|
||||
throw new BadRequestException('Hostname already exists.');
|
||||
}
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
return workspace;
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withMemberCount: true,
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
@ -256,7 +334,53 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateUser(): Promise<any> {
|
||||
return 'todo';
|
||||
async generateHostname(
|
||||
name: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string> {
|
||||
const generateRandomSuffix = (length: number) =>
|
||||
Math.random()
|
||||
.toFixed(length)
|
||||
.substring(2, 2 + length);
|
||||
|
||||
let subdomain = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.substring(0, 20);
|
||||
// Ensure we leave room for a random suffix.
|
||||
const maxSuffixLength = 3;
|
||||
|
||||
if (subdomain.length < 4) {
|
||||
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||
subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
let uniqueHostname = subdomain;
|
||||
|
||||
while (true) {
|
||||
const exists = await this.workspaceRepo.hostnameExists(
|
||||
uniqueHostname,
|
||||
trx,
|
||||
);
|
||||
if (!exists) {
|
||||
break;
|
||||
}
|
||||
// Append a random suffix and retry.
|
||||
const randomSuffix = generateRandomSuffix(maxSuffixLength);
|
||||
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
|
||||
}
|
||||
|
||||
return uniqueHostname;
|
||||
}
|
||||
|
||||
async checkHostname(hostname: string) {
|
||||
const exists = await this.workspaceRepo.hostnameExists(hostname);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Hostname not found');
|
||||
}
|
||||
return { hostname: this.domainService.getUrl(hostname) };
|
||||
}
|
||||
}
|
||||
|
||||
117
apps/server/src/core/workspace/workspace.constants.ts
Normal file
117
apps/server/src/core/workspace/workspace.constants.ts
Normal file
@ -0,0 +1,117 @@
|
||||
export enum WorkspaceStatus {
|
||||
Active = 'active',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export const DISALLOWED_HOSTNAMES = [
|
||||
'app',
|
||||
'help',
|
||||
'account',
|
||||
'billing',
|
||||
'docs',
|
||||
'blog',
|
||||
'status',
|
||||
'payment',
|
||||
'updates',
|
||||
'license',
|
||||
'customer',
|
||||
'customers',
|
||||
'dashboard',
|
||||
'docmost',
|
||||
'support',
|
||||
'admin',
|
||||
'about',
|
||||
'team',
|
||||
'analytics',
|
||||
'data',
|
||||
'dev',
|
||||
'development',
|
||||
'staging',
|
||||
'wiki',
|
||||
'www',
|
||||
'login',
|
||||
'signup',
|
||||
'signin',
|
||||
'register',
|
||||
'abuse',
|
||||
'general',
|
||||
'update',
|
||||
'updates',
|
||||
'upgrade',
|
||||
'upgrades',
|
||||
'api',
|
||||
'server',
|
||||
'servers',
|
||||
'service',
|
||||
'user',
|
||||
'upload',
|
||||
'uploads',
|
||||
'version',
|
||||
'translate',
|
||||
'translation',
|
||||
'translations',
|
||||
'translator',
|
||||
'setup',
|
||||
'share',
|
||||
'setting',
|
||||
'settings',
|
||||
'security',
|
||||
'shop',
|
||||
'store',
|
||||
'prod',
|
||||
'plan',
|
||||
'plans',
|
||||
'plugin',
|
||||
'plugins',
|
||||
'mail',
|
||||
'email',
|
||||
'checkout',
|
||||
'checkouts',
|
||||
'client',
|
||||
'career',
|
||||
'job',
|
||||
'jobs',
|
||||
'careers',
|
||||
'account',
|
||||
'accounts',
|
||||
'host',
|
||||
'connect',
|
||||
'contact',
|
||||
'core',
|
||||
'embed',
|
||||
'founder',
|
||||
'guide',
|
||||
'guides',
|
||||
'smtp',
|
||||
'imap',
|
||||
'lab',
|
||||
'collab',
|
||||
'collaboration',
|
||||
'ws',
|
||||
'websocket',
|
||||
'community',
|
||||
'forum',
|
||||
'forums',
|
||||
'wikis',
|
||||
'files',
|
||||
'app',
|
||||
'assets',
|
||||
'news',
|
||||
'jobs',
|
||||
'careers',
|
||||
'can',
|
||||
'demo',
|
||||
'logs',
|
||||
'dash',
|
||||
'auth',
|
||||
'organization',
|
||||
'org',
|
||||
'db',
|
||||
'database',
|
||||
'notes',
|
||||
'download',
|
||||
'workspace',
|
||||
'space',
|
||||
'group',
|
||||
'members',
|
||||
];
|
||||
@ -3,7 +3,7 @@ import {
|
||||
Logger,
|
||||
Module,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleDestroy,
|
||||
BeforeApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||
@ -38,6 +38,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: environmentService.getDatabaseURL(),
|
||||
max: environmentService.getDatabaseMaxPool(),
|
||||
}).on('error', (err) => {
|
||||
console.error('Database error:', err.message);
|
||||
}),
|
||||
@ -86,7 +87,9 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
BacklinkRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
||||
export class DatabaseModule
|
||||
implements OnApplicationBootstrap, BeforeApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
constructor(
|
||||
@ -103,7 +106,7 @@ export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
async beforeApplicationShutdown(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.destroy();
|
||||
}
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('billing')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('stripe_subscription_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('stripe_customer_id', 'varchar', (col) => col)
|
||||
.addColumn('status', 'varchar', (col) => col.notNull())
|
||||
.addColumn('quantity', 'int8', (col) => col)
|
||||
.addColumn('amount', 'int8', (col) => col)
|
||||
.addColumn('interval', 'varchar', (col) => col)
|
||||
.addColumn('currency', 'varchar', (col) => col)
|
||||
.addColumn('metadata', 'jsonb', (col) => col)
|
||||
|
||||
.addColumn('stripe_price_id', 'varchar', (col) => col)
|
||||
.addColumn('stripe_item_id', 'varchar', (col) => col)
|
||||
.addColumn('stripe_product_id', 'varchar', (col) => col)
|
||||
|
||||
.addColumn('period_start_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('period_end_at', 'timestamptz', (col) => col)
|
||||
|
||||
.addColumn('cancel_at_period_end', 'boolean', (col) => col)
|
||||
.addColumn('cancel_at', 'timestamptz', (col) => col)
|
||||
.addColumn('canceled_at', 'timestamptz', (col) => col)
|
||||
.addColumn('ended_at', 'timestamptz', (col) => col)
|
||||
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('billing')
|
||||
.addUniqueConstraint('billing_stripe_subscription_id_unique', [
|
||||
'stripe_subscription_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
// add new workspace columns
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('stripe_customer_id', 'varchar', (col) => col)
|
||||
.addColumn('status', 'varchar', (col) => col)
|
||||
.addColumn('plan', 'varchar', (col) => col)
|
||||
.addColumn('billing_email', 'varchar', (col) => col)
|
||||
.addColumn('trial_end_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addUniqueConstraint('workspaces_stripe_customer_id_unique', [
|
||||
'stripe_customer_id',
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('billing').execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.dropColumn('stripe_customer_id')
|
||||
.execute();
|
||||
|
||||
await db.schema.alterTable('workspaces').dropColumn('status').execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.dropColumn('billing_email')
|
||||
.execute();
|
||||
|
||||
await db.schema.alterTable('workspaces').dropColumn('trial_end_at').execute();
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createType('auth_provider_type')
|
||||
.asEnum(['saml', 'oidc', 'google'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('auth_providers')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', sql`auth_provider_type`, (col) => col.notNull())
|
||||
|
||||
// SAML
|
||||
.addColumn('saml_url', 'varchar', (col) => col)
|
||||
.addColumn('saml_certificate', 'varchar', (col) => col)
|
||||
|
||||
// OIDC
|
||||
.addColumn('oidc_issuer', 'varchar', (col) => col)
|
||||
.addColumn('oidc_client_id', 'varchar', (col) => col)
|
||||
.addColumn('oidc_client_secret', 'varchar', (col) => col)
|
||||
|
||||
.addColumn('allow_signup', 'boolean', (col) =>
|
||||
col.defaultTo(false).notNull(),
|
||||
)
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.defaultTo(false).notNull())
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('auth_accounts')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('provider_user_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('auth_provider_id', 'uuid', (col) =>
|
||||
col.references('auth_providers.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.addUniqueConstraint('auth_accounts_user_id_auth_provider_id_unique', [
|
||||
'user_id',
|
||||
'auth_provider_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('enforce_sso', 'boolean', (col) =>
|
||||
col.defaultTo(false).notNull(),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('auth_accounts').execute();
|
||||
await db.schema.dropTable('auth_providers').execute();
|
||||
await db.schema.alterTable('workspaces').dropColumn('enforce_sso').execute();
|
||||
await db.schema.dropType('auth_provider_type').execute();
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('license_key', 'varchar', (col) => col)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('workspaces').dropColumn('license_key').execute();
|
||||
}
|
||||
@ -56,12 +56,16 @@ export class UserRepo {
|
||||
async findByEmail(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
includePassword?: boolean,
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
return this.db
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('users')
|
||||
.select(this.baseFields)
|
||||
.$if(includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@ -112,7 +116,7 @@ export class UserRepo {
|
||||
return db
|
||||
.insertInto('users')
|
||||
.values({ ...insertableUser, ...user })
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@ -172,31 +176,4 @@ export class UserRepo {
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/*
|
||||
async getSpaceIds(
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<Space>> {
|
||||
const spaces = await this.spaceRepo.getSpacesInWorkspace(
|
||||
workspaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return spaces;
|
||||
}
|
||||
|
||||
async getUserSpaces(
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<Space>> {
|
||||
const spaces = await this.spaceRepo.getSpacesInWorkspace(
|
||||
workspaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return spaces;
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@ -7,25 +7,63 @@ import {
|
||||
UpdatableWorkspace,
|
||||
Workspace,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB, Workspaces } from '@docmost/db/types/db';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceRepo {
|
||||
public baseFields: Array<keyof Workspaces> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'logo',
|
||||
'hostname',
|
||||
'customDomain',
|
||||
'settings',
|
||||
'defaultRole',
|
||||
'emailDomains',
|
||||
'defaultSpaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'stripeCustomerId',
|
||||
'status',
|
||||
'billingEmail',
|
||||
'trialEndAt',
|
||||
'enforceSso',
|
||||
'plan',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
workspaceId: string,
|
||||
opts?: {
|
||||
withLock?: boolean;
|
||||
withMemberCount?: boolean;
|
||||
withLicenseKey?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Workspace> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
return db
|
||||
let query = db
|
||||
.selectFrom('workspaces')
|
||||
.selectAll()
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', workspaceId);
|
||||
|
||||
if (opts?.withMemberCount) {
|
||||
query = query.select(this.withMemberCount);
|
||||
}
|
||||
|
||||
if (opts?.withLicenseKey) {
|
||||
query = query.select('licenseKey');
|
||||
}
|
||||
|
||||
if (opts?.withLock && opts?.trx) {
|
||||
query = query.forUpdate();
|
||||
}
|
||||
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findFirst(): Promise<Workspace> {
|
||||
@ -45,17 +83,34 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async hostnameExists(
|
||||
hostname: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<boolean> {
|
||||
if (hostname?.length < 1) return false;
|
||||
|
||||
const db = dbOrTx(this.db, trx);
|
||||
let { count } = await db
|
||||
.selectFrom('workspaces')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where(sql`LOWER(hostname)`, '=', sql`LOWER(${hostname})`)
|
||||
.executeTakeFirst();
|
||||
count = count as number;
|
||||
return count != 0;
|
||||
}
|
||||
|
||||
async updateWorkspace(
|
||||
updatableWorkspace: UpdatableWorkspace,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
): Promise<Workspace> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({ ...updatableWorkspace, updatedAt: new Date() })
|
||||
.where('id', '=', workspaceId)
|
||||
.execute();
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insertWorkspace(
|
||||
@ -66,7 +121,7 @@ export class WorkspaceRepo {
|
||||
return db
|
||||
.insertInto('workspaces')
|
||||
.values(insertableWorkspace)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@ -77,4 +132,28 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
return count as number;
|
||||
}
|
||||
|
||||
withMemberCount(eb: ExpressionBuilder<DB, 'workspaces'>) {
|
||||
return eb
|
||||
.selectFrom('users')
|
||||
.select((eb) => eb.fn.countAll().as('count'))
|
||||
.where('users.deactivatedAt', 'is', null)
|
||||
.where('users.deletedAt', 'is', null)
|
||||
.whereRef('users.workspaceId', '=', 'workspaces.id')
|
||||
.as('memberCount');
|
||||
}
|
||||
|
||||
async getActiveUserCount(workspaceId: string): Promise<number> {
|
||||
const users = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'deactivatedAt', 'deletedAt'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
const activeUsers = users.filter(
|
||||
(user) => user.deletedAt === null && user.deactivatedAt === null,
|
||||
);
|
||||
|
||||
return activeUsers.length;
|
||||
}
|
||||
}
|
||||
|
||||
66
apps/server/src/database/types/db.d.ts
vendored
66
apps/server/src/database/types/db.d.ts
vendored
@ -5,6 +5,8 @@
|
||||
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type AuthProviderType = "google" | "oidc" | "saml";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
@ -42,6 +44,35 @@ export interface Attachments {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface AuthAccounts {
|
||||
authProviderId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
providerUserId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface AuthProviders {
|
||||
allowSignup: Generated<boolean>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isEnabled: Generated<boolean>;
|
||||
name: string;
|
||||
oidcClientId: string | null;
|
||||
oidcClientSecret: string | null;
|
||||
oidcIssuer: string | null;
|
||||
samlCertificate: string | null;
|
||||
samlUrl: string | null;
|
||||
type: AuthProviderType;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Backlinks {
|
||||
createdAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
@ -51,6 +82,31 @@ export interface Backlinks {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Billing {
|
||||
amount: Int8 | null;
|
||||
cancelAt: Timestamp | null;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
canceledAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
currency: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
endedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
interval: string | null;
|
||||
metadata: Json | null;
|
||||
periodEndAt: Timestamp | null;
|
||||
periodStartAt: Timestamp;
|
||||
quantity: Int8 | null;
|
||||
status: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeItemId: string | null;
|
||||
stripePriceId: string | null;
|
||||
stripeProductId: string | null;
|
||||
stripeSubscriptionId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Comments {
|
||||
content: Json | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
@ -198,6 +254,7 @@ export interface WorkspaceInvitations {
|
||||
}
|
||||
|
||||
export interface Workspaces {
|
||||
billingEmail: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
customDomain: string | null;
|
||||
defaultRole: Generated<string>;
|
||||
@ -205,17 +262,26 @@ export interface Workspaces {
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
emailDomains: Generated<string[] | null>;
|
||||
enforceSso: Generated<boolean>;
|
||||
hostname: string | null;
|
||||
id: Generated<string>;
|
||||
licenseKey: string | null;
|
||||
logo: string | null;
|
||||
name: string | null;
|
||||
plan: string | null;
|
||||
settings: Json | null;
|
||||
status: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
trialEndAt: Timestamp | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
attachments: Attachments;
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
|
||||
@ -13,6 +13,9 @@ import {
|
||||
WorkspaceInvitations,
|
||||
UserTokens,
|
||||
Backlinks,
|
||||
Billing as BillingSubscription,
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@ -83,3 +86,18 @@ export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
|
||||
export type Backlink = Selectable<Backlinks>;
|
||||
export type InsertableBacklink = Insertable<Backlink>;
|
||||
export type UpdatableBacklink = Updateable<Omit<Backlink, 'id'>>;
|
||||
|
||||
// Billing
|
||||
export type Billing = Selectable<BillingSubscription>;
|
||||
export type InsertableBilling = Insertable<BillingSubscription>;
|
||||
export type UpdatableBilling = Updateable<Omit<BillingSubscription, 'id'>>;
|
||||
|
||||
// Auth Provider
|
||||
export type AuthProvider = Selectable<AuthProviders>;
|
||||
export type InsertableAuthProvider = Insertable<AuthProviders>;
|
||||
export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
|
||||
|
||||
// Auth Account
|
||||
export type AuthAccount = Selectable<AuthAccounts>;
|
||||
export type InsertableAuthAccount = Insertable<AuthAccounts>;
|
||||
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
||||
|
||||
1
apps/server/src/ee
Submodule
1
apps/server/src/ee
Submodule
Submodule apps/server/src/ee added at 681cd3de27
21
apps/server/src/integrations/environment/domain.service.ts
Normal file
21
apps/server/src/integrations/environment/domain.service.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EnvironmentService } from './environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
constructor(private environmentService: EnvironmentService) {}
|
||||
|
||||
getUrl(hostname?: string): string {
|
||||
if (!this.environmentService.isCloud()) {
|
||||
return this.environmentService.getAppUrl();
|
||||
}
|
||||
|
||||
const domain = this.environmentService.getSubdomainHost();
|
||||
if (!hostname || !domain) {
|
||||
return this.environmentService.getAppUrl();
|
||||
}
|
||||
|
||||
const protocol = this.environmentService.isHttps() ? 'https' : 'http';
|
||||
return `${protocol}://${hostname}.${domain}`;
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { EnvironmentService } from './environment.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { validate } from './environment.validation';
|
||||
import { envPath } from '../../common/helpers';
|
||||
import { DomainService } from './domain.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@ -14,7 +15,7 @@ import { envPath } from '../../common/helpers';
|
||||
validate,
|
||||
}),
|
||||
],
|
||||
providers: [EnvironmentService],
|
||||
exports: [EnvironmentService],
|
||||
providers: [EnvironmentService, DomainService],
|
||||
exports: [EnvironmentService, DomainService],
|
||||
})
|
||||
export class EnvironmentModule {}
|
||||
|
||||
@ -10,10 +10,12 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getAppUrl(): string {
|
||||
return (
|
||||
const rawUrl =
|
||||
this.configService.get<string>('APP_URL') ||
|
||||
'http://localhost:' + this.getPort()
|
||||
);
|
||||
`http://localhost:${this.getPort()}`;
|
||||
|
||||
const { origin } = new URL(rawUrl);
|
||||
return origin;
|
||||
}
|
||||
|
||||
isHttps(): boolean {
|
||||
@ -26,6 +28,10 @@ export class EnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
getSubdomainHost(): string {
|
||||
return this.configService.get<string>('SUBDOMAIN_HOST');
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return parseInt(this.configService.get<string>('PORT', '3000'));
|
||||
}
|
||||
@ -38,6 +44,13 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('DATABASE_URL');
|
||||
}
|
||||
|
||||
getDatabaseMaxPool(): number {
|
||||
return parseInt(
|
||||
this.configService.get<string>('DATABASE_MAX_POOL', '10'),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
getRedisUrl(): string {
|
||||
return this.configService.get<string>(
|
||||
'REDIS_URL',
|
||||
@ -146,6 +159,22 @@ export class EnvironmentService {
|
||||
return !this.isCloud();
|
||||
}
|
||||
|
||||
getStripePublishableKey(): string {
|
||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||
}
|
||||
|
||||
getStripeSecretKey(): string {
|
||||
return this.configService.get<string>('STRIPE_SECRET_KEY');
|
||||
}
|
||||
|
||||
getStripeWebhookSecret(): string {
|
||||
return this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
|
||||
}
|
||||
|
||||
getEnterpriseKey(): string {
|
||||
return this.configService.get<string>('ENTERPRISE_KEY');
|
||||
}
|
||||
|
||||
getCollabUrl(): string {
|
||||
return this.configService.get<string>('COLLAB_URL');
|
||||
}
|
||||
|
||||
@ -54,6 +54,20 @@ export class EnvironmentVariables {
|
||||
@ValidateIf((obj) => obj.COLLAB_URL != '' && obj.COLLAB_URL != null)
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
COLLAB_URL: string;
|
||||
|
||||
@IsOptional()
|
||||
CLOUD: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl(
|
||||
{ protocols: [], require_tld: true },
|
||||
{
|
||||
message:
|
||||
'SUBDOMAIN_HOST must be a valid FQDN domain without the http protocol. e.g example.com',
|
||||
},
|
||||
)
|
||||
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
|
||||
SUBDOMAIN_HOST: string;
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, any>) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||
import { PostgresHealthIndicator } from './postgres.health';
|
||||
import { RedisHealthIndicator } from './redis.health';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@ -11,6 +12,7 @@ export class HealthController {
|
||||
private redis: RedisHealthIndicator,
|
||||
) {}
|
||||
|
||||
@SkipTransform()
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
async check() {
|
||||
@ -19,4 +21,9 @@ export class HealthController {
|
||||
() => this.redis.pingCheck('redis'),
|
||||
]);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
async checkLive() {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
|
||||
@Controller('robots.txt')
|
||||
export class RobotsTxtController {
|
||||
@SkipTransform()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Get()
|
||||
async robotsTxt() {
|
||||
return 'User-Agent: *\nDisallow: /login\nDisallow: /forgot-password';
|
||||
}
|
||||
}
|
||||
7
apps/server/src/integrations/security/security.module.ts
Normal file
7
apps/server/src/integrations/security/security.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RobotsTxtController } from './robots.txt.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [RobotsTxtController],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@ -38,6 +38,9 @@ export class StaticModule implements OnModuleInit {
|
||||
FILE_UPLOAD_SIZE_LIMIT:
|
||||
this.environmentService.getFileUploadSizeLimit(),
|
||||
DRAWIO_URL: this.environmentService.getDrawioUrl(),
|
||||
SUBDOMAIN_HOST: this.environmentService.isCloud()
|
||||
? this.environmentService.getSubdomainHost()
|
||||
: undefined,
|
||||
COLLAB_URL: this.environmentService.getCollabUrl(),
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
@ -6,9 +6,9 @@ import {
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
|
||||
async function bootstrap() {
|
||||
@ -17,34 +17,52 @@ async function bootstrap() {
|
||||
new FastifyAdapter({
|
||||
ignoreTrailingSlash: true,
|
||||
ignoreDuplicateSlashes: true,
|
||||
maxParamLength: 500,
|
||||
maxParamLength: 1000,
|
||||
trustProxy: true,
|
||||
}),
|
||||
{
|
||||
rawBody: true,
|
||||
logger: new InternalLogFilter(),
|
||||
},
|
||||
);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.setGlobalPrefix('api', { exclude: ['robots.txt'] });
|
||||
|
||||
const reflector = app.get(Reflector);
|
||||
const redisIoAdapter = new WsRedisIoAdapter(app);
|
||||
await redisIoAdapter.connectToRedis();
|
||||
|
||||
app.useWebSocketAdapter(redisIoAdapter);
|
||||
|
||||
await app.register(fastifyMultipart as any);
|
||||
await app.register(fastifyCookie as any);
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.decorateReply('setHeader', function (name: string, value: unknown) {
|
||||
this.header(name, value);
|
||||
})
|
||||
.decorateReply('end', function () {
|
||||
this.send('');
|
||||
})
|
||||
.addHook('preHandler', function (req, reply, done) {
|
||||
// don't require workspaceId for the following paths
|
||||
const excludedPaths = [
|
||||
'/api/auth/setup',
|
||||
'/api/health',
|
||||
'/api/billing/stripe/webhook',
|
||||
'/api/workspace/check-hostname',
|
||||
'/api/sso/google',
|
||||
'/api/workspace/create',
|
||||
'/api/workspace/joined',
|
||||
];
|
||||
|
||||
if (
|
||||
req.originalUrl.startsWith('/api') &&
|
||||
!req.originalUrl.startsWith('/api/auth/setup') &&
|
||||
!req.originalUrl.startsWith('/api/health')
|
||||
!excludedPaths.some((path) => req.originalUrl.startsWith(path))
|
||||
) {
|
||||
if (!req.raw?.['workspaceId']) {
|
||||
if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
done();
|
||||
@ -62,8 +80,7 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
app.enableCors();
|
||||
|
||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
|
||||
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const logger = new Logger('NestApplication');
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { TokenService } from '../core/auth/services/token.service';
|
||||
import { JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import * as cookie from 'cookie';
|
||||
@ -27,12 +27,10 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||
try {
|
||||
const cookies = cookie.parse(client.handshake.headers.cookie);
|
||||
const token = await this.tokenService.verifyJwt(cookies['authToken']);
|
||||
|
||||
if (token.type !== JwtType.ACCESS) {
|
||||
client.emit('Unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
const token: JwtPayload = await this.tokenService.verifyJwt(
|
||||
cookies['authToken'],
|
||||
JwtType.ACCESS,
|
||||
);
|
||||
|
||||
const userId = token.sub;
|
||||
const workspaceId = token.workspaceId;
|
||||
|
||||
@ -21,7 +21,8 @@
|
||||
"jsx": "react",
|
||||
"paths": {
|
||||
"@docmost/db/*": ["./src/database/*"],
|
||||
"@docmost/transactional/*": ["./src/integrations/transactional/*"]
|
||||
"@docmost/transactional/*": ["./src/integrations/transactional/*"],
|
||||
"@docmost/ee/*": ["./src/ee/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user