mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 14:14:06 +08:00
Merge branch 'main' into base
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -52,7 +53,13 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { RemoveIconDto } from './dto/attachment.dto';
|
||||
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
@@ -67,6 +74,8 @@ export class AttachmentController {
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -111,13 +120,7 @@ export class AttachmentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const spaceAbility = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
page.spaceId,
|
||||
);
|
||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
const spaceId = page.spaceId;
|
||||
|
||||
@@ -136,6 +139,18 @@ export class AttachmentController {
|
||||
attachmentId: attachmentId,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.ATTACHMENT_UPLOADED,
|
||||
resourceType: AuditResource.ATTACHMENT,
|
||||
resourceId: fileResponse?.id ?? attachmentId,
|
||||
spaceId,
|
||||
metadata: {
|
||||
fileName: fileResponse?.fileName,
|
||||
pageId,
|
||||
spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
@@ -172,15 +187,13 @@ export class AttachmentController {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const spaceAbility = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
attachment.spaceId,
|
||||
);
|
||||
|
||||
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
const page = await this.pageRepo.findById(attachment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
try {
|
||||
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||
} catch (err) {
|
||||
@@ -355,6 +368,34 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('files/info')
|
||||
async getAttachmentInfo(
|
||||
@Body() dto: AttachmentInfoDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const attachment = await this.attachmentRepo.findById(dto.attachmentId);
|
||||
if (
|
||||
!attachment ||
|
||||
!attachment.pageId ||
|
||||
attachment.workspaceId !== workspace.id ||
|
||||
attachment.type !== AttachmentType.File
|
||||
) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(attachment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('attachments/remove-icon')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
|
||||
import * as path from 'path';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
import { sanitizeFileName } from '../../common/helpers';
|
||||
import { getMimeType } from '../../common/helpers';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer?: Buffer;
|
||||
@@ -40,7 +41,7 @@ export async function prepareFile(
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExtension,
|
||||
mimeType: file.mimetype,
|
||||
mimeType: getMimeType(file.filename),
|
||||
multiPartFile: file,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { AttachmentType } from '../attachment.constants';
|
||||
|
||||
export class AttachmentInfoDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
attachmentId: string;
|
||||
}
|
||||
|
||||
export class RemoveIconDto {
|
||||
@IsEnum(AttachmentType)
|
||||
@IsIn([
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
@@ -24,6 +25,11 @@ import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -33,6 +39,7 @@ export class AuthController {
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -169,8 +176,17 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('logout')
|
||||
async logout(@Res({ passthrough: true }) res: FastifyReply) {
|
||||
async logout(
|
||||
@AuthUser() user: User,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
res.clearCookie('authToken');
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_LOGOUT,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
setAuthCookie(res: FastifyReply, token: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
@@ -13,6 +14,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import {
|
||||
comparePasswordHash,
|
||||
hashPassword,
|
||||
isUserDisabled,
|
||||
nanoIdGen,
|
||||
} from '../../../common/helpers';
|
||||
import { ChangePasswordDto } from '../dto/change-password.dto';
|
||||
@@ -29,6 +31,11 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -40,6 +47,7 @@ export class AuthService {
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async login(loginDto: LoginDto, workspaceId: string) {
|
||||
@@ -48,7 +56,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
const errorMessage = 'Email or password does not match';
|
||||
if (!user || user?.deletedAt) {
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
|
||||
@@ -64,6 +72,13 @@ export class AuthService {
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_LOGIN,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
metadata: { source: 'password' },
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
@@ -89,7 +104,7 @@ export class AuthService {
|
||||
includePassword: true,
|
||||
});
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
@@ -112,6 +127,12 @@ export class AuthService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: userId,
|
||||
});
|
||||
|
||||
const emailTemplate = ChangePasswordEmail({ username: user.name });
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
@@ -129,22 +150,33 @@ export class AuthService {
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
if (!user || isUserDisabled(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = nanoIdGen(16);
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx
|
||||
.deleteFrom('userTokens')
|
||||
.where('userId', '=', user.id)
|
||||
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
|
||||
.execute();
|
||||
|
||||
await this.userTokenRepo.insertUserToken({
|
||||
token: token,
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
|
||||
type: UserTokenType.FORGOT_PASSWORD,
|
||||
await this.userTokenRepo.insertUserToken(
|
||||
{
|
||||
token,
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
|
||||
type: UserTokenType.FORGOT_PASSWORD,
|
||||
},
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
|
||||
const emailTemplate = ForgotPasswordEmail({
|
||||
username: user.name,
|
||||
resetLink: resetLink,
|
||||
@@ -177,7 +209,7 @@ export class AuthService {
|
||||
const user = await this.userRepo.findById(userToken.userId, workspace.id, {
|
||||
includeUserMfa: true,
|
||||
});
|
||||
if (!user || user.deletedAt) {
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
@@ -201,6 +233,13 @@ export class AuthService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
this.auditService.setActorId(user.id);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
});
|
||||
|
||||
const emailTemplate = ChangePasswordEmail({ username: user.name });
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { WorkspaceService } from '../../workspace/services/workspace.service';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
@@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
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';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
@@ -18,6 +23,7 @@ export class SignupService {
|
||||
private workspaceService: WorkspaceService,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async signup(
|
||||
@@ -36,7 +42,7 @@ export class SignupService {
|
||||
);
|
||||
}
|
||||
|
||||
return await executeTx(
|
||||
const user = await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
// create user
|
||||
@@ -66,6 +72,24 @@ export class SignupService {
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_CREATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: 'signup',
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async initialSetup(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { isUserDisabled } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
@@ -24,7 +25,7 @@ export class TokenService {
|
||||
) {}
|
||||
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ export class TokenService {
|
||||
}
|
||||
|
||||
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ export class TokenService {
|
||||
}
|
||||
|
||||
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ export class TokenService {
|
||||
expiresIn?: string | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
@@ -53,7 +53,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
||||
|
||||
if (!user || user.deactivatedAt || user.deletedAt) {
|
||||
if (!user || isUserDisabled(user)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ function buildWorkspaceOwnerAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
|
||||
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum WorkspaceCaslSubject {
|
||||
Group = 'group',
|
||||
Attachment = 'attachment',
|
||||
API = 'api_key',
|
||||
Audit = 'audit',
|
||||
}
|
||||
|
||||
export type IWorkspaceAbility =
|
||||
@@ -20,4 +21,5 @@ export type IWorkspaceAbility =
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { WsService } from '../../ws/ws.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('comments')
|
||||
@@ -33,6 +41,9 @@ export class CommentController {
|
||||
private readonly commentRepo: CommentRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly wsService: WsService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -47,12 +58,9 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.commentService.create(
|
||||
const comment = await this.commentService.create(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
@@ -60,6 +68,18 @@ export class CommentController {
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: page.spaceId,
|
||||
metadata: {
|
||||
pageId: page.id,
|
||||
},
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -75,10 +95,8 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.commentService.findByPageId(page.id, pagination);
|
||||
}
|
||||
|
||||
@@ -90,36 +108,34 @@ export class CommentController {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId);
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// must be a space member with edit permission
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException(
|
||||
'You must have space edit permission to edit comments',
|
||||
);
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.commentService.update(comment, dto, user);
|
||||
}
|
||||
|
||||
@@ -131,47 +147,51 @@ export class CommentController {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// must be a space member with edit permission
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Check page-level edit permission first
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
// Check if user is the comment owner
|
||||
const isOwner = comment.creatorId === user.id;
|
||||
|
||||
if (isOwner) {
|
||||
/*
|
||||
// Check if comment has children from other users
|
||||
const hasChildrenFromOthers =
|
||||
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
} else {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// Owner can delete if no children from other users
|
||||
if (!hasChildrenFromOthers) {
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// If has children from others, only space admin can delete
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'Only space admins can delete comments with replies from other users',
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
);
|
||||
}*/
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentDeleted',
|
||||
pageId: comment.pageId,
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_DELETED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: comment.pageId,
|
||||
creatorId: comment.creatorId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
||||
import { WsService } from '../../ws/ws.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
@@ -25,6 +26,7 @@ export class CommentService {
|
||||
constructor(
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private wsService: WsService,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||
private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -63,17 +65,22 @@ export class CommentService {
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.commentRepo.insertComment({
|
||||
const inserted = await this.commentRepo.insertComment({
|
||||
pageId: page.id,
|
||||
content: commentContent,
|
||||
selection: createCommentDto?.selection?.substring(0, 250),
|
||||
type: 'inline',
|
||||
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
|
||||
type: createCommentDto.type ?? 'page',
|
||||
parentCommentId: createCommentDto?.parentCommentId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
const comment = await this.commentRepo.findById(inserted.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
@@ -99,6 +106,12 @@ export class CommentService {
|
||||
createCommentDto.parentCommentId,
|
||||
);
|
||||
|
||||
this.wsService.emitCommentEvent(page.spaceId, page.id, {
|
||||
operation: 'commentCreated',
|
||||
pageId: page.id,
|
||||
comment,
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -154,6 +167,12 @@ export class CommentService {
|
||||
comment.editedAt = editedAt;
|
||||
comment.updatedAt = editedAt;
|
||||
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment,
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@@ -11,6 +11,10 @@ export class CreateCommentDto {
|
||||
@IsString()
|
||||
selection: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['inline', 'page'])
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentCommentId: string;
|
||||
|
||||
@@ -14,11 +14,14 @@ import { SearchModule } from './search/search.module';
|
||||
import { SpaceModule } from './space/space.module';
|
||||
import { GroupModule } from './group/group.module';
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
import { PageAccessModule } from './page/page-access/page-access.module';
|
||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { BaseModule } from './base/base.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +35,7 @@ import { BaseModule } from './base/base.module';
|
||||
SpaceModule,
|
||||
GroupModule,
|
||||
CaslModule,
|
||||
PageAccessModule,
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
@@ -40,14 +44,21 @@ import { BaseModule } from './base/base.module';
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
const excludedRoutes = [
|
||||
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||
];
|
||||
|
||||
consumer
|
||||
.apply(DomainMiddleware)
|
||||
.exclude(
|
||||
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||
)
|
||||
.exclude(...excludedRoutes)
|
||||
.forRoutes('*');
|
||||
|
||||
consumer
|
||||
.apply(AuditContextMiddleware)
|
||||
.exclude(...excludedRoutes)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
@@ -25,6 +30,7 @@ export class GroupUserService {
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getGroupUsers(
|
||||
@@ -72,6 +78,20 @@ export class GroupUserService {
|
||||
.values(groupUsersToInsert)
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
.execute();
|
||||
|
||||
for (const user of validUsers) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
after: {
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserFromGroup(
|
||||
@@ -115,8 +135,24 @@ export class GroupUserService {
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_MEMBER_REMOVED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
before: {
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
groupName: group.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import { diffAuditTrackedFields } from '../../../common/helpers';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
@@ -29,6 +35,7 @@ export class GroupService {
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
|
||||
@@ -74,6 +81,18 @@ export class GroupService {
|
||||
);
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_CREATED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: createdGroup.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: createdGroup.name,
|
||||
description: createdGroup.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return createdGroup;
|
||||
}
|
||||
|
||||
@@ -95,6 +114,8 @@ export class GroupService {
|
||||
throw new BadRequestException('You cannot update a default group');
|
||||
}
|
||||
|
||||
const groupBefore = { name: group.name, description: group.description };
|
||||
|
||||
if (updateGroupDto.name) {
|
||||
const existingGroup = await this.groupRepo.findByName(
|
||||
updateGroupDto.name,
|
||||
@@ -121,6 +142,22 @@ export class GroupService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const changes = diffAuditTrackedFields(
|
||||
['name', 'description'],
|
||||
updateGroupDto,
|
||||
groupBefore,
|
||||
group,
|
||||
);
|
||||
|
||||
if (changes) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_UPDATED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: group.id,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -154,6 +191,18 @@ export class GroupService {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_DELETED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
before: {
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAndValidateGroup(
|
||||
|
||||
@@ -3,6 +3,7 @@ export const NotificationType = {
|
||||
COMMENT_CREATED: 'comment.created',
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
@@ -4,10 +4,9 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { WsModule } from '../../ws/ws.module';
|
||||
|
||||
@Module({
|
||||
imports: [WsModule],
|
||||
imports: [],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
NotificationService,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
@@ -33,7 +34,8 @@ export class NotificationProcessor
|
||||
job: Job<
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob,
|
||||
| IPageMentionNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
@@ -66,6 +68,14 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_PERMISSION_GRANTED: {
|
||||
await this.pageNotificationService.processPermissionGranted(
|
||||
job.data as IPermissionGrantedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
|
||||
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
|
||||
@@ -22,6 +23,7 @@ export class CommentNotificationService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
) {}
|
||||
|
||||
@@ -59,12 +61,19 @@ export class CommentNotificationService {
|
||||
const allCandidateIds = [
|
||||
...new Set([...mentionedUserIds, ...recipientIds]),
|
||||
];
|
||||
const usersWithAccess =
|
||||
const usersWithSpaceAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
allCandidateIds,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
|
||||
for (const userId of mentionedUserIds) {
|
||||
if (!usersWithAccess.has(userId)) continue;
|
||||
|
||||
@@ -146,6 +155,13 @@ export class CommentNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[commentCreatorId],
|
||||
);
|
||||
if (hasPageAccess.length === 0) return;
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: commentCreatorId,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
|
||||
import {
|
||||
IPageMentionNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,6 +19,7 @@ export class PageNotificationService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||
@@ -28,12 +34,19 @@ export class PageNotificationService {
|
||||
if (newMentions.length === 0) return;
|
||||
|
||||
const candidateUserIds = newMentions.map((m) => m.userId);
|
||||
const usersWithAccess =
|
||||
const usersWithSpaceAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
candidateUserIds,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
|
||||
const accessibleMentions = newMentions.filter((m) =>
|
||||
usersWithAccess.has(m.userId),
|
||||
);
|
||||
@@ -97,6 +110,52 @@ export class PageNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async processPermissionGranted(
|
||||
data: IPermissionGrantedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const usersWithSpaceAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
|
||||
|
||||
if (usersWithSpaceAccess.size === 0) return;
|
||||
|
||||
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, basePageUrl } = context;
|
||||
const accessLabel = role === 'writer' ? 'edit' : 'view';
|
||||
|
||||
for (const userId of usersWithSpaceAccess) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_PERMISSION_GRANTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
data: { role },
|
||||
});
|
||||
|
||||
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
PermissionGrantedEmail({
|
||||
actorName: actor.name,
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
accessLabel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PageAccessService } from './page-access.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PageAccessService],
|
||||
exports: [PageAccessService],
|
||||
})
|
||||
export class PageAccessModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
|
||||
@Injectable()
|
||||
export class PageAccessService {
|
||||
constructor(
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate user can view page, throws ForbiddenException if not.
|
||||
* If page has restrictions: page-level permission determines access.
|
||||
* If no restrictions: space-level permission determines access.
|
||||
*/
|
||||
async validateCanView(page: Page, user: User): Promise<void> {
|
||||
// TODO: cache by pageId and userId.
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
|
||||
// User must be at least a space member
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
|
||||
user.id,
|
||||
page.id,
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user can view page AND return effective canEdit permission.
|
||||
* Combines access check + edit permission in a single query pass.
|
||||
*/
|
||||
async validateCanViewWithPermissions(
|
||||
page: Page,
|
||||
user: User,
|
||||
): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const { hasAnyRestriction, canAccess, canEdit } =
|
||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
||||
|
||||
if (hasAnyRestriction && !canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return {
|
||||
canEdit: hasAnyRestriction
|
||||
? canEdit
|
||||
: ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||
hasRestriction: hasAnyRestriction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user can edit page, throws ForbiddenException if not.
|
||||
* If page has restrictions: page-level writer permission determines access.
|
||||
* If no restrictions: space-level edit permission determines access.
|
||||
*/
|
||||
async validateCanEdit(
|
||||
page: Page,
|
||||
user: User,
|
||||
): Promise<{ hasRestriction: boolean }> {
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
|
||||
// User must be at least a space member
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const { hasAnyRestriction, canEdit } =
|
||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
||||
|
||||
if (hasAnyRestriction) {
|
||||
// Page has restrictions - use page-level permission
|
||||
if (!canEdit) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
} else {
|
||||
// No restrictions - use space-level permission
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
return { hasRestriction: hasAnyRestriction };
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { PageAccessService } from './page-access/page-access.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||
@@ -24,7 +26,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { SidebarPageDto } from './dto/sidebar-page.dto';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -39,6 +41,12 @@ import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { getPageTitle } from '../../common/helpers';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -48,6 +56,8 @@ export class PageController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -65,10 +75,10 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const { canEdit, hasRestriction } =
|
||||
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
||||
|
||||
const permissions = { canEdit, hasRestriction };
|
||||
|
||||
if (dto.format && dto.format !== 'json' && page.content) {
|
||||
const contentOutput =
|
||||
@@ -78,10 +88,11 @@ export class PageController {
|
||||
return {
|
||||
...page,
|
||||
content: contentOutput,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
return page;
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -91,12 +102,28 @@ export class PageController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
createPageDto.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
if (createPageDto.parentPageId) {
|
||||
// Creating under a parent page - check edit permission on parent
|
||||
const parentPage = await this.pageRepo.findById(
|
||||
createPageDto.parentPageId,
|
||||
);
|
||||
if (
|
||||
!parentPage ||
|
||||
parentPage.deletedAt ||
|
||||
parentPage.spaceId !== createPageDto.spaceId
|
||||
) {
|
||||
throw new NotFoundException('Parent page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanEdit(parentPage, user);
|
||||
} else {
|
||||
// Creating at root level - require space-level permission
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
createPageDto.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
const page = await this.pageService.create(
|
||||
@@ -105,6 +132,24 @@ export class PageController {
|
||||
createPageDto,
|
||||
);
|
||||
|
||||
const { canEdit, hasRestriction } =
|
||||
await this.pageAccessService.validateCanViewWithPermissions(page, user);
|
||||
|
||||
const permissions = { canEdit, hasRestriction };
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_CREATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
after: {
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
createPageDto.format &&
|
||||
createPageDto.format !== 'json' &&
|
||||
@@ -114,10 +159,10 @@ export class PageController {
|
||||
createPageDto.format === 'markdown'
|
||||
? jsonToMarkdown(page.content)
|
||||
: jsonToHtml(page.content);
|
||||
return { ...page, content: contentOutput };
|
||||
return { ...page, content: contentOutput, permissions };
|
||||
}
|
||||
|
||||
return page;
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -129,10 +174,10 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const { hasRestriction } = await this.pageAccessService.validateCanEdit(
|
||||
page,
|
||||
user,
|
||||
);
|
||||
|
||||
const updatedPage = await this.pageService.update(
|
||||
page,
|
||||
@@ -140,6 +185,8 @@ export class PageController {
|
||||
user,
|
||||
);
|
||||
|
||||
const permissions = { canEdit: true, hasRestriction };
|
||||
|
||||
if (
|
||||
updatePageDto.format &&
|
||||
updatePageDto.format !== 'json' &&
|
||||
@@ -149,10 +196,10 @@ export class PageController {
|
||||
updatePageDto.format === 'markdown'
|
||||
? jsonToMarkdown(updatedPage.content)
|
||||
: jsonToHtml(updatedPage.content);
|
||||
return { ...updatedPage, content: contentOutput };
|
||||
return { ...updatedPage, content: contentOutput, permissions };
|
||||
}
|
||||
|
||||
return updatedPage;
|
||||
return { ...updatedPage, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -178,16 +225,45 @@ export class PageController {
|
||||
);
|
||||
}
|
||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DELETED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: page.id,
|
||||
slugId: page.slugId,
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Soft delete requires page manage permissions
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
// User with edit permission can delete
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.pageService.removePage(
|
||||
deletePageDto.pageId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_TRASHED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: page.id,
|
||||
slugId: page.slugId,
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +280,30 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// only users with "can edit" space level permission can restore pages
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
// make sure they have page level access to the page
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_RESTORED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
after: {
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||
includeHasChildren: true,
|
||||
});
|
||||
@@ -235,6 +328,7 @@ export class PageController {
|
||||
|
||||
return this.pageService.getRecentSpacePages(
|
||||
recentPageDto.spaceId,
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
@@ -255,12 +349,13 @@ export class PageController {
|
||||
deletedPageDto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.getDeletedSpacePages(
|
||||
deletedPageDto.spaceId,
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
@@ -278,10 +373,7 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
|
||||
}
|
||||
@@ -297,13 +389,14 @@ export class PageController {
|
||||
throw new NotFoundException('Page history not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
history.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
// Get the page to check permissions
|
||||
const page = await this.pageRepo.findById(history.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
@@ -335,7 +428,18 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||
const spaceCanEdit = ability.can(
|
||||
SpaceCaslAction.Edit,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
|
||||
return this.pageService.getSidebarPages(
|
||||
spaceId,
|
||||
pagination,
|
||||
dto.pageId,
|
||||
user.id,
|
||||
spaceCanEdit,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -365,7 +469,30 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
|
||||
// Check page-level edit permission on the source page
|
||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
||||
|
||||
// Moves only accessible pages; inaccessible child pages become root pages in original space
|
||||
const { childPageIds } = await this.pageService.movePageToSpace(
|
||||
movedPage,
|
||||
dto.spaceId,
|
||||
user.id,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_MOVED_TO_SPACE,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: movedPage.id,
|
||||
spaceId: movedPage.spaceId,
|
||||
changes: {
|
||||
before: { spaceId: movedPage.spaceId },
|
||||
after: { spaceId: dto.spaceId },
|
||||
},
|
||||
metadata: {
|
||||
title: getPageTitle(movedPage.title),
|
||||
...(childPageIds.length > 0 && { childPageIds }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -376,6 +503,12 @@ export class PageController {
|
||||
throw new NotFoundException('Page to copy not found');
|
||||
}
|
||||
|
||||
// Check page-level view permission on the source page (need to read to copy)
|
||||
// Inaccessible child branches are automatically skipped during duplication
|
||||
await this.pageAccessService.validateCanView(copiedPage, user);
|
||||
|
||||
let result;
|
||||
|
||||
// If spaceId is provided, it's a copy to different space
|
||||
if (dto.spaceId) {
|
||||
const abilities = await Promise.all([
|
||||
@@ -391,7 +524,27 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
|
||||
result = await this.pageService.duplicatePage(
|
||||
copiedPage,
|
||||
dto.spaceId,
|
||||
user,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DUPLICATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: result.id,
|
||||
spaceId: dto.spaceId,
|
||||
metadata: {
|
||||
sourcePageId: copiedPage.id,
|
||||
title: getPageTitle(copiedPage.title),
|
||||
sourceSpaceId: copiedPage.spaceId,
|
||||
targetSpaceId: dto.spaceId,
|
||||
...(result.childPageIds.length > 0 && {
|
||||
childPageIds: result.childPageIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If no spaceId, it's a duplicate in same space
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
@@ -402,8 +555,28 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
||||
result = await this.pageService.duplicatePage(
|
||||
copiedPage,
|
||||
undefined,
|
||||
user,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DUPLICATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: result.id,
|
||||
spaceId: copiedPage.spaceId,
|
||||
metadata: {
|
||||
sourcePageId: copiedPage.id,
|
||||
title: getPageTitle(copiedPage.title),
|
||||
...(result.childPageIds.length > 0 && {
|
||||
childPageIds: result.childPageIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -418,10 +591,23 @@ export class PageController {
|
||||
user,
|
||||
movedPage.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
// Check page-level edit permission
|
||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
||||
|
||||
// If moving to a new parent, check permission on the target parent
|
||||
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
|
||||
const targetParent = await this.pageRepo.findById(dto.parentPageId);
|
||||
if (!targetParent || targetParent.deletedAt) {
|
||||
throw new NotFoundException('Target parent page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanEdit(targetParent, user);
|
||||
}
|
||||
|
||||
return this.pageService.movePage(dto, movedPage);
|
||||
}
|
||||
|
||||
@@ -433,10 +619,8 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.pageService.getPageBreadCrumbs(page.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
||||
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -55,6 +57,7 @@ export class PageService {
|
||||
|
||||
constructor(
|
||||
private pageRepo: PageRepo,
|
||||
private pagePermissionRepo: PagePermissionRepo,
|
||||
private attachmentRepo: AttachmentRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
@@ -92,7 +95,11 @@ export class PageService {
|
||||
createPageDto.parentPageId,
|
||||
);
|
||||
|
||||
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
|
||||
if (
|
||||
!parentPage ||
|
||||
parentPage.deletedAt ||
|
||||
parentPage.spaceId !== createPageDto.spaceId
|
||||
) {
|
||||
throw new NotFoundException('Parent page not found');
|
||||
}
|
||||
|
||||
@@ -262,6 +269,8 @@ export class PageService {
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
pageId?: string,
|
||||
userId?: string,
|
||||
spaceCanEdit?: boolean,
|
||||
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
@@ -286,8 +295,8 @@ export class PageService {
|
||||
query = query.where('parentPageId', 'is', null);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: 250,
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: 200,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
@@ -303,10 +312,99 @@ export class PageService {
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (userId && result.items.length > 0) {
|
||||
const hasRestrictions =
|
||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
|
||||
if (!hasRestrictions) {
|
||||
result.items = result.items.map((p: any) => ({
|
||||
...p,
|
||||
canEdit: spaceCanEdit ?? true,
|
||||
}));
|
||||
} else {
|
||||
const pageIds = result.items.map((p: any) => p.id);
|
||||
|
||||
const accessiblePages =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
|
||||
pageIds,
|
||||
userId,
|
||||
);
|
||||
|
||||
const permissionMap = new Map(
|
||||
accessiblePages.map((p) => [p.id, p.canEdit]),
|
||||
);
|
||||
|
||||
result.items = result.items
|
||||
.filter((p: any) => permissionMap.has(p.id))
|
||||
.map((p: any) => ({
|
||||
...p,
|
||||
canEdit: permissionMap.get(p.id) && (spaceCanEdit ?? true),
|
||||
}));
|
||||
|
||||
const pagesWithChildren = result.items.filter(
|
||||
(p: any) => p.hasChildren,
|
||||
);
|
||||
if (pagesWithChildren.length > 0) {
|
||||
const parentIds = pagesWithChildren.map((p: any) => p.id);
|
||||
const parentsWithAccessibleChildren =
|
||||
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
|
||||
parentIds,
|
||||
userId,
|
||||
);
|
||||
const hasAccessibleChildrenSet = new Set(
|
||||
parentsWithAccessibleChildren,
|
||||
);
|
||||
|
||||
result.items = result.items.map((p: any) => ({
|
||||
...p,
|
||||
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async movePageToSpace(rootPage: Page, spaceId: string) {
|
||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
||||
let childPageIds: string[] = [];
|
||||
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: false,
|
||||
});
|
||||
|
||||
// Filter to only accessible pages while maintaining tree integrity
|
||||
const accessiblePages = await this.filterAccessibleTreePages(
|
||||
allPages,
|
||||
rootPage.id,
|
||||
userId,
|
||||
rootPage.spaceId,
|
||||
);
|
||||
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
||||
|
||||
// Find inaccessible pages whose parent is being moved - these need to be orphaned
|
||||
const pagesToOrphan = allPages.filter(
|
||||
(p) =>
|
||||
!accessibleIds.has(p.id) &&
|
||||
p.parentPageId &&
|
||||
accessibleIds.has(p.parentPageId),
|
||||
);
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
// Orphan inaccessible child pages (make them root pages in original space)
|
||||
for (const page of pagesToOrphan) {
|
||||
const orphanPosition = await this.nextPagePosition(
|
||||
rootPage.spaceId,
|
||||
null,
|
||||
);
|
||||
await this.pageRepo.updatePage(
|
||||
{ parentPageId: null, position: orphanPosition },
|
||||
page.id,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
// Update root page
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
await this.pageRepo.updatePage(
|
||||
@@ -314,52 +412,62 @@ export class PageService {
|
||||
rootPage.id,
|
||||
trx,
|
||||
);
|
||||
const pageIds = await this.pageRepo
|
||||
.getPageAndDescendants(rootPage.id, { includeContent: false })
|
||||
.then((pages) => pages.map((page) => page.id));
|
||||
// The first id is the root page id
|
||||
if (pageIds.length > 1) {
|
||||
// Update sub pages
|
||||
|
||||
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
||||
|
||||
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
await this.pageRepo.updatePages(
|
||||
{ spaceId },
|
||||
pageIds.filter((id) => id !== rootPage.id),
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
if (pageIdsToMove.length > 0) {
|
||||
// Clear page-level permissions - moved pages inherit destination space permissions
|
||||
// (page_permissions cascade deletes via foreign key)
|
||||
await trx
|
||||
.deleteFrom('pageAccess')
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// update spaceId in shares
|
||||
await trx
|
||||
.updateTable('shares')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIds)
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update comments
|
||||
await trx
|
||||
.updateTable('comments')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIds)
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
pageIds,
|
||||
pageIdsToMove,
|
||||
trx,
|
||||
);
|
||||
|
||||
// Update watchers and remove those without access to new space
|
||||
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
|
||||
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIds,
|
||||
pageId: pageIdsToMove,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { childPageIds };
|
||||
}
|
||||
|
||||
async duplicatePage(
|
||||
@@ -381,10 +489,18 @@ export class PageService {
|
||||
nextPosition = await this.nextPagePosition(spaceId);
|
||||
}
|
||||
|
||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
// Filter to only accessible pages while maintaining tree integrity
|
||||
const pages = await this.filterAccessibleTreePages(
|
||||
allPages,
|
||||
rootPage.id,
|
||||
authUser.id,
|
||||
rootPage.spaceId,
|
||||
);
|
||||
|
||||
const pageMap = new Map<string, CopyPageMapEntry>();
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
@@ -570,10 +686,12 @@ export class PageService {
|
||||
});
|
||||
|
||||
const hasChildren = pages.length > 1;
|
||||
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
|
||||
|
||||
return {
|
||||
...duplicatedPage,
|
||||
hasChildren,
|
||||
childPageIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,7 +710,11 @@ export class PageService {
|
||||
// changing the page's parent
|
||||
if (dto.parentPageId) {
|
||||
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
||||
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
|
||||
if (
|
||||
!parentPage ||
|
||||
parentPage.deletedAt ||
|
||||
parentPage.spaceId !== movedPage.spaceId
|
||||
) {
|
||||
throw new NotFoundException('Parent page not found');
|
||||
}
|
||||
parentPageId = parentPage.id;
|
||||
@@ -623,7 +745,6 @@ export class PageService {
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
@@ -639,30 +760,21 @@ export class PageService {
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
])
|
||||
.select(
|
||||
exp
|
||||
.selectFrom('pages as child')
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.fn.countAll(), '>', 0)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('count'),
|
||||
)
|
||||
.whereRef('child.parentPageId', '=', 'id')
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.limit(1)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
//.select((eb) => this.withHasChildren(eb))
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll()
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
).as('hasChildren'),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return ancestors.reverse();
|
||||
@@ -670,23 +782,72 @@ export class PageService {
|
||||
|
||||
async getRecentSpacePages(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
||||
const result = await this.pageRepo.getRecentPagesInSpace(
|
||||
spaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getRecentPages(
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
return this.pageRepo.getRecentPages(userId, pagination);
|
||||
const result = await this.pageRepo.getRecentPages(userId, pagination);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getDeletedSpacePages(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<CursorPaginationResult<Page>> {
|
||||
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||
const result = await this.pageRepo.getDeletedPagesInSpace(
|
||||
spaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
const pageIds = result.items.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||
@@ -776,4 +937,61 @@ export class PageService {
|
||||
|
||||
return prosemirrorJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
|
||||
* A page is included only if:
|
||||
* 1. The user has access to it
|
||||
* 2. Its parent is also included (or it's the root page)
|
||||
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
|
||||
*/
|
||||
private async filterAccessibleTreePages<
|
||||
T extends { id: string; parentPageId: string | null },
|
||||
>(
|
||||
pages: T[],
|
||||
rootPageId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<T[]> {
|
||||
if (pages.length === 0) return [];
|
||||
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
|
||||
{
|
||||
pageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
},
|
||||
);
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
|
||||
// Prune: include a page only if it's accessible AND its parent chain to root is included
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
// Process pages in a way that ensures parents are processed before children
|
||||
// We do this by iterating until no more pages can be added
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const page of pages) {
|
||||
if (includedIds.has(page.id)) continue;
|
||||
if (!accessibleSet.has(page.id)) continue;
|
||||
|
||||
// Root page: include if accessible
|
||||
if (page.id === rootPageId) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-root: include if parent is already included
|
||||
if (page.parentPageId && includedIds.has(page.parentPageId)) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.filter((p) => includedIds.has(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class TrashCleanupService {
|
||||
private readonly logger = new Logger(TrashCleanupService.name);
|
||||
private readonly RETENTION_DAYS = 30;
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@@ -21,36 +22,46 @@ export class TrashCleanupService {
|
||||
try {
|
||||
this.logger.debug('Starting trash cleanup job');
|
||||
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
|
||||
|
||||
// Get all pages that were deleted more than 30 days ago
|
||||
const oldDeletedPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'spaceId', 'workspaceId'])
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
const workspaces = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'trashRetentionDays'])
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
if (oldDeletedPages.length === 0) {
|
||||
this.logger.debug('No old trash items to clean up');
|
||||
return;
|
||||
}
|
||||
let totalCleaned = 0;
|
||||
|
||||
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
|
||||
for (const workspace of workspaces) {
|
||||
const retentionDays =
|
||||
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
||||
|
||||
// Process each page
|
||||
for (const page of oldDeletedPages) {
|
||||
try {
|
||||
await this.cleanupPage(page.id);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
||||
|
||||
const oldDeletedPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
.execute();
|
||||
|
||||
for (const page of oldDeletedPages) {
|
||||
try {
|
||||
await this.cleanupPage(page.id);
|
||||
totalCleaned++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Trash cleanup job completed');
|
||||
this.logger.debug(
|
||||
totalCleaned > 0
|
||||
? `Trash cleanup completed: ${totalCleaned} pages cleaned`
|
||||
: 'No old trash items to clean up',
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Trash cleanup job failed',
|
||||
|
||||
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const tsquery = require('pg-tsquery')();
|
||||
@@ -18,6 +19,7 @@ export class SearchService {
|
||||
private pageRepo: PageRepo,
|
||||
private shareRepo: ShareRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async searchPage(
|
||||
@@ -115,10 +117,23 @@ export class SearchService {
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
queryResults = await queryResults.execute();
|
||||
let results: any[] = await queryResults.execute();
|
||||
|
||||
// Filter results by page-level permissions (if user is authenticated)
|
||||
if (opts.userId && results.length > 0) {
|
||||
const pageIds = results.map((r: any) => r.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId: opts.userId,
|
||||
spaceId: searchParams.spaceId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
results = results.filter((r: any) => accessibleSet.has(r.id));
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
const searchResults = queryResults.map((result: SearchResponseDto) => {
|
||||
const searchResults = results.map((result: SearchResponseDto) => {
|
||||
if (result.highlight) {
|
||||
result.highlight = result.highlight
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
@@ -183,6 +198,7 @@ export class SearchService {
|
||||
let pageSearch = this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
|
||||
.select((eb) => this.pageRepo.withSpace(eb))
|
||||
.where((eb) =>
|
||||
eb(
|
||||
sql`LOWER(f_unaccent(pages.title))`,
|
||||
@@ -194,19 +210,33 @@ export class SearchService {
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit);
|
||||
|
||||
// only search spaces the user has access to
|
||||
// search all spaces the user has access to, prioritizing the current space
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
|
||||
if (suggestion?.spaceId) {
|
||||
if (userSpaceIds.includes(suggestion.spaceId)) {
|
||||
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
|
||||
pages = await pageSearch.execute();
|
||||
}
|
||||
} else if (userSpaceIds?.length > 0) {
|
||||
// we need this check or the query will throw an error if the userSpaceIds array is empty
|
||||
if (userSpaceIds?.length > 0) {
|
||||
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
|
||||
|
||||
if (suggestion?.spaceId) {
|
||||
pageSearch = pageSearch.orderBy(
|
||||
sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`,
|
||||
'asc',
|
||||
);
|
||||
}
|
||||
|
||||
pages = await pageSearch.execute();
|
||||
}
|
||||
|
||||
// Filter by page-level permissions
|
||||
if (pages.length > 0) {
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
pages = pages.filter((p) => accessibleSet.has(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
return { users, groups, pages };
|
||||
|
||||
@@ -5,18 +5,14 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { ShareService } from './share.service';
|
||||
import {
|
||||
CreateShareDto,
|
||||
@@ -26,22 +22,31 @@ import {
|
||||
UpdateShareDto,
|
||||
} from './dto/share.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { hasLicenseOrEE } from '../../common/helpers';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
export class ShareController {
|
||||
constructor(
|
||||
private readonly shareService: ShareService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -119,10 +124,7 @@ export class ShareController {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.shareService.getShareForPage(page.id, workspace.id);
|
||||
}
|
||||
@@ -140,9 +142,17 @@ export class ShareController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
// User must be able to edit the page to create a share
|
||||
//TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
|
||||
// rather, use space level permission and workspace/space level sharing restriction
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
// Prevent sharing restricted pages
|
||||
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
page.id,
|
||||
);
|
||||
if (isRestricted) {
|
||||
throw new BadRequestException('Cannot share a restricted page');
|
||||
}
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
@@ -153,12 +163,25 @@ export class ShareController {
|
||||
throw new ForbiddenException('Public sharing is disabled');
|
||||
}
|
||||
|
||||
return this.shareService.createShare({
|
||||
const share = await this.shareService.createShare({
|
||||
page,
|
||||
authUserId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
createShareDto,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SHARE_CREATED,
|
||||
resourceType: AuditResource.SHARE,
|
||||
resourceId: share.id,
|
||||
spaceId: page.spaceId,
|
||||
metadata: {
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -170,11 +193,14 @@ export class ShareController {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
const page = await this.pageRepo.findById(share.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// User must be able to edit the page to update its share
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.shareService.updateShare(share.id, updateShareDto);
|
||||
}
|
||||
|
||||
@@ -187,12 +213,28 @@ export class ShareController {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
const page = await this.pageRepo.findById(share.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// User must be able to edit the page to delete its share
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.shareRepo.deleteShare(share.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SHARE_DELETED,
|
||||
resourceType: AuditResource.SHARE,
|
||||
resourceId: share.id,
|
||||
spaceId: share.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: share.pageId,
|
||||
spaceId: share.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { updateAttachmentAttr } from './share.util';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
@@ -31,6 +32,7 @@ export class ShareService {
|
||||
constructor(
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
@@ -41,12 +43,20 @@ export class ShareService {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
if (share.includeSubPages) {
|
||||
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
|
||||
includeContent: false,
|
||||
});
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||
if (isRestricted) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
return { share, pageTree: pageList };
|
||||
if (share.includeSubPages) {
|
||||
const pageTree =
|
||||
await this.pageRepo.getPageAndDescendantsExcludingRestricted(
|
||||
share.pageId,
|
||||
{ includeContent: false },
|
||||
);
|
||||
|
||||
return { share, pageTree };
|
||||
} else {
|
||||
return { share, pageTree: [] };
|
||||
}
|
||||
@@ -112,6 +122,13 @@ export class ShareService {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
// Block access to restricted pages
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
|
||||
if (isRestricted) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
return { page, share };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -17,6 +18,11 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberService {
|
||||
@@ -26,6 +32,7 @@ export class SpaceMemberService {
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async addUserToSpace(
|
||||
@@ -90,7 +97,6 @@ export class SpaceMemberService {
|
||||
authUser: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
// await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
|
||||
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) {
|
||||
@@ -164,8 +170,45 @@ export class SpaceMemberService {
|
||||
|
||||
if (membersToAdd.length > 0) {
|
||||
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
|
||||
} else {
|
||||
// either they are already members or do not exist on the workspace
|
||||
|
||||
// Audit log for each member added
|
||||
for (const user of validUsers) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ADDED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
memberType: 'user',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of validGroups) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ADDED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
memberType: 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +273,23 @@ export class SpaceMemberService {
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_REMOVED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
before: { role: spaceMember.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: spaceMember.userId,
|
||||
groupId: spaceMember.groupId,
|
||||
memberType: spaceMember.userId ? 'user' : 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSpaceMemberRole(
|
||||
@@ -280,6 +340,24 @@ export class SpaceMemberService {
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
before: { role: spaceMember.role },
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: spaceMember.userId,
|
||||
groupId: spaceMember.groupId,
|
||||
memberType: spaceMember.userId ? 'user' : 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateLastAdmin(spaceId: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import { diffAuditTrackedFields } from '../../../common/helpers';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
@@ -32,6 +39,7 @@ export class SpaceService {
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async createSpace(
|
||||
@@ -63,6 +71,19 @@ export class SpaceService {
|
||||
trx,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_CREATED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: space.id,
|
||||
spaceId: space.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { ...space, memberCount: 1 };
|
||||
}
|
||||
|
||||
@@ -124,28 +145,74 @@ export class SpaceService {
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSharingSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateSpaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateSpaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
description: updateSpaceDto.description,
|
||||
slug: updateSpaceDto.slug,
|
||||
},
|
||||
const spaceBefore = await this.spaceRepo.findById(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
);
|
||||
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
|
||||
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
let updatedSpace: Space;
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||
const prev = settingsBefore?.sharing?.disabled ?? false;
|
||||
if (prev !== updateSpaceDto.disablePublicSharing) {
|
||||
before.disablePublicSharing = prev;
|
||||
after.disablePublicSharing = updateSpaceDto.disablePublicSharing;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSharingSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateSpaceDto.disablePublicSharing,
|
||||
trx,
|
||||
);
|
||||
|
||||
if (updateSpaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx);
|
||||
}
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
description: updateSpaceDto.description,
|
||||
slug: updateSpaceDto.slug,
|
||||
},
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
const columnChanges = diffAuditTrackedFields(
|
||||
['name', 'slug', 'description'],
|
||||
updateSpaceDto,
|
||||
spaceBefore,
|
||||
updatedSpace,
|
||||
);
|
||||
if (columnChanges) {
|
||||
Object.assign(before, columnChanges.before);
|
||||
Object.assign(after, columnChanges.after);
|
||||
}
|
||||
|
||||
if (Object.keys(after).length > 0) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_UPDATED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: updateSpaceDto.spaceId,
|
||||
spaceId: updateSpaceDto.spaceId,
|
||||
changes: { before, after },
|
||||
});
|
||||
}
|
||||
|
||||
return updatedSpace;
|
||||
}
|
||||
|
||||
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
|
||||
@@ -174,5 +241,19 @@ export class SpaceService {
|
||||
|
||||
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
|
||||
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_DELETED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: spaceId,
|
||||
spaceId: spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
description: space.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { comparePasswordHash } from 'src/common/helpers/utils';
|
||||
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private userRepo: UserRepo) {}
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async findById(userId: string, workspaceId: string) {
|
||||
return this.userRepo.findById(userId, workspaceId);
|
||||
@@ -51,6 +60,8 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
||||
|
||||
if (updateUserDto.name) {
|
||||
user.name = updateUserDto.name;
|
||||
}
|
||||
@@ -91,6 +102,23 @@ export class UserService {
|
||||
delete updateUserDto.confirmPassword;
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
|
||||
|
||||
const changes = diffAuditTrackedFields(
|
||||
['name', 'email'],
|
||||
updateUserDto,
|
||||
userBefore,
|
||||
user,
|
||||
);
|
||||
|
||||
if (changes) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_UPDATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: userId,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export class WorkspaceController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('members/deactivate')
|
||||
async deactivateWorkspaceMember(
|
||||
@Body() dto: RemoveWorkspaceUserDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -118,6 +119,23 @@ export class WorkspaceController {
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.workspaceService.deactivateUser(user, dto.userId, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('members/activate')
|
||||
async activateWorkspaceMember(
|
||||
@Body() dto: RemoveWorkspaceUserDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.workspaceService.activateUser(user, dto.userId, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from './create-workspace.dto';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@@ -34,4 +41,13 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mcpEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -33,6 +34,11 @@ import {
|
||||
validateAllowedEmail,
|
||||
validateSsoEnforcement,
|
||||
} from '../../auth/auth.util';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
||||
@@ -180,6 +187,24 @@ export class WorkspaceInvitationService {
|
||||
workspace.hostname,
|
||||
);
|
||||
});
|
||||
|
||||
// Audit log for each invitation created
|
||||
for (const invitation of invites) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_CREATED,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
changes: {
|
||||
after: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
groupIds: invitation.groupIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +321,23 @@ export class WorkspaceInvitationService {
|
||||
});
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_CREATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: newUser.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: newUser.name,
|
||||
email: newUser.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: 'invitation',
|
||||
invitationId: invitation.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.environmentService.isCloud()) {
|
||||
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
|
||||
workspaceId: workspace.id,
|
||||
@@ -339,17 +381,48 @@ export class WorkspaceInvitationService {
|
||||
invitedByUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_RESENT,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
metadata: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revokeInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select(['id', 'email', 'role'])
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
await this.db
|
||||
.deleteFrom('workspaceInvitations')
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (invitation) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_REVOKED,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
changes: {
|
||||
before: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getInvitationLinkById(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -31,11 +32,19 @@ import { v4 } from 'uuid';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import {
|
||||
generateRandomSuffixNumbers,
|
||||
diffAuditTrackedFields,
|
||||
} from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -57,6 +66,7 @@ export class WorkspaceService {
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -280,7 +290,7 @@ export class WorkspaceService {
|
||||
if (updateWorkspaceDto.enforceSso) {
|
||||
const sso = await this.db
|
||||
.selectFrom('authProviders')
|
||||
.selectAll()
|
||||
.select(['id'])
|
||||
.where('isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
@@ -295,9 +305,7 @@ export class WorkspaceService {
|
||||
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);
|
||||
@@ -313,93 +321,187 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
|
||||
await this.workspaceRepo.updateApiSettings(
|
||||
workspaceId,
|
||||
'restrictToAdmins',
|
||||
updateWorkspaceDto.restrictApiToAdmins,
|
||||
);
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
}
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'search',
|
||||
updateWorkspaceDto.aiSearch,
|
||||
);
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'trashRetentionDays'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||
if (!tableExists) {
|
||||
throw new BadRequestException(
|
||||
'Failed to activate. Make sure pgvector postgres extension is installed.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
// Schedule deletion after 24 hours
|
||||
const deleteJobId = `ai-search-disabled-${workspaceId}`;
|
||||
await this.aiQueue.add(
|
||||
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
|
||||
{ workspaceId },
|
||||
{
|
||||
jobId: deleteJobId,
|
||||
delay: 24 * 60 * 60 * 1000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
);
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
|
||||
) {
|
||||
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateSharingSettings(
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateWorkspaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateWorkspaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteByWorkspaceId(workspaceId);
|
||||
if (
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
|
||||
updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
|
||||
) {
|
||||
before.trashRetentionDays = ws.trashRetentionDays;
|
||||
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||
if (!tableExists) {
|
||||
throw new BadRequestException(
|
||||
'Failed to activate. Make sure pgvector postgres extension is installed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceBefore = await this.workspaceRepo.findById(workspaceId);
|
||||
const settingsBefore = (workspaceBefore?.settings ?? {}) as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
|
||||
const prev = settingsBefore?.api?.restrictToAdmins ?? false;
|
||||
if (prev !== updateWorkspaceDto.restrictApiToAdmins) {
|
||||
before.restrictApiToAdmins = prev;
|
||||
after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins;
|
||||
}
|
||||
await this.workspaceRepo.updateApiSettings(
|
||||
workspaceId,
|
||||
'restrictToAdmins',
|
||||
updateWorkspaceDto.restrictApiToAdmins,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.search ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiSearch) {
|
||||
before.aiSearch = prev;
|
||||
after.aiSearch = updateWorkspaceDto.aiSearch;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'search',
|
||||
updateWorkspaceDto.aiSearch,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.generative ?? false;
|
||||
if (prev !== updateWorkspaceDto.generativeAi) {
|
||||
before.generativeAi = prev;
|
||||
after.generativeAi = updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const prev = settingsBefore?.sharing?.disabled ?? false;
|
||||
if (prev !== updateWorkspaceDto.disablePublicSharing) {
|
||||
before.disablePublicSharing = prev;
|
||||
after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing;
|
||||
}
|
||||
await this.workspaceRepo.updateSharingSettings(
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateWorkspaceDto.disablePublicSharing,
|
||||
trx,
|
||||
);
|
||||
if (updateWorkspaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteByWorkspaceId(workspaceId, trx);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.mcp ?? false;
|
||||
if (prev !== updateWorkspaceDto.mcpEnabled) {
|
||||
before.mcpEnabled = prev;
|
||||
after.mcpEnabled = updateWorkspaceDto.mcpEnabled;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'mcp',
|
||||
updateWorkspaceDto.mcpEnabled,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
if (after.aiSearch === true) {
|
||||
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
|
||||
workspaceId,
|
||||
});
|
||||
} else if (after.aiSearch === false) {
|
||||
const deleteJobId = `ai-search-disabled-${workspaceId}`;
|
||||
await this.aiQueue.add(
|
||||
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
|
||||
{ workspaceId },
|
||||
{
|
||||
jobId: deleteJobId,
|
||||
delay: 24 * 60 * 60 * 1000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withMemberCount: true,
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
const columnChanges = diffAuditTrackedFields(
|
||||
[
|
||||
'name',
|
||||
'logo',
|
||||
'enforceSso',
|
||||
'enforceMfa',
|
||||
'emailDomains',
|
||||
],
|
||||
updateWorkspaceDto,
|
||||
workspaceBefore,
|
||||
workspace,
|
||||
);
|
||||
if (columnChanges) {
|
||||
Object.assign(before, columnChanges.before);
|
||||
Object.assign(after, columnChanges.after);
|
||||
}
|
||||
|
||||
if (Object.keys(after).length > 0) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_UPDATED,
|
||||
resourceType: AuditResource.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
changes: { before, after },
|
||||
});
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
@@ -457,6 +559,16 @@ export class WorkspaceService {
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_ROLE_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: { role: user.role },
|
||||
after: { role: newRole },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async generateHostname(
|
||||
@@ -505,6 +617,105 @@ export class WorkspaceService {
|
||||
return { hostname: this.domainService.getUrl(hostname) };
|
||||
}
|
||||
|
||||
async deactivateUser(
|
||||
authUser: User,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (user.deactivatedAt) {
|
||||
throw new BadRequestException('User is already deactivated');
|
||||
}
|
||||
|
||||
if (authUser.id === userId) {
|
||||
throw new BadRequestException('You cannot deactivate yourself');
|
||||
}
|
||||
|
||||
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||
throw new BadRequestException(
|
||||
'You cannot deactivate a user with owner role',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.role === UserRole.OWNER) {
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_DEACTIVATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async activateUser(
|
||||
authUser: User,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (!user.deactivatedAt) {
|
||||
throw new BadRequestException('User is not deactivated');
|
||||
}
|
||||
|
||||
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||
throw new BadRequestException(
|
||||
'You cannot activate a user with owner role',
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: null },
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_ACTIVATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(
|
||||
authUser: User,
|
||||
userId: string,
|
||||
@@ -564,6 +775,19 @@ export class WorkspaceService {
|
||||
});
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_DELETED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user